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 +7 -0
- package/bin/_cctally_dashboard_sources.py +19 -6
- package/bin/_cctally_five_hour.py +21 -7
- package/bin/_cctally_rederive.py +16 -0
- package/bin/_lib_rederive.py +124 -2
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
|
1537
|
-
"
|
|
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
|
-
|
package/bin/_cctally_rederive.py
CHANGED
|
@@ -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
|
package/bin/_lib_rederive.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|