cctally 1.96.2 → 1.97.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.
@@ -334,6 +334,10 @@ def _sum_cost_for_range(*args, **kwargs):
334
334
  return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
335
335
 
336
336
 
337
+ def _sum_cost_and_tokens_for_range(*args, **kwargs):
338
+ return sys.modules["cctally"]._sum_cost_and_tokens_for_range(*args, **kwargs)
339
+
340
+
337
341
  def _compute_cost_for_weekref(*args, **kwargs):
338
342
  return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
339
343
 
@@ -869,6 +873,11 @@ class TuiCurrentWeek:
869
873
  # default `None` keeps fixture modules that construct TuiCurrentWeek
870
874
  # directly (without this field) backwards-compatible.
871
875
  five_hour_block: dict | None = None
876
+ # #556 S1 §3.3 — the current cycle's #104 token total, accumulated in the
877
+ # SAME pass that produces `spent_usd` so the two halves describe one entry
878
+ # set. Appended last with a default so fixture modules that construct
879
+ # `TuiCurrentWeek` positionally stay valid.
880
+ total_tokens: int = 0
872
881
 
873
882
 
874
883
  # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
@@ -1607,7 +1616,11 @@ def _tui_build_current_week(
1607
1616
  latest = samples[-1]
1608
1617
  used_pct = float(latest[1])
1609
1618
  five_hr_pct = float(latest[2]) if latest[2] is not None else None
1610
- spent = _sum_cost_for_range(
1619
+ # #556 S1 §3.3: one walk yields both halves. The range is whatever
1620
+ # `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
1621
+ # above, so a mid-week reset shortens the accumulation and the published
1622
+ # period together.
1623
+ spent, total_tokens = _sum_cost_and_tokens_for_range(
1611
1624
  week_start_at, now_utc, mode="auto", skip_sync=skip_sync
1612
1625
  )
1613
1626
  dpp = (spent / used_pct) if used_pct > 0 else None
@@ -1678,6 +1691,7 @@ def _tui_build_current_week(
1678
1691
  five_hour_block=_select_current_block_for_envelope(
1679
1692
  conn, current_used_pct=used_pct, now_utc=now_utc,
1680
1693
  ),
1694
+ total_tokens=total_tokens,
1681
1695
  )
1682
1696
 
1683
1697
 
@@ -2449,8 +2463,6 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2449
2463
  identity=(ordinal, raw.get("axis"), raw.get("threshold"), raw.get("alerted_at")),
2450
2464
  ))
2451
2465
 
2452
- daily_total = daily.get("total_cost_usd", 0.0)
2453
- daily_tokens = daily.get("total_tokens", 0)
2454
2466
  budget_settings = _tui_source_copy(legacy.get("alerts_settings"))
2455
2467
  if isinstance(budget_settings, dict):
2456
2468
  # The legacy top-level settings mirror contains Codex capability flags
@@ -2465,8 +2477,15 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2465
2477
  budget_settings.pop(key, None)
2466
2478
  return {
2467
2479
  "hero": {
2468
- "cost_usd": daily_total,
2469
- "total_tokens": daily_tokens,
2480
+ # #556 S1 §3.4: current-cycle accounting, NOT the thirty-day
2481
+ # rollup. Both providers' `hero.cost_usd` / `hero.total_tokens`
2482
+ # now mean the same thing; the thirty-day figures keep their own
2483
+ # home in `periods.daily`. `None` when no current week resolves —
2484
+ # composition distinguishes an empty provider from an unresolved
2485
+ # cycle by the provider's availability, and a zero here would make
2486
+ # both read as observed spend.
2487
+ "cost_usd": current_week.get("spent_usd"),
2488
+ "total_tokens": current_week.get("total_tokens"),
2470
2489
  "header": _tui_source_copy(legacy.get("header")),
2471
2490
  "current_week": _tui_source_copy(current_week),
2472
2491
  "forecast": _tui_source_copy(legacy.get("forecast")),
@@ -2499,15 +2518,41 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2499
2518
  }
2500
2519
 
2501
2520
 
2521
+ def _tui_claude_cycle_is_resolved(
2522
+ current_week: object, now_utc: dt.datetime,
2523
+ ) -> bool:
2524
+ """Whether Claude's current week resolves AND has not expired (#556 §4.1).
2525
+
2526
+ A stale percent observation does not enter this: the boundary can be
2527
+ perfectly resolved while the number that reports progress against it is an
2528
+ hour old.
2529
+ """
2530
+ if not isinstance(current_week, dict):
2531
+ return False
2532
+ end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
2533
+ if end is None:
2534
+ return False
2535
+ return now_utc.astimezone(dt.timezone.utc) < dt.datetime.fromisoformat(end)
2536
+
2537
+
2502
2538
  def _tui_claude_domain_freshness(
2503
2539
  source_data: dict[str, object] | None,
2540
+ *,
2541
+ now_utc: dt.datetime,
2504
2542
  ) -> dict[str, str]:
2505
- """Derive Claude axes from its selected weekly snapshot evidence.
2543
+ """Derive Claude's axes from its selected weekly snapshot evidence.
2544
+
2545
+ #556 S1 §4.1 repoints the two axes it publishes. ``quota`` carries the
2546
+ percent-OBSERVATION age, which is what it always described. ``hero`` now
2547
+ carries current-cycle ACCOUNTING resolvability: fresh while the boundary
2548
+ resolves and has not expired, so the backward-looking counters inside it
2549
+ are publishable. Pointing ``hero`` at the percent age is what kept All's
2550
+ combined caveat permanently on, because that clock's 90-second bound is
2551
+ forty times tighter than the Codex weekly one it was joined with.
2506
2552
 
2507
2553
  The legacy current-week label has a third presentation-only ``aging``
2508
2554
  state. The source contract deliberately keeps the frozen fresh/stale
2509
- vocabulary: only the exact stale label moves the weekly hero/quota axes.
2510
- Missing evidence remains a capability/availability concern.
2555
+ vocabulary: only the exact stale label moves the quota axis.
2511
2556
  """
2512
2557
  data = source_data if isinstance(source_data, dict) else {}
2513
2558
  hero = data.get("hero")
@@ -2516,12 +2561,15 @@ def _tui_claude_domain_freshness(
2516
2561
  current_week.get("freshness")
2517
2562
  if isinstance(current_week, dict) else None
2518
2563
  )
2519
- weekly = (
2564
+ quota = (
2520
2565
  "stale"
2521
2566
  if isinstance(freshness, dict) and freshness.get("label") == "stale"
2522
2567
  else "fresh"
2523
2568
  )
2524
- return {"hero": weekly, "quota": weekly, "sessions": "fresh"}
2569
+ accounting = (
2570
+ "fresh" if _tui_claude_cycle_is_resolved(current_week, now_utc) else "stale"
2571
+ )
2572
+ return {"hero": accounting, "quota": quota, "sessions": "fresh"}
2525
2573
 
2526
2574
 
2527
2575
  def _refresh_claude_source_clock(
@@ -2536,29 +2584,41 @@ def _refresh_claude_source_clock(
2536
2584
  return state
2537
2585
  if now_utc.tzinfo is None or now_utc.utcoffset() is None:
2538
2586
  raise ValueError("now_utc must be timezone-aware")
2539
- captured = getattr(current_week, "latest_snapshot_at", None)
2540
- if not isinstance(captured, dt.datetime):
2541
- return state
2542
- if captured.tzinfo is None or captured.utcoffset() is None:
2543
- captured = captured.replace(tzinfo=dt.timezone.utc)
2544
- age_seconds = max(
2545
- 0.0,
2546
- (
2547
- now_utc.astimezone(dt.timezone.utc)
2548
- - captured.astimezone(dt.timezone.utc)
2549
- ).total_seconds(),
2550
- )
2551
- try:
2552
- freshness_config = _get_oauth_usage_config(raw_config)
2553
- except Exception:
2554
- freshness_config = _OAUTH_USAGE_DEFAULTS
2555
- weekly = (
2556
- "stale"
2557
- if _freshness_label(age_seconds, freshness_config) == "stale"
2558
- else "fresh"
2559
- )
2560
2587
  domain_freshness = dict(state.domain_freshness or {})
2561
- domain_freshness.update({"hero": weekly, "quota": weekly})
2588
+ # #556 S1 §4.1: the two axes advance from DIFFERENT evidence. Both legs run
2589
+ # on every tick — this used to write one percent-derived label to both, and
2590
+ # it also returned early on a missing capture, which left the accounting
2591
+ # axis frozen at its build-time value for the whole idle stretch.
2592
+ week_end = getattr(current_week, "week_end_at", None)
2593
+ domain_freshness["hero"] = (
2594
+ "fresh"
2595
+ if isinstance(week_end, dt.datetime)
2596
+ and now_utc.astimezone(dt.timezone.utc) < (
2597
+ week_end if week_end.tzinfo is not None
2598
+ else week_end.replace(tzinfo=dt.timezone.utc)
2599
+ ).astimezone(dt.timezone.utc)
2600
+ else "stale"
2601
+ )
2602
+ captured = getattr(current_week, "latest_snapshot_at", None)
2603
+ if isinstance(captured, dt.datetime):
2604
+ if captured.tzinfo is None or captured.utcoffset() is None:
2605
+ captured = captured.replace(tzinfo=dt.timezone.utc)
2606
+ age_seconds = max(
2607
+ 0.0,
2608
+ (
2609
+ now_utc.astimezone(dt.timezone.utc)
2610
+ - captured.astimezone(dt.timezone.utc)
2611
+ ).total_seconds(),
2612
+ )
2613
+ try:
2614
+ freshness_config = _get_oauth_usage_config(raw_config)
2615
+ except Exception:
2616
+ freshness_config = _OAUTH_USAGE_DEFAULTS
2617
+ domain_freshness["quota"] = (
2618
+ "stale"
2619
+ if _freshness_label(age_seconds, freshness_config) == "stale"
2620
+ else "fresh"
2621
+ )
2562
2622
  refreshed = dataclasses.replace(
2563
2623
  state,
2564
2624
  domain_freshness=domain_freshness,
@@ -2566,6 +2626,100 @@ def _refresh_claude_source_clock(
2566
2626
  return state if refreshed == state else refreshed
2567
2627
 
2568
2628
 
2629
+ def _tui_normalized_period_instant(value: object) -> str | None:
2630
+ """One canonical UTC spelling for a published cycle bound (#556 S1 §3.6).
2631
+
2632
+ Legacy rows carry local-offset spellings and current rows carry `Z`, so the
2633
+ raw text is not an identity: hashing it would rebuild the Claude source on
2634
+ a spelling change that moved no boundary. Returns ``None`` for anything
2635
+ that does not parse as an instant.
2636
+ """
2637
+ if not isinstance(value, str) or not value:
2638
+ return None
2639
+ try:
2640
+ return parse_iso_datetime(value, "period bound").astimezone(
2641
+ dt.timezone.utc).isoformat()
2642
+ except ValueError:
2643
+ return None
2644
+
2645
+
2646
+ def _tui_claude_period_identity(claude_data: object) -> str:
2647
+ """The effective current-cycle bounds, as a version fragment (§3.6).
2648
+
2649
+ `claude_version` carried the database signatures, the semantics identity
2650
+ and the accounts digest but no period, and `reuse_coherent_source_state`
2651
+ returns the exact prior object on an unchanged version — so a week that
2652
+ rolled over with no database movement could be republished as current.
2653
+ The snapshot-level `_snapshot_period_rolled_over` gate already covers the
2654
+ undecorated case; this is the second layer, so provider reuse cannot
2655
+ outlive the cycle even if that gate is later changed.
2656
+
2657
+ The bounds are read from the SAME resolved object the aggregation used
2658
+ (`current_week`, stored after `_apply_midweek_reset_override`), so a stale
2659
+ identity can never accompany a shortened range.
2660
+
2661
+ The literal `"none"` degradation is deliberate and is NOT an oversight. A
2662
+ caller that holds no `claude_data` — the capture and QA paths — contributes
2663
+ a constant fragment, so layer two adds no rollover protection there. Those
2664
+ paths are still covered by the snapshot-level `_snapshot_period_rolled_over`
2665
+ gate, which is layer one and is what production reuse actually runs behind.
2666
+ """
2667
+ data = claude_data if isinstance(claude_data, dict) else {}
2668
+ hero = data.get("hero")
2669
+ current_week = hero.get("current_week") if isinstance(hero, dict) else None
2670
+ if not isinstance(current_week, dict):
2671
+ return "none"
2672
+ start = _tui_normalized_period_instant(current_week.get("week_start_at"))
2673
+ end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
2674
+ if start is None and end is None:
2675
+ return "none"
2676
+ return f"{start or ''}~{end or ''}"
2677
+
2678
+
2679
+ def _tui_resolve_account_scope(stats_conn, provider: str) -> dict[str, object] | None:
2680
+ """Read one provider's authoritative REAL account count (#556 S1 §3.8).
2681
+
2682
+ Returns ``None`` when the count cannot be read. That is the FAIL-CLOSED
2683
+ path: composition withholds the combined figure rather than assuming a
2684
+ single account. It is deliberately the opposite of the builders' existing
2685
+ swallow-and-degrade behaviour for the accounts WIRE, where the fallback
2686
+ loses decoration styling; here it would publish a wrong number.
2687
+
2688
+ The catch is narrow on purpose. ``real_account_count`` is one SELECT over
2689
+ ``accounts`` — no file I/O, no label lookups — so ``sqlite3.Error`` covers
2690
+ every failure the CALL can produce, and anything else is a real defect that
2691
+ must not be swallowed into a silent withholding. ``ImportError`` is caught
2692
+ alongside it because the deferred import is a different failure class from
2693
+ the query: an unimportable module is exactly the "count cannot be read"
2694
+ state this function exists to report, and letting it propagate would fail
2695
+ the whole dashboard tick instead of withholding one figure.
2696
+ """
2697
+ try:
2698
+ import _cctally_account
2699
+ return {
2700
+ "real_account_count": int(
2701
+ _cctally_account.real_account_count(stats_conn, provider)
2702
+ ),
2703
+ }
2704
+ except (ImportError, sqlite3.Error):
2705
+ return None
2706
+
2707
+
2708
+ def _tui_with_account_scope(
2709
+ state: SourceDashboardState, scope: dict[str, object] | None,
2710
+ ) -> SourceDashboardState:
2711
+ """Attach ``scope`` without breaking reuse-by-identity.
2712
+
2713
+ ``reuse_coherent_source_state`` returns the EXACT prior object, and callers
2714
+ assert on that identity, so an unconditional ``dataclasses.replace`` would
2715
+ defeat reuse on every tick. Frozen mappings compare equal to plain dicts,
2716
+ so an unchanged count returns the prior object untouched.
2717
+ """
2718
+ if state.account_scope == scope:
2719
+ return state
2720
+ return dataclasses.replace(state, account_scope=scope)
2721
+
2722
+
2569
2723
  def _tui_build_source_bundle(
2570
2724
  *,
2571
2725
  stats_conn,
@@ -2659,11 +2813,15 @@ def _tui_build_source_bundle(
2659
2813
  f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
2660
2814
  f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
2661
2815
  )
2816
+ # #556 S1 §3.6: normalized period identity, so a nominal week rollover
2817
+ # invalidates the generation even when no database signature moved.
2818
+ _period_identity = _tui_claude_period_identity(claude_data)
2662
2819
  claude_version = (
2663
2820
  f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
2664
2821
  f"{signature.max_wus_id}:{signature.max_wcs_id}:"
2665
2822
  f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
2666
- f"{signature.generation}:{semantics.claude_identity}{_acct_suffix}"
2823
+ f"{signature.generation}:{semantics.claude_identity}"
2824
+ f":p{_period_identity}{_acct_suffix}"
2667
2825
  )
2668
2826
  prior_claude = (
2669
2827
  prior_bundle.sources.get("claude")
@@ -2754,7 +2912,9 @@ def _tui_build_source_bundle(
2754
2912
  ),
2755
2913
  **({"accounts": claude_accounts} if claude_accounts else {}),
2756
2914
  },
2757
- domain_freshness=_tui_claude_domain_freshness(claude_data),
2915
+ domain_freshness=_tui_claude_domain_freshness(
2916
+ claude_data, now_utc=now_utc,
2917
+ ),
2758
2918
  )
2759
2919
  if codex_ingest_failed:
2760
2920
  warning = SourceDashboardWarning(
@@ -2838,6 +2998,15 @@ def _tui_build_source_bundle(
2838
2998
  # guard, so a freshly built state is handed back unchanged. Claude is
2839
2999
  # deliberately untouched.
2840
3000
  codex = refresh_codex_source_clock(codex, now_utc=now_utc)
3001
+ # #556 S1 §3.8: the decoration fact reaches composition as authoritative
3002
+ # server-only metadata. It is attached HERE, after every build / reuse /
3003
+ # degrade / clock branch, so no branch can publish a state without it.
3004
+ claude = _tui_with_account_scope(
3005
+ claude, _tui_resolve_account_scope(stats_conn, "claude"),
3006
+ )
3007
+ codex = _tui_with_account_scope(
3008
+ codex, _tui_resolve_account_scope(stats_conn, "codex"),
3009
+ )
2841
3010
  combined = compose_all_state(claude, codex)
2842
3011
  bundle = SourceDashboardBundle(
2843
3012
  source_schema_version=SOURCE_SCHEMA_VERSION,