mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
Everyone upgrading from the last published CLI crosses the session-cache v7 -> v9 re-layout (#1005/#1007) and the daily-cache v17 -> v19 re-derivation (#1015) on their first run. That path was covered by unit tests on one platform against caches the tests wrote themselves. `npm run verify:upgrade` (and the matching matrix job over {ubuntu, windows, macos} x node {22.13.0, 22}) instead installs the real codeburn@0.9.20 into an isolated global prefix, points it at a generated seven-provider corpus in a HOME whose path contains a space, and lets it write a genuine session-cache.v7.json + daily-cache.v17.json. This build then runs against that same cache dir, installed the same way, so dist/parse-worker.js has to resolve from an entry point outside the checkout. It asserts the v7 file is retired, the v9 envelope and shards publish, the daily history does not shrink, and per-provider calls/tokens/cost match the baseline exactly for claude, codex, gemini, kiro and cursor. grok is reported rather than asserted (its accounting changed in #1015) and dsh is required to be new. It also smoke-tests serve --stdio against the one-shot payloads, pins CODEBURN_PARSE_WORKERS to 0 and 3 and requires identical shards and payloads either way, and checks that a second run re-parses nothing.
130 lines
6.2 KiB
JavaScript
130 lines
6.2 KiB
JavaScript
// Per-provider payload parity between the published CLI and this build.
|
|
//
|
|
// node scripts/upgrade-path/compare.mjs <baselineDir> <upgradedDir>
|
|
//
|
|
// Each dir holds the payloads run.mjs captured: `export.json` (per-call records,
|
|
// the token/call source) and `menubar.json` (the unrounded per-provider cost).
|
|
// Prints one row per provider and exits non-zero on a diff that is not expected.
|
|
//
|
|
// Expectations, and why:
|
|
// claude, codex, gemini, kiro, cursor parse identically either side of the
|
|
// upgrade. Calls and every token field must match EXACTLY; cost is allowed
|
|
// COST_TOLERANCE of drift because the two binaries carry different bundled
|
|
// LiteLLM price snapshots and only agree when the shared pricing cache in
|
|
// CODEBURN_CACHE_DIR is warm (which it is, unless the runner is offline).
|
|
// grok changed by design in #1015: usage now comes from the CLI's own
|
|
// turn_completed records instead of a context-curve estimate. The change
|
|
// is REPORTED, never asserted — not even directionally. On real corpora
|
|
// the changelog documents totals rising, but that is a property of real
|
|
// Grok sessions, and the direction here would only reflect how the
|
|
// generator happened to size its synthetic context curve against its
|
|
// synthetic usage records. Both sides must still count the same SESSIONS,
|
|
// which is the part the corpus can honestly establish.
|
|
// dsh did not exist in the published CLI. Reported; required to be absent
|
|
// in the baseline and present after the upgrade.
|
|
const EXACT = ['claude', 'codex', 'gemini', 'kiro', 'cursor']
|
|
const CHANGED_BY_DESIGN = ['grok']
|
|
const NEW_IN_THIS_RELEASE = ['dsh']
|
|
|
|
const COST_TOLERANCE = 0.005 // 0.5% relative
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const [baseDir, upDir] = process.argv.slice(2)
|
|
if (!baseDir || !upDir) {
|
|
console.error('usage: compare.mjs <baselineDir> <upgradedDir>')
|
|
process.exit(2)
|
|
}
|
|
|
|
const TOKEN_FIELDS = ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheWriteTokens', 'cacheReadTokens']
|
|
|
|
function load(dir) {
|
|
const exported = JSON.parse(readFileSync(join(dir, 'export.json'), 'utf8'))
|
|
const menubar = JSON.parse(readFileSync(join(dir, 'menubar.json'), 'utf8'))
|
|
const byProvider = {}
|
|
for (const r of exported.records ?? []) {
|
|
const p = r.provider || 'unknown'
|
|
const acc = (byProvider[p] ??= { calls: 0, cost: 0, ...Object.fromEntries(TOKEN_FIELDS.map(f => [f, 0])) })
|
|
acc.calls++
|
|
acc.cost += r.cost ?? 0
|
|
for (const f of TOKEN_FIELDS) acc[f] += r[f] ?? 0
|
|
}
|
|
// Prefer the unrounded cost, keyed by the provider's internal id.
|
|
// `providerDetails` is the only place that pairing exists — the sibling
|
|
// `providers` map is keyed by lowercased display name. The per-record sum
|
|
// above stands in when a binary predates providerDetails; it is rounded per
|
|
// record, so it is the coarser of the two.
|
|
for (const d of menubar.current?.providerDetails ?? []) {
|
|
if (byProvider[d.id]) byProvider[d.id].cost = d.cost
|
|
}
|
|
return byProvider
|
|
}
|
|
|
|
const base = load(baseDir)
|
|
const up = load(upDir)
|
|
const providers = [...new Set([...Object.keys(base), ...Object.keys(up)])].sort()
|
|
|
|
const failures = []
|
|
const notes = []
|
|
const rows = []
|
|
|
|
const relDiff = (a, b) => (a === 0 && b === 0 ? 0 : Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b)))
|
|
const fmt = n => (Number.isInteger(n) ? String(n) : n.toFixed(6))
|
|
|
|
for (const name of providers) {
|
|
const b = base[name]
|
|
const u = up[name]
|
|
let verdict
|
|
|
|
if (NEW_IN_THIS_RELEASE.includes(name)) {
|
|
if (b) failures.push(`${name}: expected to be absent from the 0.9.20 baseline, but it reported ${b.calls} calls`)
|
|
else if (!u || u.calls === 0) failures.push(`${name}: new in this release but the upgraded run reported nothing`)
|
|
verdict = 'new (expected)'
|
|
} else if (!b || !u) {
|
|
failures.push(`${name}: present in ${b ? 'baseline' : 'upgraded'} only`)
|
|
verdict = 'MISSING'
|
|
} else if (CHANGED_BY_DESIGN.includes(name)) {
|
|
const bt = TOKEN_FIELDS.reduce((s, f) => s + b[f], 0)
|
|
const ut = TOKEN_FIELDS.reduce((s, f) => s + u[f], 0)
|
|
if (b.calls !== u.calls) failures.push(`${name}: usage accounting changed in #1015, but the session/call COUNT should not have: ${b.calls} != ${u.calls}`)
|
|
verdict = 'changed by design'
|
|
notes.push(`${name}: tokens ${bt} -> ${ut}, cost ${fmt(b.cost)} -> ${fmt(u.cost)} (#1015, expected; magnitude here is a property of the fixture, not evidence)`)
|
|
} else {
|
|
const diffs = []
|
|
if (b.calls !== u.calls) diffs.push(`calls ${b.calls} != ${u.calls}`)
|
|
for (const f of TOKEN_FIELDS) if (b[f] !== u[f]) diffs.push(`${f} ${b[f]} != ${u[f]}`)
|
|
const costDrift = relDiff(b.cost, u.cost)
|
|
if (costDrift > COST_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`)
|
|
if (!EXACT.includes(name)) {
|
|
notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`)
|
|
verdict = diffs.length ? 'differs (unclassified)' : 'identical'
|
|
} else if (diffs.length) {
|
|
failures.push(`${name}: ${diffs.join(', ')}`)
|
|
verdict = 'DIFFERS'
|
|
} else {
|
|
verdict = costDrift === 0 ? 'identical' : `identical (cost ${(costDrift * 100).toFixed(3)}% drift)`
|
|
}
|
|
}
|
|
|
|
rows.push({
|
|
provider: name,
|
|
calls: `${b?.calls ?? '-'} -> ${u?.calls ?? '-'}`,
|
|
tokens: `${b ? TOKEN_FIELDS.reduce((s, f) => s + b[f], 0) : '-'} -> ${u ? TOKEN_FIELDS.reduce((s, f) => s + u[f], 0) : '-'}`,
|
|
cost: `${b ? fmt(b.cost) : '-'} -> ${u ? fmt(u.cost) : '-'}`,
|
|
verdict,
|
|
})
|
|
}
|
|
|
|
const cols = ['provider', 'calls', 'tokens', 'cost', 'verdict']
|
|
const width = Object.fromEntries(cols.map(c => [c, Math.max(c.length, ...rows.map(r => r[c].length))]))
|
|
const line = r => cols.map(c => String(r[c]).padEnd(width[c])).join(' ')
|
|
console.log('')
|
|
console.log(line(Object.fromEntries(cols.map(c => [c, c.toUpperCase()]))))
|
|
console.log(cols.map(c => '-'.repeat(width[c])).join(' '))
|
|
for (const r of rows) console.log(line(r))
|
|
console.log('')
|
|
for (const n of notes) console.log(`note: ${n}`)
|
|
for (const f of failures) console.log(`FAIL: ${f}`)
|
|
console.log(failures.length ? `\nparity: ${failures.length} unexpected difference(s)` : '\nparity: ok')
|
|
process.exit(failures.length ? 1 : 0)
|