mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +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
148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
import type { DateRange } from './types.js'
|
|
import { toDateString } from './daily-cache.js'
|
|
|
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
const END_OF_DAY_HOURS = 23
|
|
const END_OF_DAY_MINUTES = 59
|
|
const END_OF_DAY_SECONDS = 59
|
|
const END_OF_DAY_MS = 999
|
|
|
|
// "All Time" is intentionally bounded to the last 6 months. Older data is
|
|
// rarely actionable for a cost tracker, and capping the range keeps the parse
|
|
// path bounded so providers like Codex/Cursor with sparse multi-year history
|
|
// still load in seconds. Users who need an unbounded window can use
|
|
// `--from` / `--to`.
|
|
const ALL_TIME_MONTHS = 6
|
|
|
|
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
|
|
|
export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
|
|
|
// Short labels suitable for the dashboard tab strip. Long-form labels for
|
|
// header text come from `getDateRange().label`.
|
|
export const PERIOD_LABELS: Record<Period, string> = {
|
|
today: 'Today',
|
|
week: '7 Days',
|
|
'30days': '30 Days',
|
|
month: 'This Month',
|
|
all: '6 Months',
|
|
}
|
|
|
|
const VALID_PERIODS: ReadonlyArray<Period> = ['today', 'week', '30days', 'month', 'all']
|
|
|
|
export function toPeriod(s: string): Period {
|
|
if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period
|
|
// Fail loudly instead of silently coercing to 'week'. Previously a typo
|
|
// like `-p mounth` produced a quiet 7-day report and the user thought
|
|
// they were viewing the month.
|
|
process.stderr.write(
|
|
`codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
function parseLocalDate(s: string): Date {
|
|
if (!ISO_DATE_RE.test(s)) {
|
|
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
|
|
}
|
|
const [y, m, d] = s.split('-').map(Number) as [number, number, number]
|
|
const date = new Date(y, m - 1, d)
|
|
// JS Date silently rolls overflow forward (Feb 31 → Mar 3). That makes a
|
|
// typo like `--from 2026-02-31 --to 2026-03-15` quietly drop sessions
|
|
// dated Feb 28 - Mar 2. Reject overflow so the user gets a loud error
|
|
// instead of an off-by-N-days date range.
|
|
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
|
|
throw new Error(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`)
|
|
}
|
|
return date
|
|
}
|
|
|
|
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
|
|
if (from === undefined && to === undefined) return null
|
|
|
|
const now = new Date()
|
|
// When --from is omitted, default to 6 months back (the same window the
|
|
// dashboard's "all" period uses) instead of epoch. Previously a bare
|
|
// `--to 2026-01-01` opened a 55-year scan from 1970 which is rarely what
|
|
// the user meant and is expensive on machines with many session files.
|
|
const ALL_TIME_FALLBACK_MS = 6 * 31 * 24 * 60 * 60 * 1000
|
|
const start = from !== undefined
|
|
? parseLocalDate(from)
|
|
: new Date(now.getTime() - ALL_TIME_FALLBACK_MS)
|
|
|
|
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const end = new Date(
|
|
endDate.getFullYear(),
|
|
endDate.getMonth(),
|
|
endDate.getDate(),
|
|
END_OF_DAY_HOURS,
|
|
END_OF_DAY_MINUTES,
|
|
END_OF_DAY_SECONDS,
|
|
END_OF_DAY_MS,
|
|
)
|
|
|
|
if (start > end) {
|
|
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
|
|
}
|
|
return { start, end }
|
|
}
|
|
|
|
/**
|
|
* Returns the date range and a human-readable label for a named period.
|
|
*
|
|
* Accepts a string (rather than the strict `Period` type) because the CLI
|
|
* surfaces a few extra inputs not exposed in the dashboard tab strip
|
|
* (e.g. `'yesterday'`). Unknown values fall back to `'week'`.
|
|
*
|
|
* Note: `'all'` is bounded to the last 6 months. Use `--from`/`--to` for
|
|
* an unbounded historical window.
|
|
*/
|
|
export function getDateRange(period: string): { range: DateRange; label: string } {
|
|
const now = new Date()
|
|
const end = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate(),
|
|
END_OF_DAY_HOURS,
|
|
END_OF_DAY_MINUTES,
|
|
END_OF_DAY_SECONDS,
|
|
END_OF_DAY_MS,
|
|
)
|
|
|
|
switch (period) {
|
|
case 'today': {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
return { range: { start, end }, label: `Today (${toDateString(start)})` }
|
|
}
|
|
case 'yesterday': {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS)
|
|
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
|
|
}
|
|
case 'week': {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
|
return { range: { start, end }, label: 'Last 7 Days' }
|
|
}
|
|
case 'month': {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` }
|
|
}
|
|
case '30days': {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
|
|
return { range: { start, end }, label: 'Last 30 Days' }
|
|
}
|
|
case 'all': {
|
|
const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1)
|
|
return { range: { start, end }, label: 'Last 6 months' }
|
|
}
|
|
default: {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
|
return { range: { start, end }, label: 'Last 7 Days' }
|
|
}
|
|
}
|
|
}
|
|
|
|
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
|
|
return `${from ?? 'all'} to ${to ?? 'today'}`
|
|
}
|