From 834a81a6f92b40eba04d5c330ad86b9fbe0ac423 Mon Sep 17 00:00:00 2001 From: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:56:08 +0530 Subject: [PATCH] fix(stats): treat an unusable day window as the default --- src/stats.logs-window.test.ts | 55 +++++++++++++++++++++++++++++++++++ src/stats.ts | 10 +++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/stats.logs-window.test.ts b/src/stats.logs-window.test.ts index 43bbfe46..3a98524c 100644 --- a/src/stats.logs-window.test.ts +++ b/src/stats.logs-window.test.ts @@ -75,3 +75,58 @@ describe("formatRecentLogs day window", () => { expect(out).toContain("2 days"); }); }); + +/** + * The `/stats ` command parses with `parseInt(arg, 10) || 7`, which lets a + * negative through because -1 is truthy, and it then calls getStats() directly. + * getStats() slices its newest-first file list with `slice(0, days)`, so a + * negative trims days off the window and the period renders "last -1 days". + * The HTTP endpoint (resolveStatsDays) and formatRecentLogs guard their own + * windows; getStats is the sink shared by all of them. + */ +describe("getStats day window", () => { + let home: string; + + const entry = (model: string, day: string) => + JSON.stringify({ + timestamp: `${day}T12:00:00.000Z`, + model, + tier: "CHEAP", + cost: 0.01, + baselineCost: 0.02, + savings: 0.5, + latencyMs: 100, + }); + + beforeEach(async () => { + vi.resetModules(); + home = await mkdtemp(join(tmpdir(), "clawrouter-stats-window-")); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => home }; + }); + const logs = join(home, ".openclaw", "blockrun", "logs"); + await mkdir(logs, { recursive: true }); + // Oldest → newest. getLogFiles() sorts and reverses, so NEWEST is index 0. + await writeFile(join(logs, "usage-2026-08-27.jsonl"), entry("oldest-model", "2026-08-27")); + await writeFile(join(logs, "usage-2026-08-28.jsonl"), entry("middle-model", "2026-08-28")); + await writeFile(join(logs, "usage-2026-08-29.jsonl"), entry("newest-model", "2026-08-29")); + }); + + afterEach(async () => { + vi.doUnmock("node:os"); + await rm(home, { recursive: true, force: true }); + }); + + it.each([ + ["negative", -1], + ["zero", 0], + ["NaN", Number.NaN], + ])("falls back to the default window for a %s day count", async (_label, value) => { + const { getStats, DEFAULT_STATS_DAYS } = await import("./stats.js"); + const stats = await getStats(value); + + expect(stats.period).toBe(`last ${DEFAULT_STATS_DAYS} days`); + expect(stats.totalRequests).toBe(3); + }); +}); diff --git a/src/stats.ts b/src/stats.ts index 98404989..d5574726 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -155,8 +155,14 @@ function aggregateDay(date: string, entries: UsageEntry[]): DailyStats { * Get aggregated statistics for the last N days. */ export async function getStats(days: number = 7): Promise { + // The sink for every caller's window, including the `/stats ` command, + // whose `parseInt(arg, 10) || 7` lets a negative through because -1 is truthy. + // A negative `slice(0, days)` trims days off the newest-first list and NaN + // reads as zero, while the period rendered "last -1 days". Same guard as + // formatRecentLogs and resolveStatsDays: an unusable window is the default. + const window = Number.isFinite(days) && days >= 1 ? Math.floor(days) : DEFAULT_STATS_DAYS; const logFiles = await getLogFiles(); - const filesToRead = logFiles.slice(0, days); + const filesToRead = logFiles.slice(0, window); const dailyBreakdown: DailyStats[] = []; const allByTier: Record = {}; @@ -234,7 +240,7 @@ export async function getStats(days: number = 7): Promise { } return { - period: days === 1 ? "today" : `last ${days} days`, + period: window === 1 ? "today" : `last ${window} days`, totalRequests, totalCost, totalBaselineCost,