mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 00:14:40 +00:00
Merge pull request #906 from ozymandiashh/fix/770-tz-carry-dedup
fix(daily-cache): surgical tz-migration de-dup for carried days (#770)
This commit is contained in:
commit
c6548fc96f
4 changed files with 858 additions and 14 deletions
|
|
@ -526,19 +526,32 @@ function emptyModelStats(): ModelDayStats {
|
|||
/// day but whose turns all landed on another) only contributes its session
|
||||
/// count, deduplicated by max — the same real session may be counted on both
|
||||
/// sides.
|
||||
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice): void {
|
||||
/// `residual` marks a slice that came out of the tz subtraction (issue #770):
|
||||
/// the subtraction already removed the placeholder's sessions (the ones the
|
||||
/// fresh parse explained), so the residual sessions are all distinct from the
|
||||
/// placeholder's and must ADD to it, not max-dedup against it. Max would clamp
|
||||
/// max(placeholder, residual) and permanently drop the source-gone sessions the
|
||||
/// residual still carries.
|
||||
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice, residual = false): void {
|
||||
// Reads keyed by names from foreign caches use hasOwn throughout: a plain
|
||||
// lookup of "__proto__" returns the prototype object, and accumulating into
|
||||
// it pollutes every object in the process.
|
||||
const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||||
const placeholderSessions = placeholder?.sessions ?? 0
|
||||
const merged = structuredClone(slice)
|
||||
if (placeholderSessions > (merged.sessions ?? 0)) merged.sessions = placeholderSessions
|
||||
if (residual) {
|
||||
// The subtraction removed the placeholder's sessions from this residual, so
|
||||
// every remaining session is distinct from the placeholder's - add, don't
|
||||
// max (max would clamp 1 + 1 to 1 and lose the source-gone session).
|
||||
merged.sessions = placeholderSessions + (merged.sessions ?? 0)
|
||||
} else if (placeholderSessions > (merged.sessions ?? 0)) {
|
||||
merged.sessions = placeholderSessions
|
||||
}
|
||||
setOwn(day.providers, provider, merged)
|
||||
day.cost += slice.cost
|
||||
day.calls += slice.calls
|
||||
day.savingsUSD += slice.savingsUSD ?? 0
|
||||
day.sessions += Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
|
||||
day.sessions += residual ? (slice.sessions ?? 0) : Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
|
||||
day.inputTokens += slice.inputTokens ?? 0
|
||||
day.outputTokens += slice.outputTokens ?? 0
|
||||
day.cacheReadTokens += slice.cacheReadTokens ?? 0
|
||||
|
|
@ -578,7 +591,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
|
|||
// project sessions were already counted into the day when the fresh day
|
||||
// was built, so only the excess is added.
|
||||
const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0
|
||||
acc.sessions += Math.max(0, num(p.sessions) - placeholderProjectSessions)
|
||||
acc.sessions += residual ? num(p.sessions) : Math.max(0, num(p.sessions) - placeholderProjectSessions)
|
||||
setOwn(dayProjects, name, acc)
|
||||
}
|
||||
// Placeholder-only projects (session counted fresh, calls landed elsewhere)
|
||||
|
|
@ -588,7 +601,11 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
|
|||
for (const [name, p] of Object.entries(placeholderProjects)) {
|
||||
if (!p || typeof p !== 'object') continue
|
||||
if (Object.hasOwn(mergedProjects, name)) {
|
||||
if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions)
|
||||
if (residual) {
|
||||
mergedProjects[name]!.sessions = num(mergedProjects[name]!.sessions) + num(p.sessions)
|
||||
} else if (num(p.sessions) > num(mergedProjects[name]!.sessions)) {
|
||||
mergedProjects[name]!.sessions = num(p.sessions)
|
||||
}
|
||||
} else {
|
||||
setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) })
|
||||
}
|
||||
|
|
@ -604,6 +621,246 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
|
|||
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
|
||||
// --- tz-aware carry subtraction (issue #770) ---------------------------------
|
||||
//
|
||||
// After a timezone change the full re-derive re-aggregates the same session
|
||||
// parse under the CURRENT tz and merges it over the cached (old-tz) days.
|
||||
// mergeDayEntries carries a baseline slice only when the fresh day has no data
|
||||
// slice for that (date, provider), so a turn that re-bucketed across local
|
||||
// midnight leaves its old day sliceless, gets carried there, AND counts again on
|
||||
// its new day. The fix subtracts from each carried baseline slice the content
|
||||
// the fresh parse still attributes to that (date, provider) under the OLD
|
||||
// bucketing (`freshUnderOldTz`): exactly the re-bucketed turns, nothing else.
|
||||
// A sources-gone slice has no such content and survives untouched; a slice fully
|
||||
// explained away is dropped.
|
||||
|
||||
/// Reduce `base` by `sub` at the slice level, clamping every field at 0 and
|
||||
/// dropping nested entries that reduce to nothing. Returns null when no positive
|
||||
/// data remains; the merge then drops the slice instead of carrying an empty
|
||||
/// one. `sub` is always a subset of `base` in practice (same parse, old bucketing
|
||||
/// vs cached baseline), so the clamp only guards rounding and cache/baseline skew.
|
||||
function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderDaySlice | null {
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||||
const inputTokens = Math.max(0, (base.inputTokens ?? 0) - (sub.inputTokens ?? 0))
|
||||
const outputTokens = Math.max(0, (base.outputTokens ?? 0) - (sub.outputTokens ?? 0))
|
||||
const cacheReadTokens = Math.max(0, (base.cacheReadTokens ?? 0) - (sub.cacheReadTokens ?? 0))
|
||||
const cacheWriteTokens = Math.max(0, (base.cacheWriteTokens ?? 0) - (sub.cacheWriteTokens ?? 0))
|
||||
const editTurns = Math.max(0, (base.editTurns ?? 0) - (sub.editTurns ?? 0))
|
||||
const oneShotTurns = Math.max(0, (base.oneShotTurns ?? 0) - (sub.oneShotTurns ?? 0))
|
||||
const models = subtractModels(base.models, sub.models)
|
||||
const categories = subtractCategories(base.categories, sub.categories)
|
||||
const projects = subtractProjects(base.projects, sub.projects)
|
||||
const out: ProviderDaySlice = {
|
||||
calls, cost, savingsUSD,
|
||||
...(sessions > 0 ? { sessions } : {}),
|
||||
...(inputTokens > 0 ? { inputTokens } : {}),
|
||||
...(outputTokens > 0 ? { outputTokens } : {}),
|
||||
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
||||
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
||||
...(editTurns > 0 ? { editTurns } : {}),
|
||||
...(oneShotTurns > 0 ? { oneShotTurns } : {}),
|
||||
...(models ? { models } : {}),
|
||||
...(categories ? { categories } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
}
|
||||
return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null
|
||||
}
|
||||
|
||||
function subtractModelStats(base: ModelDayStats, sub: ModelDayStats): ModelDayStats | null {
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const inputTokens = Math.max(0, base.inputTokens - (sub.inputTokens ?? 0))
|
||||
const outputTokens = Math.max(0, base.outputTokens - (sub.outputTokens ?? 0))
|
||||
const cacheReadTokens = Math.max(0, base.cacheReadTokens - (sub.cacheReadTokens ?? 0))
|
||||
const cacheWriteTokens = Math.max(0, base.cacheWriteTokens - (sub.cacheWriteTokens ?? 0))
|
||||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||||
}
|
||||
|
||||
function subtractModels(base: DailyEntry['models'] | undefined, sub: DailyEntry['models'] | undefined): DailyEntry['models'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['models'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractModelStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
function subtractCategoryStats(base: CategoryDayStats, sub: CategoryDayStats): CategoryDayStats | null {
|
||||
const turns = Math.max(0, base.turns - (sub.turns ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const editTurns = Math.max(0, base.editTurns - (sub.editTurns ?? 0))
|
||||
const oneShotTurns = Math.max(0, base.oneShotTurns - (sub.oneShotTurns ?? 0))
|
||||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||||
}
|
||||
|
||||
function subtractCategories(base: DailyEntry['categories'] | undefined, sub: DailyEntry['categories'] | undefined): DailyEntry['categories'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['categories'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractCategoryStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): ProjectDayStats | null {
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||||
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) }
|
||||
}
|
||||
|
||||
function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['projects'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractProjectStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
/// How much a nested stat entry actually lost: `base` before minus `reduced`
|
||||
/// after, or null when nothing was lost. The raw `sub` is only a lower bound -
|
||||
/// with tz skew it can exceed the slice, and subtracting it would eat OTHER
|
||||
/// providers' share of the day-level breakdown.
|
||||
function modelStatsDelta(base: ModelDayStats, reduced: ModelDayStats): ModelDayStats | null {
|
||||
const calls = base.calls - reduced.calls
|
||||
const cost = base.cost - reduced.cost
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const inputTokens = base.inputTokens - reduced.inputTokens
|
||||
const outputTokens = base.outputTokens - reduced.outputTokens
|
||||
const cacheReadTokens = base.cacheReadTokens - reduced.cacheReadTokens
|
||||
const cacheWriteTokens = base.cacheWriteTokens - reduced.cacheWriteTokens
|
||||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||||
}
|
||||
|
||||
function categoryStatsDelta(base: CategoryDayStats, reduced: CategoryDayStats): CategoryDayStats | null {
|
||||
const turns = base.turns - reduced.turns
|
||||
const cost = base.cost - reduced.cost
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const editTurns = base.editTurns - reduced.editTurns
|
||||
const oneShotTurns = base.oneShotTurns - reduced.oneShotTurns
|
||||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||||
}
|
||||
|
||||
function projectStatsDelta(base: ProjectDayStats, reduced: ProjectDayStats): ProjectDayStats | null {
|
||||
const cost = base.cost - reduced.cost
|
||||
const calls = base.calls - reduced.calls
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const sessions = (base.sessions ?? 0) - (reduced.sessions ?? 0)
|
||||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||||
return { cost, calls, savingsUSD, sessions }
|
||||
}
|
||||
|
||||
/// Remove `sub`'s contribution from a carried baseline day (the baseline-only
|
||||
/// date branch of the merge, where the whole day clones over). Reduces the
|
||||
/// provider's slice, the day-level totals, and the day-level models/categories/
|
||||
/// projects maps that `addSliceIntoDay` would have grown them by.
|
||||
///
|
||||
/// Every day-level subtraction uses the EFFECTIVE removal - what the provider
|
||||
/// slice actually lost (current before minus reduced after) - not the raw `sub`.
|
||||
/// With tz skew (`freshUnderOldTz` content larger than the baseline slice), the
|
||||
/// raw sub exceeds the slice and subtracting it would over-remove the day's
|
||||
/// totals and its nested maps, eating unrelated providers' carried history and
|
||||
/// breaking the invariant that a day's totals sum to its slices. A provider
|
||||
/// slice that was absent has an effective removal of zero: nothing is subtracted
|
||||
/// from the day.
|
||||
function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDaySlice): void {
|
||||
const current = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||||
if (!current) return
|
||||
const reduced = subtractSlice(current, sub)
|
||||
if (reduced) setOwn(day.providers, provider, reduced)
|
||||
else delete day.providers[provider]
|
||||
|
||||
day.cost = Math.max(0, day.cost - (current.cost - (reduced?.cost ?? 0)))
|
||||
day.calls = Math.max(0, day.calls - (current.calls - (reduced?.calls ?? 0)))
|
||||
day.savingsUSD = Math.max(0, (day.savingsUSD ?? 0) - ((current.savingsUSD ?? 0) - (reduced?.savingsUSD ?? 0)))
|
||||
day.sessions = Math.max(0, day.sessions - ((current.sessions ?? 0) - (reduced?.sessions ?? 0)))
|
||||
day.inputTokens = Math.max(0, day.inputTokens - ((current.inputTokens ?? 0) - (reduced?.inputTokens ?? 0)))
|
||||
day.outputTokens = Math.max(0, day.outputTokens - ((current.outputTokens ?? 0) - (reduced?.outputTokens ?? 0)))
|
||||
day.cacheReadTokens = Math.max(0, day.cacheReadTokens - ((current.cacheReadTokens ?? 0) - (reduced?.cacheReadTokens ?? 0)))
|
||||
day.cacheWriteTokens = Math.max(0, day.cacheWriteTokens - ((current.cacheWriteTokens ?? 0) - (reduced?.cacheWriteTokens ?? 0)))
|
||||
day.editTurns = Math.max(0, day.editTurns - ((current.editTurns ?? 0) - (reduced?.editTurns ?? 0)))
|
||||
day.oneShotTurns = Math.max(0, day.oneShotTurns - ((current.oneShotTurns ?? 0) - (reduced?.oneShotTurns ?? 0)))
|
||||
|
||||
for (const [name, m] of Object.entries(current.models ?? {})) {
|
||||
const rm = reduced?.models && Object.hasOwn(reduced.models, name) ? reduced.models[name] : undefined
|
||||
const removed = rm ? modelStatsDelta(m, rm) : m
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.models, name) ? day.models[name] : undefined
|
||||
if (!acc) continue
|
||||
const reducedM = subtractModelStats(acc, removed)
|
||||
if (reducedM) setOwn(day.models, name, reducedM)
|
||||
else delete day.models[name]
|
||||
}
|
||||
for (const [cat, c] of Object.entries(current.categories ?? {})) {
|
||||
const rc = reduced?.categories && Object.hasOwn(reduced.categories, cat) ? reduced.categories[cat] : undefined
|
||||
const removed = rc ? categoryStatsDelta(c, rc) : c
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat] : undefined
|
||||
if (!acc) continue
|
||||
const reducedC = subtractCategoryStats(acc, removed)
|
||||
if (reducedC) setOwn(day.categories, cat, reducedC)
|
||||
else delete day.categories[cat]
|
||||
}
|
||||
if (!day.projects) return
|
||||
for (const [name, p] of Object.entries(current.projects ?? {})) {
|
||||
const rp = reduced?.projects && Object.hasOwn(reduced.projects, name) ? reduced.projects[name] : undefined
|
||||
const removed = rp ? projectStatsDelta(p, rp) : p
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.projects, name) ? day.projects[name] : undefined
|
||||
if (!acc) continue
|
||||
const reducedP = subtractProjectStats(acc, removed)
|
||||
if (reducedP) setOwn(day.projects, name, reducedP)
|
||||
else delete day.projects[name]
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the tz subtraction leave any positive data on a carried baseline day?
|
||||
/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the
|
||||
/// day level, extended to the day's other scalar and nested content.
|
||||
function hasPositiveDayContent(day: DailyEntry): boolean {
|
||||
if (day.cost > 0 || day.calls > 0 || (day.savingsUSD ?? 0) > 0 || day.sessions > 0) return true
|
||||
if (day.inputTokens > 0 || day.outputTokens > 0 || day.cacheReadTokens > 0 || day.cacheWriteTokens > 0) return true
|
||||
if (day.editTurns > 0 || day.oneShotTurns > 0) return true
|
||||
if (Object.keys(day.providers).length > 0) return true
|
||||
if (Object.keys(day.models).length > 0 || Object.keys(day.categories).length > 0) return true
|
||||
if (day.projects && Object.keys(day.projects).length > 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD
|
||||
/// tzKey) by date then provider, so the merge can subtract exactly what the
|
||||
/// fresh parse still explains under the old bucketing.
|
||||
function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> {
|
||||
const byDate = new Map<string, Map<string, ProviderDaySlice>>()
|
||||
for (const day of days) {
|
||||
if (Object.keys(day.providers).length === 0) continue
|
||||
const byProvider = new Map<string, ProviderDaySlice>()
|
||||
for (const [provider, slice] of Object.entries(day.providers)) {
|
||||
byProvider.set(provider, slice)
|
||||
}
|
||||
byDate.set(day.date, byProvider)
|
||||
}
|
||||
return byDate
|
||||
}
|
||||
|
||||
/// Merge two day lists per (date, provider): `primary` wins wherever both have
|
||||
/// data; `secondary` only fills dates primary lacks entirely and provider
|
||||
/// slices primary lacks on shared dates. Nothing in secondary can overwrite or
|
||||
|
|
@ -619,13 +876,36 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
|
|||
/// A primary slice blocks a secondary one only when it carries DATA; a
|
||||
/// zero-data placeholder (sessions only) is merged into, not treated as a
|
||||
/// re-derivation of the provider's day.
|
||||
export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] {
|
||||
/// `subtract`, present ONLY on the tz-change re-derive, maps (date, provider)
|
||||
/// to the content the fresh parse still attributes there under the OLD
|
||||
/// bucketing. Every baseline slice the merge would otherwise carry has that
|
||||
/// content subtracted first (clamped at 0, dropped when nothing positive
|
||||
/// remains), so turns that re-bucketed across local midnight are not counted on
|
||||
/// both their old and new days. Absent (undefined) on every other path, which
|
||||
/// keeps those merges byte-identical to the pre-fix behavior.
|
||||
export function mergeDayEntries(
|
||||
primary: DailyEntry[],
|
||||
secondary: DailyEntry[],
|
||||
markSecondaryCarried: boolean,
|
||||
subtract?: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>>,
|
||||
): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
for (const day of primary) byDate.set(day.date, structuredClone(day))
|
||||
for (const day of secondary) {
|
||||
const existing = byDate.get(day.date)
|
||||
if (!existing) {
|
||||
const copy = structuredClone(day)
|
||||
if (subtract) {
|
||||
const subForDate = subtract.get(day.date)
|
||||
if (subForDate) {
|
||||
for (const [provider, slice] of Object.entries(copy.providers)) {
|
||||
const subSlice = subForDate.get(provider)
|
||||
if (!subSlice) continue
|
||||
subtractSliceFromDay(copy, provider, subSlice)
|
||||
}
|
||||
if (!hasPositiveDayContent(copy)) continue
|
||||
}
|
||||
}
|
||||
if (markSecondaryCarried) copy.carried = true
|
||||
byDate.set(day.date, copy)
|
||||
continue
|
||||
|
|
@ -637,7 +917,22 @@ export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[],
|
|||
if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue
|
||||
const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined
|
||||
if (existingSlice && hasSliceData(existingSlice)) continue
|
||||
addSliceIntoDay(existing, provider, slice)
|
||||
let toAdd = slice
|
||||
let residual = false
|
||||
if (subtract) {
|
||||
const subSlice = subtract.get(day.date)?.get(provider)
|
||||
if (subSlice) {
|
||||
const reduced = subtractSlice(slice, subSlice)
|
||||
if (!reduced) continue
|
||||
toAdd = reduced
|
||||
// The subtraction already removed the sessions the fresh parse
|
||||
// explained, so the residual's sessions are distinct from the fresh
|
||||
// placeholder's: merging over it must ADD, not max-dedup (fix round
|
||||
// 1 - max would drop the source-gone sessions the residual carries).
|
||||
residual = true
|
||||
}
|
||||
}
|
||||
addSliceIntoDay(existing, provider, toAdd, residual)
|
||||
if (markSecondaryCarried) existing.carried = true
|
||||
}
|
||||
}
|
||||
|
|
@ -685,6 +980,12 @@ export async function ensureCacheHydrated(
|
|||
/// So the backfill is only marked `complete` when this returns true. Defaults
|
||||
/// to a trusting `true` for callers that don't (or can't) supply it.
|
||||
sessionComplete: () => boolean = () => true,
|
||||
/// Re-aggregate the SAME parsed projects under an explicit timezone instead of
|
||||
/// the machine's local one. Used only on a tz-change re-derive: the result is
|
||||
/// compared against the fresh local-tz days to subtract the turns that
|
||||
/// re-bucketed across local midnight from the carried baseline (issue #770).
|
||||
/// Absent, the tz-change path carries forward exactly as it did before.
|
||||
aggregateDaysInTz?: (projects: ProjectSummary[], tz: string) => DailyEntry[],
|
||||
): Promise<DailyCache> {
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
|
@ -750,16 +1051,50 @@ export async function ensureCacheHydrated(
|
|||
const priorWatermark = c.lastComputedDate
|
||||
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||||
let freshDays: DailyEntry[] = []
|
||||
let projects: ProjectSummary[] = []
|
||||
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd }))
|
||||
// Hoisted so a tz-change re-derive can aggregate the SAME parse twice
|
||||
// (once under the current tz as freshDays, once under the cache's old
|
||||
// tzKey as freshUnderOldTz) without a second session parse.
|
||||
//
|
||||
// The parse stops at yesterdayEnd. Keeping it a HISTORY parse is what
|
||||
// makes the parser slice a midnight-straddling turn at the yesterday
|
||||
// boundary: day-N's turn-level category/counts then carry only the
|
||||
// pre-midnight half and today's live parse carries the rest, so the two
|
||||
// sides reconcile (issue #852). Widening THIS parse through now would
|
||||
// leave the full turn on day N while today's half was excluded from the
|
||||
// cache, breaking that reconciliation - so the subtraction below gets
|
||||
// its own through-now parse instead.
|
||||
projects = await parseSessions({ start: backfillStart, end: yesterdayEnd })
|
||||
freshDays = aggregateDays(projects)
|
||||
}
|
||||
const parseWasComplete = sessionComplete()
|
||||
// A PARTIAL parse must not overwrite finalized baseline days with
|
||||
// undercounts (if their sources die before the next complete parse, the
|
||||
// undercount would be what survives). Partial fresh data only fills days
|
||||
// and slices the baseline lacks; the next complete parse gets to win.
|
||||
//
|
||||
// On a complete-parse TZ re-derive (savings config untouched), subtract
|
||||
// from each carried baseline slice the content the fresh parse still
|
||||
// attributes to that (date, provider) under the OLD bucketing: the turns
|
||||
// that re-bucketed across local midnight. That is the issue #770
|
||||
// double-count; re-pricing drift (a savings-hash change) must never be
|
||||
// subtracted, so a hash change in the same re-derive skips this entirely.
|
||||
let tzSubtraction: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> | undefined
|
||||
if (parseWasComplete && tzChanged && c.savingsConfigHash === savingsConfigHash && aggregateDaysInTz && c.tzKey !== undefined) {
|
||||
// The subtraction re-parses THROUGH NOW (fix round 1): a call bucketed
|
||||
// to OLD-tz yesterday that re-buckets to NEW-tz TODAY sits past the
|
||||
// history parse's yesterdayEnd, so `freshUnderOldTz` built from `projects`
|
||||
// would never see it - the baseline slice would be carried un-subtracted
|
||||
// while today's live parse counts it again. This second parse exists
|
||||
// ONLY for the subtraction; it never feeds freshDays, so the merged
|
||||
// days written to the cache stay exactly the history days and today is
|
||||
// still owned by the caller's live parse.
|
||||
const wideProjects = await parseSessions({ start: backfillStart, end: now })
|
||||
tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey))
|
||||
}
|
||||
const merged = parseWasComplete
|
||||
? mergeDayEntries(freshDays, baseline, true)
|
||||
? mergeDayEntries(freshDays, baseline, true, tzSubtraction)
|
||||
: mergeDayEntries(baseline, freshDays, false)
|
||||
c = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,23 @@ export function dateKey(iso: string): string {
|
|||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/// Bucket an ISO timestamp under an explicit IANA timezone instead of the
|
||||
/// machine's local one. `en-CA` emits the ISO-ish YYYY-MM-DD layout directly,
|
||||
/// so formatToParts under the given `timeZone` yields exactly that shape. Used
|
||||
/// to re-aggregate the same parse under a cache's OLD tzKey when a timezone
|
||||
/// change forces a full re-derive (issue #770): comparing that bucketing to the
|
||||
/// fresh one shows exactly which turns re-bucketed across local midnight.
|
||||
export function dateKeyInTz(iso: string, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso))
|
||||
let year = '', month = '', day = ''
|
||||
for (const p of parts) {
|
||||
if (p.type === 'year') year = p.value
|
||||
else if (p.type === 'month') month = p.value
|
||||
else if (p.type === 'day') day = p.value
|
||||
}
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function emptySlice(): ProviderDaySlice {
|
||||
return {
|
||||
calls: 0, cost: 0, savingsUSD: 0,
|
||||
|
|
@ -34,7 +51,7 @@ function emptySlice(): ProviderDaySlice {
|
|||
}
|
||||
}
|
||||
|
||||
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
|
||||
export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn: (iso: string) => string = dateKey): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
const ensure = (date: string): DailyEntry => {
|
||||
let d = byDate.get(date)
|
||||
|
|
@ -61,7 +78,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const sessionDate = dateKey(session.firstTimestamp)
|
||||
const sessionDate = dateKeyFn(session.firstTimestamp)
|
||||
const sessionDay = ensure(sessionDate)
|
||||
sessionDay.sessions += 1
|
||||
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
|
||||
|
|
@ -94,7 +111,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
// sliced per call, per-call bucketing here was what caused the
|
||||
// constant offset against the whole-turn headline; the slice is
|
||||
// what makes it exact now.)
|
||||
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
|
||||
const turnDate = dateKeyFn(turn.timestamp || turn.assistantCalls[0]!.timestamp)
|
||||
const turnDay = ensure(turnDate)
|
||||
|
||||
const editTurns = turn.hasEdits ? 1 : 0
|
||||
|
|
@ -154,7 +171,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
// Call-derived values bucket under the call's OWN day (see the
|
||||
// two-rule comment above). An unparseable call timestamp falls back
|
||||
// to the turn's anchor day rather than producing a garbage date key.
|
||||
const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp)
|
||||
const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKeyFn(call.timestamp)
|
||||
const callDay = ensure(callDate)
|
||||
|
||||
callDay.cost += call.costUSD
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesCo
|
|||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { aggregateModels } from './models-report.js'
|
||||
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
|
||||
|
|
@ -95,6 +95,10 @@ async function hydrateCache(): Promise<DailyCache> {
|
|||
// Never finalize the daily history off a partial (interrupted) session
|
||||
// hydration — that is what froze empty older days into the chart.
|
||||
isSessionHydrationComplete,
|
||||
// On a tz-change re-derive the same parse is re-aggregated under the old
|
||||
// tzKey so carried slices can be reduced by the turns that re-bucketed
|
||||
// across local midnight (issue #770).
|
||||
(projects, tz) => aggregateProjectsIntoDays(projects, (iso) => dateKeyInTz(iso, tz)),
|
||||
)
|
||||
} catch (err) {
|
||||
// Previously swallowed silently, which turned any backfill failure into an
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue