mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-18 23:37:13 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift, performance, security, and end-to-end smoke tests on real session data. Data-correctness fixes: - parseLocalDate rejects month/day overflow. JS Date silently rolled Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected). - CSV/JSON exports use the active currency's natural decimal places. The previous round2 helper produced ¥412.37 in CSV while the dashboard rendered ¥412 — finance teams comparing the two surfaces saw a discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc). - Copilot toolRequests is Array.isArray-guarded in both modern and legacy event branches. Previously a corrupt session with toolRequests=null or a string aborted the whole file's parse loop and silently dropped every legitimate call after it. - Codex token_count dedup uses a null sentinel for prevCumulativeTotal so the first event is never confused with a duplicate. Sessions that emit only last_token_usage (no total_token_usage) report cumulativeTotal=0 on every event; with the previous 0-initialized prev, the first event matched the dedup guard and was dropped. - LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate. Defense in depth against a tampered upstream JSON shipping negative or absurdly large per-token costs that would otherwise propagate into all cost totals. Performance: - Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two changes: per-conversation user-message buffer uses an index pointer instead of Array.shift() (which was O(n) per call); and a real ROWID cutoff via subquery limits the scan to the most recent 250k bubbles with a stderr warning so power users get a partial report rather than a stalled CLI. - Spawned codeburn CLI subprocesses are terminated when the calling Task is cancelled. Without this, rapid period/provider tab clicks in the menubar cancelled the Task but left the subprocess running to completion, piling up zombie processes. UX: - Dashboard period switch flips to loading and clears projects synchronously before reloadData runs, eliminating the frame where the new period label rendered over the old period's projects. - Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4 new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling the StatusBar off the alt buffer top. - Custom --from/--to ranges hide the period tab strip and disable the 1-5 / arrow keys so a stray period press no longer abandons the user's explicit range. A "Custom range: X to Y" banner replaces the tab strip. - OpenCode storage-format warning is per-table-set, rate-limited to once per process, and points the user at OpenCode's migration step or the issue tracker. The previous all-or-nothing check fired the generic "format not recognized" string for any schema mismatch. Menubar / OAuth: - Both Claude and Codex bootstrap (Reconnect button) now honour the usageBlockedUntil 429 backoff that refreshIfBootstrapped respects. Spamming Reconnect during sustained rate-limit windows previously hammered the upstream endpoint on every click. - Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate fallback) so we don't over-back-off when ChatGPT tells us a shorter window than our 5-minute floor. - Both credential cache files are written via SafeFile.write (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race window where the temp file briefly exists at default umask, and a symlink at the destination cannot redirect the write. Reads now route through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap on Data(contentsOf:). CI signal: - TypeScript strict typecheck (tsc --noEmit) is now zero errors. The six errors in src/providers/copilot.ts came from a discriminated-union catch-all branch whose `data: Record<string, unknown>` shape TS picked over the specific event branches when narrowing on `type`. Removed the catch-all; runtime falls through unknown event types via the existing if/else chain. Tests added: 16 new (now 555 total) - date-range-filter: month/day/year overflow rejection, leap-day correctness - currency-rounding: convertCost no-rounding contract, roundForActiveCurrency for USD/JPY/KRW/EUR - providers/copilot: malformed toolRequests does not abort the parse - providers/cursor-bubble-dedup: re-parse after token mutation does not double-count, single parse yields one call per bubble - providers/codex: first event with cumulativeTotal=0 not dropped, consecutive zero-cumulative duplicates still deduped
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import { readConfig } from './config.js'
|
|
|
|
type CurrencyState = {
|
|
code: string
|
|
rate: number
|
|
symbol: string
|
|
}
|
|
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
|
|
// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
|
|
// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
|
|
const MIN_VALID_FX_RATE = 0.0001
|
|
const MAX_VALID_FX_RATE = 1_000_000
|
|
|
|
function isValidRate(value: unknown): value is number {
|
|
return typeof value === 'number'
|
|
&& Number.isFinite(value)
|
|
&& value >= MIN_VALID_FX_RATE
|
|
&& value <= MAX_VALID_FX_RATE
|
|
}
|
|
|
|
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
|
|
|
|
const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
|
|
|
|
// Intl.NumberFormat throws on invalid ISO 4217 codes, so we use it as a validator
|
|
export function isValidCurrencyCode(code: string): boolean {
|
|
try {
|
|
new Intl.NumberFormat('en', { style: 'currency', currency: code })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function resolveSymbol(code: string): string {
|
|
const parts = new Intl.NumberFormat('en', {
|
|
style: 'currency',
|
|
currency: code,
|
|
currencyDisplay: 'symbol',
|
|
}).formatToParts(0)
|
|
return parts.find(p => p.type === 'currency')?.value ?? code
|
|
}
|
|
|
|
export function getFractionDigits(code: string): number {
|
|
return new Intl.NumberFormat('en', {
|
|
style: 'currency',
|
|
currency: code,
|
|
}).resolvedOptions().maximumFractionDigits ?? 2
|
|
}
|
|
|
|
/// Round a converted cost to the currency's natural decimal places. JPY/KRW/CLP
|
|
/// resolve to 0 fraction digits — exporting those with `round2` produced rows
|
|
/// like `¥412.37` while the dashboard rendered `¥412`, breaking finance reports
|
|
/// that compare the two surfaces.
|
|
export function roundForActiveCurrency(value: number): number {
|
|
const code = getCurrency().code
|
|
const digits = getFractionDigits(code)
|
|
const factor = Math.pow(10, digits)
|
|
return Math.round(value * factor) / factor
|
|
}
|
|
|
|
function getCacheDir(): string {
|
|
return join(homedir(), '.cache', 'codeburn')
|
|
}
|
|
|
|
function getRateCachePath(): string {
|
|
return join(getCacheDir(), 'exchange-rate.json')
|
|
}
|
|
|
|
async function fetchRate(code: string): Promise<number> {
|
|
const response = await fetch(`${FRANKFURTER_URL}${code}`)
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const data = await response.json() as { rates?: Record<string, unknown> }
|
|
const rate = data.rates?.[code]
|
|
if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
|
|
return rate
|
|
}
|
|
|
|
async function loadCachedRate(code: string): Promise<number | null> {
|
|
try {
|
|
const raw = await readFile(getRateCachePath(), 'utf-8')
|
|
const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
|
|
// Validate every field -- a tampered cache file could set rate to a string, null, or
|
|
// Infinity and break downstream math silently.
|
|
if (typeof cached.code !== 'string' || cached.code !== code) return null
|
|
if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
|
|
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
|
|
if (!isValidRate(cached.rate)) return null
|
|
return cached.rate
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function cacheRate(code: string, rate: number): Promise<void> {
|
|
await mkdir(getCacheDir(), { recursive: true })
|
|
await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate }))
|
|
}
|
|
|
|
async function getExchangeRate(code: string): Promise<number> {
|
|
if (code === 'USD') return 1
|
|
|
|
const cached = await loadCachedRate(code)
|
|
if (cached) return cached
|
|
|
|
let rate: number
|
|
try {
|
|
rate = await fetchRate(code)
|
|
} catch {
|
|
return 1
|
|
}
|
|
// Persist the rate, but never let a cache-write failure (disk full, no
|
|
// permissions, etc.) cause us to return the USD-equivalent fallback.
|
|
// The original code wrapped fetch + cacheRate in one try/catch, so a
|
|
// disk-full at write time would discard a perfectly good rate and silently
|
|
// make every cost render as if the user had selected USD.
|
|
cacheRate(code, rate).catch(() => {})
|
|
return rate
|
|
}
|
|
|
|
export async function loadCurrency(): Promise<void> {
|
|
const config = await readConfig()
|
|
if (!config.currency) return
|
|
|
|
const code = config.currency.code.toUpperCase()
|
|
const rate = await getExchangeRate(code)
|
|
const symbol = config.currency.symbol ?? resolveSymbol(code)
|
|
|
|
active = { code, rate, symbol }
|
|
}
|
|
|
|
export function getCurrency(): CurrencyState {
|
|
return active
|
|
}
|
|
|
|
export async function switchCurrency(code: string): Promise<void> {
|
|
if (code === 'USD') {
|
|
active = USD
|
|
return
|
|
}
|
|
const rate = await getExchangeRate(code)
|
|
const symbol = resolveSymbol(code)
|
|
active = { code, rate, symbol }
|
|
}
|
|
|
|
export function getCostColumnHeader(): string {
|
|
return `Cost (${active.code})`
|
|
}
|
|
|
|
export function convertCost(costUSD: number): number {
|
|
// Return the unrounded converted cost. Rounding here meant zero-fraction
|
|
// currencies (JPY, KRW, CLP) clamped every per-session cost to the nearest
|
|
// whole unit before aggregation; a project with 1000 sessions averaging
|
|
// ¥0.4 each would aggregate to ¥0 instead of ¥400 because each row was
|
|
// rounded independently. formatCost (and the export rowsToCsv path) round
|
|
// at the display boundary instead.
|
|
return costUSD * active.rate
|
|
}
|
|
|
|
export function formatCost(costUSD: number): string {
|
|
const { rate, symbol, code } = active
|
|
const cost = costUSD * rate
|
|
const digits = getFractionDigits(code)
|
|
|
|
if (digits === 0) return `${symbol}${Math.round(cost)}`
|
|
|
|
if (cost >= 1) return `${symbol}${cost.toFixed(2)}`
|
|
if (cost >= 0.01) return `${symbol}${cost.toFixed(3)}`
|
|
if (cost >= 0.0001) return `${symbol}${cost.toFixed(4)}`
|
|
return `${symbol}${cost.toFixed(2)}`
|
|
}
|