mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-05 06:13:40 +00:00
The CCS Bar server was crash-looping with:
[X] Cannot read properties of undefined (reading 'length')
Usage records from partially-written or legacy persisted files (e.g. a
Codex rollout scanned mid-write) can be missing modelBreakdowns despite
the type. getHourlyRequestCount() and the daily/monthly/hourly merges
read it unguarded, and the Bar re-runs aggregation every ~5 min and
restarts on crash - hence the loop.
Default modelBreakdowns to [] at every use site and fall back to 0 for
request count. Regression test added for all three merges.
I don't normally work in TS and this was heavily tool-assisted, but it
has fixed the crash loop on my machine.
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import {
|
|
mergeDailyData,
|
|
mergeHourlyData,
|
|
mergeMonthlyData,
|
|
} from '../../../src/web-server/usage/aggregator';
|
|
import type {
|
|
DailyUsage,
|
|
HourlyUsage,
|
|
ModelBreakdown,
|
|
MonthlyUsage,
|
|
} from '../../../src/web-server/usage/types';
|
|
|
|
const breakdown: ModelBreakdown = {
|
|
modelName: 'claude-sonnet-4-5',
|
|
inputTokens: 100,
|
|
outputTokens: 40,
|
|
cacheCreationTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cost: 0.1,
|
|
};
|
|
|
|
function validHour(): HourlyUsage {
|
|
return {
|
|
hour: '2026-03-02 10:00',
|
|
source: 'test',
|
|
inputTokens: 100,
|
|
outputTokens: 40,
|
|
cacheCreationTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cost: 0.1,
|
|
totalCost: 0.1,
|
|
modelsUsed: ['claude-sonnet-4-5'],
|
|
modelBreakdowns: [{ ...breakdown }],
|
|
requestCount: 1,
|
|
};
|
|
}
|
|
|
|
function validDay(): DailyUsage {
|
|
return {
|
|
date: '2026-03-02',
|
|
source: 'test',
|
|
inputTokens: 100,
|
|
outputTokens: 40,
|
|
cacheCreationTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cost: 0.1,
|
|
totalCost: 0.1,
|
|
modelsUsed: ['claude-sonnet-4-5'],
|
|
modelBreakdowns: [{ ...breakdown }],
|
|
};
|
|
}
|
|
|
|
function validMonth(): MonthlyUsage {
|
|
return {
|
|
month: '2026-03',
|
|
source: 'test',
|
|
inputTokens: 100,
|
|
outputTokens: 40,
|
|
cacheCreationTokens: 0,
|
|
cacheReadTokens: 0,
|
|
totalCost: 0.1,
|
|
modelsUsed: ['claude-sonnet-4-5'],
|
|
modelBreakdowns: [{ ...breakdown }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Simulates a partially-written or legacy persisted usage record that is
|
|
* missing `modelBreakdowns` (and `requestCount`) despite the type declaring
|
|
* them, e.g. a Codex rollout file scanned mid-write.
|
|
*/
|
|
function stripBreakdowns<T extends { modelBreakdowns: ModelBreakdown[] }>(record: T): T {
|
|
const partial = { ...record } as Partial<T>;
|
|
delete partial.modelBreakdowns;
|
|
if ('requestCount' in partial) {
|
|
delete (partial as { requestCount?: number }).requestCount;
|
|
}
|
|
return partial as T;
|
|
}
|
|
|
|
describe('usage aggregator merge with missing modelBreakdowns', () => {
|
|
it('does not throw when a same-hour record is missing modelBreakdowns (merge-into-existing path)', () => {
|
|
const merged = mergeHourlyData([[validHour()], [stripBreakdowns(validHour())]]);
|
|
expect(merged).toHaveLength(1);
|
|
// requestCount stays numeric instead of throwing on `.length` of undefined.
|
|
expect(typeof merged[0]?.requestCount).toBe('number');
|
|
// Tokens from both records still aggregate.
|
|
expect(merged[0]?.inputTokens).toBe(200);
|
|
});
|
|
|
|
it('does not throw when the only record is missing modelBreakdowns (new-bucket path)', () => {
|
|
const merged = mergeHourlyData([[stripBreakdowns(validHour())]]);
|
|
expect(merged).toHaveLength(1);
|
|
expect(merged[0]?.modelBreakdowns).toEqual([]);
|
|
expect(merged[0]?.requestCount).toBe(0);
|
|
});
|
|
|
|
it('daily merge tolerates a record missing modelBreakdowns', () => {
|
|
expect(() => mergeDailyData([[validDay()], [stripBreakdowns(validDay())]])).not.toThrow();
|
|
});
|
|
|
|
it('monthly merge tolerates a record missing modelBreakdowns', () => {
|
|
expect(() => mergeMonthlyData([[validMonth()], [stripBreakdowns(validMonth())]])).not.toThrow();
|
|
});
|
|
});
|