feat(projects): per-repo cost, and the mixed-era rule that makes it safe - #121
Merged
Conversation
Closes the cost half of issue 102. The surfacing half shipped in #118. Project rows were keyed project|source|hour and carried NO model, so per-repo cost was not merely unimplemented — it was absent from the data model. The README's "which repo, which model, and which hour" promised a repo x model join that had never existed. The key is now project|source|model|hour and the row carries the model, so computeRowCost — the same function the rest of the product prices with — works per project. A repo total and a model total cannot disagree, because they come from one function. THE TRANSITION IS THE WHOLE JOB, and it is the part that can be wrong in a way nobody notices. A legacy row (project|source|hour) and a new row (project|source|model|hour) describe the SAME bucket at different granularity. Keep both and you double-count; drop either and you lose real usage. Neither is acceptable, and neither is what happens here. The bucket state in cursors.json is re-keyed on load: an old bucket becomes the `unknown` slot for its hour, keeping its running total AND its queuedKey. It then stops receiving new usage — that goes to the model-specific bucket — so it is frozen at its pre-upgrade total and the two sum to the true number. The reader keys model-less rows to the same `unknown` slot, so a legacy row and its re-keyed bucket are one identity rather than two. Without the re-key the old bucket would strand: its key never matches again, new usage restarts from zero under the model key, and the reader still sees the old row. That is the double count, and it is why this is a state migration rather than a key change. Migration runs in normalizeProjectState, which every parser goes through, so one call covers all seven. Idempotent — a four-segment key is skipped — and it refuses to clobber an existing unattributed bucket, leaving the legacy one in place for a human rather than silently discarding either. COST IS HONEST ABOUT WHAT IT CANNOT PRICE. A row with no model prices at 0 and is counted as `unattributed_tokens` rather than folded into the total. The panel shows "no model recorded" instead of "$0.00" for a repo that is entirely pre-model, and "$3.00+" for one that is partly priced. Pricing those at zero would render "recorded before we recorded models" as "this cost nothing" — the exact tier issue 94 was added to avoid. 19 new tests. The mixed-era ones are the point: a legacy row and a model row for one bucket count exactly once each; repeated appends of one key still collapse; two models in one hour are two slots; a stranded bucket keeps its total and its dedup marker through the re-key; running the migration twice changes nothing. ci:local exit 0: 948 root tests (+15), 266 dashboard (+4).
This was referenced Jul 25, 2026
Merged
pitimon
added a commit
that referenced
this pull request
Jul 26, 2026
15 commits since v0.39.43. Tracker: #125. User-facing: - The Projects panel honours the date range. It read no query parameter at all, so picking "24h" narrowed every other card while Projects kept showing all-time totals with nothing on screen saying so (#118). - Per-repo cost, with rows written before the change named as unpriced rather than shown as a confident $0 (#121). - Sources with no per-repo attribution are named, instead of their absence reading as "that tool cost nothing here" (#118). - A plan-value card answering what README:32 already promised, labelled as list-price-equivalent rather than a saving (#122). - The Codex quota chip no longer vanishes on a 401 (#119). - `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install (#117). Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar proxy per-hop revalidation, port-aware allowlisting and a real download cap (#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of the product gate (#115), and a usage-limits fixture capture tool (#124). Version bumped in all four lockstep locations by the `version` hook: package.json, package-lock.json, both TokenTrackerBar targets, and the Windows csproj. validate:version-lockstep passes. WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync after upgrading, re-keying project buckets from project|source|hour to project|source|model|hour. Without that the old bucket strands and its usage is counted twice. Verified read-only against a real 2,015-bucket state — every key migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015 queuedKey markers survived, the loss of which would re-append every row. That is one machine; a differently-shaped state is the residual risk. ci:local exit 0: 972 root tests, 302 dashboard. Co-authored-by: itarun.p <itarun.p@somapait.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the cost half of #102. The surfacing half shipped in #118.
Project rows were keyed
project|source|hourand carried no model, so per-repo cost was not merely unimplemented — it was absent from the data model. The README's "which repo, which model, and which hour" promised a repo × model join that had never existed.The key is now
project|source|model|hourand the row carries the model, socomputeRowCost— the same function the rest of the product prices with — works per project. A repo total and a model total cannot disagree, because they come from one function.The transition is the whole job
This is the part that can be wrong in a way nobody notices, and it is what #102 flags.
A legacy row (
project|source|hour) and a new row (project|source|model|hour) describe the same bucket at different granularity. Keep both → double-count. Drop either → lose real usage. Neither is acceptable, and neither is what happens here.The bucket state in
cursors.jsonis re-keyed on load. An old bucket becomes theunknownslot for its hour, keeping its running total and itsqueuedKey. It then stops receiving new usage — that goes to the model-specific bucket — so it is frozen at its pre-upgrade total and the two sum to the true number. The reader keys model-less rows to that sameunknownslot, so a legacy row and its re-keyed bucket are one identity, not two.Without the re-key the old bucket strands: its key never matches again, new usage restarts from zero under the model key, and the reader still sees the old row. That is the double count, and it is why this is a state migration rather than a key change.
The migration runs in
normalizeProjectState, which every parser goes through, so one call covers all seven. Idempotent — a four-segment key is skipped — and it refuses to clobber an existing unattributed bucket, leaving the legacy one in place for a human rather than silently discarding either.Cost is honest about what it cannot price
A row with no model prices at 0 and is counted as
unattributed_tokensrather than folded into the total.$3.00no model recorded— not$0.00$3.00+Pricing model-less rows at zero would render "recorded before we recorded models" as "this cost nothing" — exactly the tier #94 was added to avoid.
Test plan
ci:localexit 0 — 948 root tests (+15), 266 dashboard (+4)queuedKey(without which the row re-appends), running it twice changes nothing, a model-keyed bucket is left alone, an existing unattributed bucket is not clobbered, and garbage state does not throwNot in this PR
Backfilling
modelonto historical rows — out of scope per the issue: the source logs may no longer exist, andunattributedis the honest rendering of "we recorded this before we recorded models".