cctally 1.84.1 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.85.0] - 2026-07-29
9
+
10
+ ### Fixed
11
+ - Rebuilding `stats.db` now restores only the newest open 5-hour block for each Claude account. A correction that deliberately retired historical block-close facts could previously make the rebuild recreate every older snapshot window as an open, unstamped block; the next dashboard sync then tried to record an obsolete revision over the correction and stayed on `server sync error`. The append-only journal remains unchanged, while rebuilding the disposable index now converges on the corrected history and background sync resumes normally.
12
+ - `cctally db rederive` no longer deletes the history it cannot re-derive. Usage observations are only recorded from the point cctally started journaling them, so everything older survives solely as the rows exported when the journal was first created — and the re-derivation compared those rows against a replay that could never contain them, then retired every one as stale. A single `db rederive --family claude-usage --yes` therefore erased months of weekly usage and cost: a `cctally dollar-per-percent --weeks 12` and the dashboard's `$/1% Trend` card both collapsed to the weeks since the journal began, and because the deletions are durable journal truth every later rebuild reproduced the loss exactly. History from before that point is now held out of the comparison entirely, while anything inside the window a replay does cover is still re-derived and retired as before. On an install that already lost history, the fix is also the cure: the next plan restores those weeks from the journal's own retained lines, so running `cctally db rederive --family claude-usage` and applying it with `--yes` brings the full trend back. A retirement recorded by anything other than this re-derivation is left alone, and `db rederive --json` now reports how many events a plan protected. (#426)
13
+ - The Codex current cycle shows its per-percent milestones again. OpenAI reports the same weekly reset a few seconds apart from one sample to the next, and cctally settles those spellings into a single window — but the dashboard hero was still publishing the raw provider value while the milestone rows were recorded against the settled one, so the two never matched and the cycle read `0 crossed` beneath "No integer-percent crossing has been retained in this cycle yet." while its crossings sat intact and correct in the index. Nothing was lost: the hero, the live quota rows and the milestone ladder now all name the same settled reset, so the crossings reappear on the next load, whether an account is selected or not. A running quota window is also no longer treated as finished the moment its raw reset passes, seconds ahead of the settled one, and the 5-hour percentage a window contributes is held to the same rule. Past cycles were never affected, and an install whose resets do not jitter is unchanged. (#428)
14
+
8
15
  ## [1.84.1] - 2026-07-29
9
16
 
10
17
  ### Fixed
@@ -228,10 +228,16 @@ def _resolve_codex_weekly_cycle(
228
228
  continue
229
229
  account = history.identity.account_key
230
230
  accounts_seen.add(account)
231
- if baseline is None or baseline.resets_at <= now_utc:
231
+ # #428: the CANONICAL anchor, never the raw provider reset. Blocks and
232
+ # milestones are keyed on the anchor (#416 §4.1), so publishing the raw
233
+ # value here mints a second spelling of one window — and the client's
234
+ # current-cycle milestone filter matches `resets_at` exactly, so the
235
+ # ladder empties for precisely the jittered cycles canonicalization
236
+ # exists to collapse. Liveness rides the same instant the hero shows.
237
+ if baseline is None or baseline.canonical_resets_at <= now_utc:
232
238
  continue
233
239
  state = quota_freshness(history.physical_observations, now_utc).state
234
- boundary = (history.identity.window_minutes, baseline.resets_at)
240
+ boundary = (history.identity.window_minutes, baseline.canonical_resets_at)
235
241
  if state == "fresh":
236
242
  bucket = fresh_by_account
237
243
  elif state == "stale":
@@ -431,7 +437,9 @@ def _codex_next_decision_at(
431
437
  baseline = select_baseline(history.observations, now_utc)
432
438
  if codex_history_is_model_scoped(history, baseline=baseline):
433
439
  continue
434
- if baseline is not None and baseline.resets_at > now_utc:
440
+ # #428: the anchor, so the decision deadline describes the same window
441
+ # the hero publishes rather than a jitter sibling up to 600s away.
442
+ if baseline is not None and baseline.canonical_resets_at > now_utc:
435
443
  latest = latest_physical_observation(history.physical_observations)
436
444
  if latest is not None:
437
445
  candidates.append(
@@ -1747,12 +1755,15 @@ def _quota_read_model(
1747
1755
  history_rows.append(row)
1748
1756
  if _codex_history_row_is_model_scoped(row):
1749
1757
  continue
1750
- if baseline is not None and baseline.resets_at > context.now_utc:
1758
+ # #428: the client compares `active[].resets_at` against
1759
+ # `hero.cycle.resets_at` (`activeWeeklyKeys`) to decide which weekly
1760
+ # history is the live one, so both must carry the SAME anchor.
1761
+ if baseline is not None and baseline.canonical_resets_at > context.now_utc:
1751
1762
  active_rows.append({
1752
1763
  "key": dashboard_resource_key("quota", "codex", *key_parts),
1753
1764
  "current_percent": baseline.used_percent,
1754
1765
  "captured_at": baseline.captured_at.astimezone(UTC).isoformat(),
1755
- "resets_at": baseline.resets_at.astimezone(UTC).isoformat(),
1766
+ "resets_at": baseline.canonical_resets_at.astimezone(UTC).isoformat(),
1756
1767
  "freshness": freshness.state,
1757
1768
  "stale_after_seconds": freshness.stale_after_seconds,
1758
1769
  })
@@ -2554,7 +2565,9 @@ def _codex_account_five_hour_percent(
2554
2565
  baseline = select_baseline(history.observations, now_utc)
2555
2566
  if codex_history_is_model_scoped(history, baseline=baseline):
2556
2567
  continue
2557
- if baseline is None or baseline.resets_at <= now_utc:
2568
+ # #428: the anchor a 5h window whose raw reset has passed but whose
2569
+ # canonical anchor has not is still live and still owns its percent.
2570
+ if baseline is None or baseline.canonical_resets_at <= now_utc:
2558
2571
  continue
2559
2572
  acct = history.identity.account_key
2560
2573
  pct = float(baseline.used_percent)
@@ -1526,15 +1526,30 @@ def _backfill_five_hour_blocks(
1526
1526
  # shared physical 5h window observed by two accounts yields two distinct
1527
1527
  # open blocks so rebuild reproduces per-account ownership.
1528
1528
  keys_sql = """
1529
- SELECT DISTINCT five_hour_window_key, account_key
1530
- FROM weekly_usage_snapshots
1531
- WHERE five_hour_window_key IS NOT NULL
1532
- AND five_hour_percent IS NOT NULL
1529
+ SELECT DISTINCT snapshots.five_hour_window_key,
1530
+ snapshots.account_key
1531
+ FROM weekly_usage_snapshots AS snapshots
1532
+ WHERE snapshots.five_hour_window_key IS NOT NULL
1533
+ AND snapshots.five_hour_percent IS NOT NULL
1533
1534
  """
1534
1535
  if only_missing:
1535
1536
  keys_sql += (
1536
- " AND (five_hour_window_key, account_key) NOT IN "
1537
- "(SELECT five_hour_window_key, account_key FROM five_hour_blocks)"
1537
+ " AND snapshots.id = ("
1538
+ " SELECT latest.id"
1539
+ " FROM weekly_usage_snapshots AS latest"
1540
+ " WHERE latest.account_key IS snapshots.account_key"
1541
+ " AND latest.five_hour_window_key IS NOT NULL"
1542
+ " AND latest.five_hour_percent IS NOT NULL"
1543
+ " ORDER BY unixepoch(latest.captured_at_utc) DESC,"
1544
+ " latest.id DESC"
1545
+ " LIMIT 1"
1546
+ " )"
1547
+ " AND NOT EXISTS ("
1548
+ " SELECT 1 FROM five_hour_blocks AS blocks"
1549
+ " WHERE blocks.five_hour_window_key ="
1550
+ " snapshots.five_hour_window_key"
1551
+ " AND blocks.account_key IS snapshots.account_key"
1552
+ " )"
1538
1553
  )
1539
1554
  keys = [(int(r[0]), r[1]) for r in conn.execute(keys_sql).fetchall()]
1540
1555
 
@@ -1784,4 +1799,3 @@ def _backfill_five_hour_blocks(
1784
1799
  eprint(f"[5h-block backfill] failed: {exc}")
1785
1800
  return 0
1786
1801
  return inserted
1787
-
@@ -397,12 +397,25 @@ def plan_claude_usage(
397
397
  )
398
398
  with tempfile.TemporaryDirectory(prefix="cctally-rederive-") as tmp:
399
399
  desired = _derive_desired_events(records, cache_conn, Path(tmp))
400
+ # #426: the scratch replay only sees RETAINED sources, so it can never
401
+ # reproduce the pre-cutover rows the journal exported as `b:<table>:<rowid>`
402
+ # evt lines. Hold them out of the diff instead of retiring them. Evidence is
403
+ # counted in OBSERVATIONS alone — an operator record is replay input but
404
+ # derives nothing on its own, and the cutover re-emits some of them
405
+ # (`weekly_credit_floor`) carrying their original historical timestamp.
406
+ preserved = _lib_rederive.preserved_history(
407
+ records,
408
+ evidence_retained=any(
409
+ record.get("t") == "obs" for record in raw_records
410
+ ),
411
+ )
400
412
  return _lib_rederive.build_claude_usage_plan(
401
413
  selection=selection,
402
414
  desired_events=desired,
403
415
  journal_high_water=journal_high_water,
404
416
  cache_fingerprint=cache_fingerprint,
405
417
  config_fingerprint=config_fingerprint,
418
+ preserved_events=preserved.values(),
406
419
  conflicted_event_ids=owned_conflicted_event_ids(selection),
407
420
  )
408
421
 
@@ -868,6 +881,9 @@ def _command_payload(
868
881
  "batchId": batch_id,
869
882
  "planHash": None if plan is None else plan.plan_hash,
870
883
  "actionCounts": counts,
884
+ # #426: how many owned events the plan held OUT of the diff because no
885
+ # retained source can re-derive them (pre-cutover exported history).
886
+ "preservedEventCount": 0 if plan is None else plan.preserved_event_count,
871
887
  # `conflicts` is the LEGACY key and keeps its meaning: command-validation
872
888
  # failure messages (unsupported family, prod guard, structural journal
873
889
  # protocol errors). #374's quarantined same-revision GROUPS ride the new
@@ -20,6 +20,18 @@ from collections.abc import Iterable, Mapping
20
20
 
21
21
  FAMILY = "claude-usage"
22
22
 
23
+ # Prefix of a correction batch this family authored. Only its own destructive
24
+ # batches may be undone by :func:`build_claude_usage_plan` (#426) — a tombstone
25
+ # written by anyone else is a deliberate retirement and stays retired.
26
+ _FAMILY_BATCH_PREFIX = f"rederive:{FAMILY}:"
27
+
28
+ # Id shape minted by the journal cutover exporter (`_lib_journal.bootstrap_id`,
29
+ # `b:<table>:<rowid>`) for a row that predates the journal. The family's own
30
+ # derivation only ever mints natural-key ids (`sa:`, `wcs:`, `pm:`, …), and the
31
+ # cutover runs once per install, so this prefix identifies exactly the events a
32
+ # replay can never reproduce — independently of any timestamp (#426).
33
+ _CUTOVER_EXPORT_ID_PREFIX = "b:"
34
+
23
35
 
24
36
  class RederiveError(RuntimeError):
25
37
  """Base error for a plan that cannot be produced truthfully."""
@@ -173,6 +185,74 @@ def validate_claude_cache_contract(tables: Mapping[str, set[str]]) -> None:
173
185
  )
174
186
 
175
187
 
188
+ def preserved_history(records: Iterable[Mapping], *,
189
+ evidence_retained: bool) -> dict[str, Mapping]:
190
+ """Owned events the current re-derivation cannot reach, keyed by event id.
191
+
192
+ Two independent, deterministic reasons an event can have no retained source
193
+ behind it — so a replay-derived desired set can never contain it:
194
+
195
+ * its id was minted by the cutover exporter (``b:<table>:<rowid>``). The
196
+ journal only starts recording observations AT the cutover, so those
197
+ exported lines ARE the pre-journal truth; the family's own derivation
198
+ mints natural keys and can never produce them.
199
+ * nothing is retained at all (``evidence_retained=False``) — every desired
200
+ set is then empty, so a diff could only ever be destructive.
201
+
202
+ Diffing such an event anyway put it in the "current but not desired" branch
203
+ and TOMBSTONED it: one ``db rederive --yes`` retired every pre-cutover
204
+ weekly usage/cost snapshot on a real install (#426). Those events are
205
+ preserved instead — never tombstoned, never rewritten from a re-derivation
206
+ that does not cover them. Everything the retained observations DO cover
207
+ still diffs normally, so an obsolete derivation still retires.
208
+
209
+ The returned record is the highest-revision non-tombstone line for that id
210
+ — a rev-0 evt, or a replacement from a committed correction batch — i.e.
211
+ exactly the payload the selector would choose if no tombstone existed.
212
+ """
213
+ committed = {
214
+ record.get("id") for record in records
215
+ if record.get("t") == "correction_batch"
216
+ and record.get("phase") == "commit"
217
+ }
218
+ best: dict[str, tuple[int, int, Mapping]] = {}
219
+ for sequence, record in enumerate(records):
220
+ if record.get("t") == "evt":
221
+ candidate = record
222
+ elif (
223
+ record.get("t") == "correction"
224
+ and record.get("action") == "replace"
225
+ and record.get("batch") in committed
226
+ ):
227
+ candidate = {
228
+ "v": record.get("v"),
229
+ "t": "evt",
230
+ "id": record.get("id"),
231
+ "rev": record.get("rev", 0),
232
+ "at": record.get("at"),
233
+ "src": "ingest",
234
+ "payload": dict(record.get("payload") or {}),
235
+ }
236
+ else:
237
+ continue
238
+ event_id = candidate.get("id")
239
+ if not isinstance(event_id, str) or not event_id:
240
+ continue
241
+ if not _is_owned_event(candidate):
242
+ continue
243
+ if evidence_retained and not event_id.startswith(
244
+ _CUTOVER_EXPORT_ID_PREFIX
245
+ ):
246
+ continue # the retained observations can re-derive it
247
+ revision = candidate.get("rev", 0)
248
+ if not isinstance(revision, int):
249
+ revision = 0
250
+ prior = best.get(event_id)
251
+ if prior is None or (revision, sequence) >= (prior[0], prior[1]):
252
+ best[event_id] = (revision, sequence, candidate)
253
+ return {event_id: entry[2] for event_id, entry in best.items()}
254
+
255
+
176
256
  def _canonical_bytes(value) -> bytes:
177
257
  return json.dumps(
178
258
  value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
@@ -266,6 +346,7 @@ class RederivePlan:
266
346
  counts: Mapping[str, int]
267
347
  actions: tuple[PlanAction, ...]
268
348
  retained_event_count: int
349
+ preserved_event_count: int = 0
269
350
 
270
351
  def _body(self) -> dict:
271
352
  return {
@@ -281,6 +362,7 @@ class RederivePlan:
281
362
  "configFingerprint": self.config_fingerprint,
282
363
  "counts": dict(self.counts),
283
364
  "retainedEventCount": self.retained_event_count,
365
+ "preservedEventCount": self.preserved_event_count,
284
366
  "payloadHashes": sorted(action.payload_hash for action in self.actions),
285
367
  "actions": [action.to_dict() for action in self.actions],
286
368
  }
@@ -317,9 +399,19 @@ def build_claude_usage_plan(*, selection, desired_events: Iterable[Mapping],
317
399
  journal_high_water: "tuple[str, int] | None",
318
400
  cache_fingerprint: str,
319
401
  config_fingerprint: str,
402
+ preserved_events: Iterable[Mapping],
320
403
  conflicted_event_ids=frozenset()) -> RederivePlan:
321
404
  """Diff current effective events against one scratch-derived desired set.
322
405
 
406
+ ``preserved_events`` (#426) names the owned events the re-derivation cannot
407
+ reach — see :func:`preserved_history`. They are held OUT of the diff, so
408
+ the "current but not desired" branch below can never retire history the
409
+ replay was never able to reproduce. The keyword is deliberately required:
410
+ defaulting it to empty is exactly the data-loss bug it exists to prevent.
411
+ A preserved event is only ever acted on to make it durable again —
412
+ re-affirmed at ``rev + 1`` when its group is quarantined (#374), or revived
413
+ at ``rev + 1`` when THIS family's own earlier batch tombstoned it.
414
+
323
415
  ``conflicted_event_ids`` (#374) names the event ids this family owns whose
324
416
  same-revision group the selector QUARANTINED at the winning revision. They
325
417
  force a ``supersede`` at ``selected.rev + 1`` **even when the provisional
@@ -344,14 +436,43 @@ def build_claude_usage_plan(*, selection, desired_events: Iterable[Mapping],
344
436
  and selected.record is not None
345
437
  and not _is_owned_event(selected.record)
346
438
  )
439
+ preserved = {}
440
+ for record in preserved_events:
441
+ event_id = record.get("id")
442
+ if isinstance(event_id, str) and event_id and event_id not in desired:
443
+ preserved[event_id] = record
347
444
  counts = {"retain": 0, "supersede": 0, "tombstone": 0, "add": 0}
348
445
  actions: list[PlanAction] = []
349
446
 
350
- for event_id in sorted(set(current) | set(desired)):
447
+ for event_id in sorted(set(current) | set(desired) | set(preserved)):
351
448
  current_record = current.get(event_id)
352
449
  desired_record = desired.get(event_id)
353
450
  selected = selection.by_id.get(event_id)
354
- if current_record is not None and desired_record is not None:
451
+ preserved_record = preserved.get(event_id)
452
+ if preserved_record is not None:
453
+ # Un-re-derivable history: retain it, or restore it when this
454
+ # family's own earlier plan retired it (#426).
455
+ revive = (
456
+ selected is not None
457
+ and selected.status == "tombstone"
458
+ and str(selected.batch_id or "").startswith(_FAMILY_BATCH_PREFIX)
459
+ )
460
+ reaffirm = (
461
+ selected is not None
462
+ and selected.status == "active"
463
+ and event_id in conflicted_event_ids
464
+ )
465
+ if not (revive or reaffirm):
466
+ counts["retain"] += 1
467
+ continue
468
+ source = (
469
+ selected.record if reaffirm else preserved_record
470
+ )
471
+ revision = int(selected.rev) + 1
472
+ disposition = "supersede"
473
+ at = str(source["at"])
474
+ payload = dict(source.get("payload") or {})
475
+ elif current_record is not None and desired_record is not None:
355
476
  desired_record = _preserve_non_derivable_state(
356
477
  current_record, desired_record
357
478
  )
@@ -404,4 +525,5 @@ def build_claude_usage_plan(*, selection, desired_events: Iterable[Mapping],
404
525
  counts=counts,
405
526
  actions=tuple(actions),
406
527
  retained_event_count=retained_event_count,
528
+ preserved_event_count=len(preserved),
407
529
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.84.1",
3
+ "version": "1.85.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {