cctally 1.113.0 → 1.115.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -223,7 +223,7 @@ from _lib_display_tz import (
223
223
  _compute_display_block,
224
224
  )
225
225
  from _lib_aggregators import _aggregate_monthly, codex_path_scope
226
- from _lib_fmt import stable_sum
226
+ from _lib_fmt import _fmt_usd_accounting, stable_sum
227
227
  # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
228
228
  # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
229
229
  # shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
@@ -348,6 +348,10 @@ def _fetch_current_week_snapshots(*args, **kwargs):
348
348
  return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
349
349
 
350
350
 
351
+ def _current_week_five_hour_reading(*args, **kwargs):
352
+ return sys.modules["cctally"]._current_week_five_hour_reading(*args, **kwargs)
353
+
354
+
351
355
  def _load_forecast_inputs(*args, **kwargs):
352
356
  return sys.modules["cctally"]._load_forecast_inputs(*args, **kwargs)
353
357
 
@@ -1682,12 +1686,14 @@ def _tui_build_current_week(
1682
1686
  # controls decoration, not the physical population used for computation.
1683
1687
  # Cost and forecast retain their existing merged semantics.
1684
1688
  fetched = _fetch_current_week_snapshots(
1685
- conn, now_utc, include_held=True, include_account=True)
1689
+ conn, now_utc, include_held=True, include_account=True,
1690
+ include_scope=True)
1686
1691
  if fetched is None:
1687
1692
  return None
1688
- week_start_at, week_end_at, observations = fetched
1693
+ week_start_at, week_end_at, observations, week_scope = fetched
1689
1694
  if not observations:
1690
1695
  return None
1696
+ week_start_before_override = week_start_at
1691
1697
  # Mirror the reset override applied by `_load_forecast_inputs` so the
1692
1698
  # Current Week card's spent_usd and $/1% reflect the post-reset window.
1693
1699
  week_start_at, observations = _apply_midweek_reset_override(
@@ -1698,8 +1704,8 @@ def _tui_build_current_week(
1698
1704
  # Held-inclusive sample shape: (captured_at_utc, weekly_percent,
1699
1705
  # five_hour_percent, weekly_observation_held[, account_key]). The optional
1700
1706
  # account key identifies the displayed weekly sample on a decorated store.
1701
- # The helper does not surface five_hour_resets_at, so do a targeted lookup
1702
- # below for the freshest non-NULL reset timestamp on the current week.
1707
+ # The helper does not surface five_hour_resets_at; the five-hour reading
1708
+ # selected below supplies it, with its percentage.
1703
1709
  #
1704
1710
  # #769 S11 (#824). This card publishes BOTH axes, and the dashboard
1705
1711
  # envelope reads `used_pct` and `latest_snapshot_at` straight off it for
@@ -1716,10 +1722,16 @@ def _tui_build_current_week(
1716
1722
  used_pct = float(latest[1])
1717
1723
  weekly_account = latest[4]
1718
1724
  # Five-hour readings may be newer held observations, but must remain on
1719
- # the account that supplied the displayed weekly reading.
1720
- five_hour_samples = [s for s in observations if s[4] == weekly_account]
1721
- five_hr_raw = five_hour_samples[-1][2]
1722
- five_hr_pct = float(five_hr_raw) if five_hr_raw is not None else None
1725
+ # the account that supplied the displayed weekly reading. The reading is
1726
+ # the newest five-hour READING under this card's own week and as-of scope
1727
+ # (#825/#832 spec 4.7 revision 7, the one shared selection): the newest
1728
+ # sample can be a retired replica or a weekly-only reading, which carries
1729
+ # no five-hour value and used to render as 0 %.
1730
+ five_hour = _current_week_five_hour_reading(
1731
+ conn, now_utc, week_scope, account_key=weekly_account,
1732
+ week_start_at=week_start_at,
1733
+ week_start_before_override=week_start_before_override)
1734
+ five_hr_pct = None if five_hour is None else five_hour.percent
1723
1735
  # #556 S1 §3.3: one walk yields both halves. The range is whatever
1724
1736
  # `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
1725
1737
  # above, so a mid-week reset shortens the accumulation and the published
@@ -1738,47 +1750,23 @@ def _tui_build_current_week(
1738
1750
  used_pct_corrected = corrected_percent_point(used_pct)
1739
1751
  dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
1740
1752
  dpp = (spent / dpp_divisor) if used_pct > 0 else None
1741
- # Collect every textual variant of week_start_at that parses to the same
1742
- # instant — mirrors `_fetch_current_week_snapshots` lines 9199-9210 so
1743
- # legacy local-offset rows and newly UTC-canonicalized rows both contribute.
1744
- ws_texts = conn.execute(
1745
- "SELECT DISTINCT week_start_at FROM weekly_usage_snapshots "
1746
- "WHERE week_start_at IS NOT NULL"
1747
- ).fetchall()
1748
- matching_ws_texts: list[str] = []
1749
- for r in ws_texts:
1753
+ # The reset instant comes from the SAME reading as the percentage beside
1754
+ # it (#825/#832 spec 4.7 revision 7: the shared selection returns the
1755
+ # percent and the reset together from one row), so the countdown never
1756
+ # describes another row. That reading is held-inclusive (#769 S11, #824),
1757
+ # because a held row's five-hour fields are the writing tick's own.
1758
+ five_hr_resets_at: dt.datetime | None = None
1759
+ if five_hour is not None and five_hour.resets_at is not None:
1750
1760
  try:
1751
- rws = parse_iso_datetime(r[0], "week_start_at")
1761
+ five_hr_resets_at = parse_iso_datetime(
1762
+ five_hour.resets_at, "five_hour_resets_at"
1763
+ )
1752
1764
  except ValueError:
1753
- continue
1754
- if rws == week_start_at:
1755
- matching_ws_texts.append(r[0])
1756
- five_hr_resets_at: dt.datetime | None = None
1757
- if matching_ws_texts:
1758
- placeholders = ",".join("?" * len(matching_ws_texts))
1759
- # HELD-INCLUSIVE, deliberately (#769 S11, #824): the reset instant is a
1760
- # five-hour fact, and a held row's five-hour fields are the writing
1761
- # tick's own. Excluding held rows here would make the countdown lag the
1762
- # five-hour percentage rendered beside it.
1763
- reset_row = conn.execute(
1764
- f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
1765
- f"WHERE week_start_at IN ({placeholders}) "
1766
- f" AND five_hour_resets_at IS NOT NULL "
1767
- f" AND account_key = ? "
1768
- f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
1769
- tuple(matching_ws_texts) + (weekly_account,),
1770
- ).fetchone()
1771
- if reset_row is not None:
1772
- try:
1773
- five_hr_resets_at = parse_iso_datetime(
1774
- reset_row[0], "five_hour_resets_at"
1775
- )
1776
- except ValueError:
1777
- five_hr_resets_at = None
1778
- # Suppress stale resets that have already elapsed so renderers
1779
- # don't show "resets 0h 00m" or a negative duration at the boundary.
1780
- if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
1781
- five_hr_resets_at = None
1765
+ five_hr_resets_at = None
1766
+ # Suppress stale resets that have already elapsed so renderers
1767
+ # don't show "resets 0h 00m" or a negative duration at the boundary.
1768
+ if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
1769
+ five_hr_resets_at = None
1782
1770
  # Freshness — compute label/age from latest snapshot vs. now using the
1783
1771
  # configured oauth_usage thresholds. Mirrors the dashboard envelope's
1784
1772
  # cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
@@ -6878,7 +6866,6 @@ def _tui_panel_current_week(
6878
6866
  bar_fill = _tui_bar_string(cw.used_pct, bar_w)
6879
6867
 
6880
6868
  five = cw.five_hour_pct or 0.0
6881
- five_bar = _tui_bar_string(five, bar_w)
6882
6869
 
6883
6870
  reset_delta = cw.week_end_at - snap.generated_at
6884
6871
  reset_days = max(0, reset_delta.days)
@@ -6914,6 +6901,17 @@ def _tui_panel_current_week(
6914
6901
  for c in fh_credits
6915
6902
  )
6916
6903
  fh_credit_badge = f" {{bright}}⚡ {deltas}{{/}}"
6904
+ # The 5-hour row is " 5-hour " (8 cells) + bar + " NNN%" (5) + badge, and
6905
+ # it must fit the panel interior (width - 2). A bar of the Used row's width
6906
+ # leaves five cells, while the badge's wide ⚡ glyph alone costs two, so a
6907
+ # badge shortens the 5-hour bar instead of spilling into the next panel.
6908
+ # It also leaves one blank cell before the right border, as every other
6909
+ # row of the panel does.
6910
+ five_w = bar_w
6911
+ if fh_credit_badge:
6912
+ badge_cells = _tui_lines_to_text([fh_credit_badge]).cell_len
6913
+ five_w = max(10, min(bar_w, width - 2 - 13 - badge_cells - 1))
6914
+ five_bar = _tui_bar_string(five, five_w)
6917
6915
  lines = [
6918
6916
  "",
6919
6917
  f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
@@ -7648,7 +7646,13 @@ def _tui_tagged_box_lines(
7648
7646
  When a body line's plain length exceeds the interior width it is truncated
7649
7647
  with ``{/}`` appended as a safety net — callers should size their content
7650
7648
  to avoid this branch.
7649
+
7650
+ Lengths are terminal CELLS, not characters: a wide glyph such as the
7651
+ 5-hour credit badge's ``⚡`` is one character but two cells, and padding
7652
+ by characters pushed that row's right border one cell out of the box
7653
+ (#825/#832, UI-QA of fix round K16c).
7651
7654
  """
7655
+ from rich.cells import cell_len, set_cell_size
7652
7656
  H, V = _TUI_BOX["h"], _TUI_BOX["v"]
7653
7657
  TL, TR, BL, BR = _TUI_BOX["tl"], _TUI_BOX["tr"], _TUI_BOX["bl"], _TUI_BOX["br"]
7654
7658
  interior = width - 2
@@ -7662,12 +7666,12 @@ def _tui_tagged_box_lines(
7662
7666
  t_seg = f" {title} " if title else ""
7663
7667
  p_seg = f" {pin} " if pin else ""
7664
7668
  if title and pin:
7665
- fill = width - 4 - len(_tui_strip_tags(t_seg)) - len(_tui_strip_tags(p_seg))
7669
+ fill = width - 4 - cell_len(_tui_strip_tags(t_seg)) - cell_len(_tui_strip_tags(p_seg))
7666
7670
  if fill >= 1:
7667
7671
  return (_wrap_border(TL + H) + t_seg
7668
7672
  + _wrap_border(H * fill) + p_seg + _wrap_border(H + TR))
7669
7673
  if title:
7670
- fill = width - 3 - len(_tui_strip_tags(t_seg))
7674
+ fill = width - 3 - cell_len(_tui_strip_tags(t_seg))
7671
7675
  if fill >= 1:
7672
7676
  return _wrap_border(TL + H) + t_seg + _wrap_border(H * fill + TR)
7673
7677
  return _wrap_border(TL + H * interior + TR)
@@ -7675,14 +7679,14 @@ def _tui_tagged_box_lines(
7675
7679
  lines: list[str] = [_top()]
7676
7680
  for line in body_tagged:
7677
7681
  plain = _tui_strip_tags(line)
7678
- if len(plain) > interior:
7682
+ if cell_len(plain) > interior:
7679
7683
  # On overflow, drop color markup entirely — partial-tag truncation
7680
7684
  # would produce malformed tokens that crash _tui_colortag. Plain-
7681
7685
  # text is always safe, and callers are expected to size content
7682
7686
  # to fit (this branch is a safety net only).
7683
- line = plain[: max(0, interior - 1)] + "…"
7687
+ line = set_cell_size(plain, max(0, interior - 1)) + "…"
7684
7688
  plain = line
7685
- pad = interior - len(plain)
7689
+ pad = interior - cell_len(plain)
7686
7690
  lines.append(_wrap_border(V) + line + " " * pad + _wrap_border(V))
7687
7691
  lines.append(_wrap_border(BL + H * interior + BR))
7688
7692
  return lines
@@ -8401,8 +8405,9 @@ def _tui_modal_current_week(snap, runtime, width):
8401
8405
  ms.crossed_at, runtime.display_tz,
8402
8406
  fmt="%b %d %H:%M:%S", suffix=True,
8403
8407
  )
8404
- cumul_str = f"${ms.cumulative_cost_usd:.2f}".ljust(8)
8405
- marg_str = (f"${ms.marginal_cost_usd:.2f}" if ms.marginal_cost_usd is not None else "—").ljust(10)
8408
+ cumul_str = _fmt_usd_accounting(ms.cumulative_cost_usd, 2).ljust(8)
8409
+ marg_str = (_fmt_usd_accounting(ms.marginal_cost_usd, 2)
8410
+ if ms.marginal_cost_usd is not None else "—").ljust(10)
8406
8411
  line = f" {{b}}{ms.percent:>3}{{/}} {{bright}}{ts_str:<22}{{/}} {{b}}{cumul_str}{{/}} {{b}}{marg_str}{{/}}"
8407
8412
  if show_5h:
8408
8413
  five_str = (f"{int(ms.five_hour_pct_at_crossing)}%"
@@ -88,6 +88,131 @@ def _get_canonical_boundary_for_date(
88
88
  return None, None
89
89
 
90
90
 
91
+ #: #901 (I1/G1, Amendment 1 item 8 and 1b): one leg per snapshot table of the
92
+ #: recent-week read. Each groups ONE table's weeks newest first, streamed off a
93
+ #: week-date-leading index, so neither leg sorts anything; `_recent_week_rows`
94
+ #: merges the two. The usage leg reads the covering epoch-1017
95
+ #: `idx_usage_week_date_group` (no table lookups), falling back to
96
+ #: `idx_usage_week_time` on an index without it; the cost leg reads
97
+ #: `idx_cost_week_time`.
98
+ _RECENT_USAGE_WEEKS_SQL = (
99
+ "SELECT week_start_date, MAX(week_end_date) AS week_end_date "
100
+ "FROM weekly_usage_snapshots{hint}{account} "
101
+ "GROUP BY week_start_date ORDER BY week_start_date DESC"
102
+ )
103
+ _RECENT_COST_WEEKS_SQL = (
104
+ "SELECT week_start_date, MAX(week_end_date) AS week_end_date "
105
+ "FROM weekly_cost_snapshots{hint}{account} "
106
+ "GROUP BY week_start_date ORDER BY week_start_date DESC"
107
+ )
108
+
109
+
110
+ def _sqlite_sort_key(value) -> tuple:
111
+ """Rank a stored value the way SQLite's BINARY comparison ranks it.
112
+
113
+ NULL first, then numbers (integers and reals compared numerically), then
114
+ text in code-point order (which is UTF-8 byte order), then blobs.
115
+ """
116
+ if value is None:
117
+ return (0, 0)
118
+ if isinstance(value, (int, float)):
119
+ return (1, value)
120
+ if isinstance(value, str):
121
+ return (2, value)
122
+ return (3, bytes(value))
123
+
124
+
125
+ def _sqlite_max(left, right):
126
+ """SQLite's MAX() over two group maxima: NULL never wins."""
127
+ if left is None:
128
+ return right
129
+ if right is None:
130
+ return left
131
+ return left if _sqlite_sort_key(left) >= _sqlite_sort_key(right) else right
132
+
133
+
134
+ def _recent_week_rows(
135
+ conn: sqlite3.Connection, limit: "int | None", *,
136
+ account_key: "str | None" = None,
137
+ ) -> "list[tuple[object, object]]":
138
+ """The newest ``limit`` week dates across both snapshot tables (#901).
139
+
140
+ Returns ``(week_start_date, week_end_date)`` pairs, newest date first, where
141
+ ``week_end_date`` is the latest end date EITHER table records for that
142
+ date: exactly the rows of the pre-#901 statement, which grouped the
143
+ ``UNION ALL`` of both tables through a temp b-tree over every snapshot ever
144
+ recorded and applied ``LIMIT`` only afterwards (Amendment 1 item 8,
145
+ ``c901-design-2``). Each table now streams its own groups in descending
146
+ date order; this merges the two cursors, combining equal dates with MAX,
147
+ holds one pending group per cursor, and stops at ``limit`` merged distinct
148
+ dates. ``None`` or a negative ``limit`` is unbounded and ``0`` returns
149
+ nothing, as SQL ``LIMIT`` did. The limit applies before any boundary
150
+ conversion or invalid-ref filtering, as it did.
151
+
152
+ Both cursors read one snapshot: inside the caller's transaction when there
153
+ is one (left open), otherwise inside a read transaction opened and closed
154
+ here. Each leg is pinned to its index only when the index exists; the
155
+ usage leg prefers the covering ``idx_usage_week_date_group`` (Amendment
156
+ 1b) and keeps ``idx_usage_week_time`` as its compatibility fallback.
157
+ """
158
+ limit_n = -1 if limit is None else int(limit)
159
+ if limit_n == 0:
160
+ return []
161
+ present = {
162
+ str(row[0]) for row in conn.execute(
163
+ "SELECT name FROM sqlite_master WHERE type='index' AND name IN "
164
+ "('idx_usage_week_date_group', 'idx_usage_week_time', "
165
+ "'idx_cost_week_time')")
166
+ }
167
+ account = "" if account_key is None else " WHERE account_key = ?"
168
+ params: tuple = () if account_key is None else (account_key,)
169
+ usage_index = next(
170
+ (name for name in ("idx_usage_week_date_group", "idx_usage_week_time")
171
+ if name in present), None)
172
+ usage_sql = _RECENT_USAGE_WEEKS_SQL.format(
173
+ hint=f" INDEXED BY {usage_index}" if usage_index else "",
174
+ account=account)
175
+ cost_sql = _RECENT_COST_WEEKS_SQL.format(
176
+ hint=(" INDEXED BY idx_cost_week_time"
177
+ if "idx_cost_week_time" in present else ""),
178
+ account=account)
179
+ own_transaction = not conn.in_transaction
180
+ if own_transaction:
181
+ conn.execute("BEGIN")
182
+ try:
183
+ usage = conn.execute(usage_sql, params)
184
+ cost = conn.execute(cost_sql, params)
185
+ try:
186
+ rows: "list[tuple[object, object]]" = []
187
+ pending_usage = usage.fetchone()
188
+ pending_cost = cost.fetchone()
189
+ while (pending_usage is not None or pending_cost is not None) and (
190
+ limit_n < 0 or len(rows) < limit_n):
191
+ if pending_cost is None or (
192
+ pending_usage is not None
193
+ and _sqlite_sort_key(pending_usage[0])
194
+ > _sqlite_sort_key(pending_cost[0])):
195
+ rows.append((pending_usage[0], pending_usage[1]))
196
+ pending_usage = usage.fetchone()
197
+ elif pending_usage is None or (
198
+ _sqlite_sort_key(pending_cost[0])
199
+ > _sqlite_sort_key(pending_usage[0])):
200
+ rows.append((pending_cost[0], pending_cost[1]))
201
+ pending_cost = cost.fetchone()
202
+ else:
203
+ rows.append((pending_usage[0], _sqlite_max(
204
+ pending_usage[1], pending_cost[1])))
205
+ pending_usage = usage.fetchone()
206
+ pending_cost = cost.fetchone()
207
+ finally:
208
+ usage.close()
209
+ cost.close()
210
+ finally:
211
+ if own_transaction:
212
+ conn.execute("COMMIT")
213
+ return rows
214
+
215
+
91
216
  def get_recent_weeks(
92
217
  conn: sqlite3.Connection, limit: "int | None", *,
93
218
  account_key: "str | None" = None,
@@ -100,33 +225,15 @@ def get_recent_weeks(
100
225
  # (today's byte-identical behavior); a real key / ``unattributed`` scopes both
101
226
  # snapshot legs to that account's weeks (the ``--account`` render consumers —
102
227
  # ``report`` — pass it explicitly).
103
- limit_sql = -1 if limit is None else int(limit)
104
- acct_pred = "" if account_key is None else " WHERE account_key = ?"
105
- acct_p: tuple = () if account_key is None else (account_key,)
106
- rows = conn.execute(
107
- f"""
108
- SELECT week_start_date, MAX(week_end_date) AS week_end_date
109
- FROM (
110
- SELECT week_start_date, week_end_date FROM weekly_usage_snapshots{acct_pred}
111
- UNION ALL
112
- SELECT week_start_date, week_end_date FROM weekly_cost_snapshots{acct_pred}
113
- )
114
- GROUP BY week_start_date
115
- ORDER BY week_start_date DESC
116
- LIMIT ?
117
- """,
118
- acct_p + acct_p + (limit_sql,),
119
- ).fetchall()
120
-
121
228
  refs: list[WeekRef] = []
122
- for row in rows:
123
- date_str = row["week_start_date"]
229
+ for date_str, week_end_date in _recent_week_rows(
230
+ conn, limit, account_key=account_key):
124
231
  canon_start, canon_end = _get_canonical_boundary_for_date(
125
232
  conn, date_str, account_key=account_key)
126
233
  try:
127
234
  ref = make_week_ref(
128
235
  week_start_date=date_str,
129
- week_end_date=row["week_end_date"],
236
+ week_end_date=week_end_date,
130
237
  week_start_at=canon_start,
131
238
  week_end_at=canon_end,
132
239
  )
@@ -0,0 +1,152 @@
1
+ """Immutable account binding carried by every current statusline/OAuth producer.
2
+
3
+ #825/#832 spec 4.1. A binding is captured ONCE, from the stable-read identity
4
+ resolver (`_cctally_core._resolve_active_claude_identity`), and then travels
5
+ with the candidate, the publication plan or the OAuth publication it belongs
6
+ to. No later stage re-resolves the active account: an A->B switch between
7
+ capture and publication can therefore never re-label A's reading as B's.
8
+
9
+ - ``identified`` binds the real account key, which must equal
10
+ ``_lib_accounts.account_key("claude", natural_id)``;
11
+ - ``stably_absent`` (no ``~/.claude.json`` account, api-key mode) binds the
12
+ ``unattributed`` sentinel deliberately;
13
+ - ``torn`` and every unreadable or internally inconsistent read yield NO
14
+ binding. The caller then writes no candidate, mutates no control state and
15
+ consumes no retry attempt.
16
+
17
+ The credential evidence is a salted digest of the selected OAuth credential,
18
+ never the credential itself. Serialization is strict: an exact camelCase key
19
+ set, and ``load`` re-derives the identity evidence and compares it.
20
+
21
+ Pure: no filesystem, clock or ``cctally`` namespace dependency.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import dataclasses
26
+ import hashlib
27
+ import json
28
+ import re
29
+
30
+ import _lib_accounts
31
+
32
+ STATUS_IDENTIFIED = "identified"
33
+ STATUS_STABLY_ABSENT = "stably_absent"
34
+
35
+ _CREDENTIAL_SALT = "cctally-selected-credential-v1\0"
36
+ _DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
37
+ _DOCUMENT_KEYS = frozenset({
38
+ "status", "accountKey", "identityEvidence", "credentialEvidence",
39
+ "naturalId", "email", "planType",
40
+ })
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True)
44
+ class AccountBinding:
45
+ status: str
46
+ account_key: str
47
+ identity_evidence: str
48
+ credential_evidence: str | None = None
49
+ natural_id: str | None = None
50
+ email: str | None = None
51
+ plan_type: str | None = None
52
+
53
+ def document(self) -> dict:
54
+ """The exact persisted shape (camelCase, every key always present)."""
55
+ return {
56
+ "status": self.status,
57
+ "accountKey": self.account_key,
58
+ "identityEvidence": self.identity_evidence,
59
+ "credentialEvidence": self.credential_evidence,
60
+ "naturalId": self.natural_id,
61
+ "email": self.email,
62
+ "planType": self.plan_type,
63
+ }
64
+
65
+ def identity(self) -> dict:
66
+ """The resolver's own shape, for consumers that take an identity."""
67
+ return {
68
+ "status": self.status,
69
+ "account_key": self.account_key,
70
+ "natural_id": self.natural_id,
71
+ "email": self.email,
72
+ "plan_type": self.plan_type,
73
+ }
74
+
75
+ def without_credential(self) -> "AccountBinding":
76
+ """The same identity with no credential evidence (identity equality)."""
77
+ return dataclasses.replace(self, credential_evidence=None)
78
+
79
+
80
+ def credential_digest(credential: str) -> str:
81
+ """The salted credential evidence; the credential itself is never kept."""
82
+ return hashlib.sha256(
83
+ (_CREDENTIAL_SALT + credential).encode("utf-8")).hexdigest()
84
+
85
+
86
+ def _identity_evidence(fields: dict) -> str:
87
+ return hashlib.sha256(json.dumps(
88
+ fields, sort_keys=True, separators=(",", ":")).encode("utf-8")
89
+ ).hexdigest()
90
+
91
+
92
+ def capture(identity, *, credential: str | None = None) -> AccountBinding | None:
93
+ """Bind one resolver result, or return None for a torn, unreadable or
94
+ internally inconsistent read. Never guesses an account."""
95
+ if not isinstance(identity, dict):
96
+ return None
97
+ status = identity.get("status")
98
+ key = identity.get("account_key")
99
+ natural = identity.get("natural_id")
100
+ if status == STATUS_IDENTIFIED:
101
+ if not isinstance(natural, str) or not natural:
102
+ return None
103
+ if key != _lib_accounts.account_key("claude", natural):
104
+ return None
105
+ elif status == STATUS_STABLY_ABSENT:
106
+ if key != _lib_accounts.UNATTRIBUTED or natural is not None:
107
+ return None
108
+ else:
109
+ return None
110
+ fields = {
111
+ "status": status,
112
+ "account_key": key,
113
+ "natural_id": natural,
114
+ "email": identity.get("email"),
115
+ "plan_type": identity.get("plan_type"),
116
+ }
117
+ if any(value is not None and not isinstance(value, str)
118
+ for value in fields.values()):
119
+ return None
120
+ if credential is not None and (not isinstance(credential, str)
121
+ or not credential):
122
+ return None
123
+ return AccountBinding(
124
+ status=status,
125
+ account_key=key,
126
+ identity_evidence=_identity_evidence(fields),
127
+ credential_evidence=(
128
+ None if credential is None else credential_digest(credential)),
129
+ natural_id=natural,
130
+ email=fields["email"],
131
+ plan_type=fields["plan_type"],
132
+ )
133
+
134
+
135
+ def load(document) -> AccountBinding:
136
+ """Validate a persisted binding document; ValueError on any mismatch."""
137
+ if not isinstance(document, dict) or set(document) != _DOCUMENT_KEYS:
138
+ raise ValueError("invalid account binding document")
139
+ binding = capture({
140
+ "status": document["status"],
141
+ "account_key": document["accountKey"],
142
+ "natural_id": document["naturalId"],
143
+ "email": document["email"],
144
+ "plan_type": document["planType"],
145
+ })
146
+ if binding is None or binding.identity_evidence != document["identityEvidence"]:
147
+ raise ValueError("inconsistent account binding")
148
+ evidence = document["credentialEvidence"]
149
+ if evidence is not None and (not isinstance(evidence, str)
150
+ or _DIGEST_RE.fullmatch(evidence) is None):
151
+ raise ValueError("invalid credential evidence")
152
+ return dataclasses.replace(binding, credential_evidence=evidence)