cctally 1.60.0 → 1.61.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.
@@ -335,7 +335,8 @@ class DailyView:
335
335
  display_tz_label: str = ""
336
336
 
337
337
 
338
- def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
338
+ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto",
339
+ aggregated_override=None):
339
340
  """Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
340
341
 
341
342
  Gap-free: only days with entries appear in ``view.rows`` /
@@ -351,9 +352,22 @@ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
351
352
  Leaves ``DailyPanelRow.label`` and ``intensity_bucket`` at dataclass
352
353
  defaults — the dashboard envelope adapter populates them. CLI /
353
354
  share consumers ignore them and read ``view.aggregated`` instead.
355
+
356
+ ``aggregated_override`` (#268): when provided (a list/tuple of
357
+ ``BucketUsage`` in ascending bucket-key order), skip the
358
+ ``_aggregate_daily(entries, ...)`` re-costing and build the view over
359
+ those pre-aggregated buckets instead. This is the reuse seam the
360
+ dashboard's cached daily builder uses to serve immutable past days
361
+ from memory while keeping every downstream derivation (rows,
362
+ ``cache_hit_pct``, totals, ``period_start``) single-sourced here. When
363
+ ``None`` (CLI / share / cold path) behavior is unchanged and
364
+ byte-identical.
354
365
  """
355
366
  _agg = _load_lib("_lib_aggregators")
356
- buckets = _agg._aggregate_daily(entries, mode=mode, tz=display_tz)
367
+ if aggregated_override is not None:
368
+ buckets = list(aggregated_override)
369
+ else:
370
+ buckets = _agg._aggregate_daily(entries, mode=mode, tz=display_tz)
357
371
  if not buckets:
358
372
  return DailyView(
359
373
  rows=(),
@@ -444,7 +458,8 @@ class MonthlyView:
444
458
  display_tz_label: str = ""
445
459
 
446
460
 
447
- def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
461
+ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto",
462
+ aggregated_override=None):
448
463
  """Build a ``MonthlyView`` for the trailing ``n`` calendar months
449
464
  (spec §5.2).
450
465
 
@@ -455,9 +470,21 @@ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
455
470
  Totals (``total_cost_usd`` / ``total_tokens``) sum over the
456
471
  truncated row set so the React panel sees the same number as the
457
472
  CLI table footer would.
473
+
474
+ ``aggregated_override`` (#268): when provided (a list/tuple of
475
+ ``BucketUsage`` in ascending bucket-key order), skip the
476
+ ``_aggregate_monthly(entries, ...)`` re-costing and build the view over
477
+ those pre-aggregated buckets. The reverse + cap-to-``n`` +
478
+ ``delta_cost_pct`` + ``is_current`` presentation still runs over the
479
+ assembled list, so the current month's delta sees the prior (cached)
480
+ month (Codex F3). ``None`` preserves the CLI/share/cold behavior
481
+ byte-identically.
458
482
  """
459
483
  _agg = _load_lib("_lib_aggregators")
460
- buckets = _agg._aggregate_monthly(entries, mode=mode, tz=display_tz)
484
+ if aggregated_override is not None:
485
+ buckets = list(aggregated_override)
486
+ else:
487
+ buckets = _agg._aggregate_monthly(entries, mode=mode, tz=display_tz)
461
488
  if not buckets:
462
489
  return MonthlyView(
463
490
  rows=(), aggregated=(),
@@ -545,7 +572,7 @@ class WeeklyView:
545
572
 
546
573
 
547
574
  def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
548
- as_of_utc=None, mode="auto"):
575
+ as_of_utc=None, mode="auto", aggregated_override=None):
549
576
  """Build a ``WeeklyView`` from subscription-week boundaries
550
577
  (spec §5.3).
551
578
 
@@ -562,10 +589,22 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
562
589
 
563
590
  Output is newest-first across ``rows`` / ``aggregated`` /
564
591
  ``overlay`` — the CLI re-reverses for asc rendering.
592
+
593
+ ``aggregated_override`` (#268): when provided (a list/tuple of
594
+ ``BucketUsage`` in ascending bucket-key order — one per SubWeek WITH
595
+ data), skip the ``_aggregate_weekly(entries, weeks)`` re-costing and
596
+ run the overlay / delta / is_current presentation over those
597
+ pre-aggregated buckets. The overlay (``weekly_usage_snapshots``) is
598
+ always re-read fresh here, so a snapshot change is reflected even for a
599
+ cached bucket. ``None`` preserves the CLI/share/cold behavior
600
+ byte-identically.
565
601
  """
566
602
  _agg = _load_lib("_lib_aggregators")
567
603
  _cct_core = _load_lib("_cctally_core")
568
- buckets_asc = _agg._aggregate_weekly(entries, weeks, mode=mode)
604
+ if aggregated_override is not None:
605
+ buckets_asc = list(aggregated_override)
606
+ else:
607
+ buckets_asc = _agg._aggregate_weekly(entries, weeks, mode=mode)
569
608
  if not buckets_asc:
570
609
  return WeeklyView(
571
610
  rows=(), aggregated=(), overlay=(),
@@ -719,7 +758,8 @@ class TrendView:
719
758
  display_tz_label: str = ""
720
759
 
721
760
 
722
- def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
761
+ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
762
+ use_weekref_cost_cache=False):
723
763
  """Build a ``TrendView`` of the last ``n`` subscription weeks
724
764
  (spec §5.4).
725
765
 
@@ -731,6 +771,17 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
731
771
  cmd_report path (``bin/cctally:~7969-7979``) and ``_tui_build_trend``
732
772
  (``bin/_cctally_tui.py:~1515-1524``).
733
773
 
774
+ ``skip_sync`` (default ``False``) threads through to
775
+ ``_compute_cost_for_weekref`` → ``_sum_cost_for_range`` so the
776
+ reset-event live-cost path can read the cache WITHOUT running a
777
+ JSONL ingest. The #268 dashboard/TUI sync-thread rebuild sets it
778
+ ``True`` (it ingests exactly once at the top of the rebuild); every
779
+ other caller — ``cmd_report``, ``_tui_build_trend`` panel, etc. —
780
+ keeps the default so their sync-on-read behavior is byte-identical.
781
+ At scale (~19 reset-event weeks over a 10K-file instance) the default
782
+ ran a full 10K-file glob PER reset week PER call site — the CPU peg
783
+ the #268 M1 sync-once refactor was meant to remove.
784
+
734
785
  Rows are emitted oldest-first (chronological); each row populates
735
786
  all 17 ``TuiTrendRow`` fields (7 historical + 10 extended).
736
787
  ``delta_dpp`` is computed against the prior chronological row's
@@ -810,7 +861,33 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
810
861
  )
811
862
  usage_captured_at = usage["captured_at_utc"] if usage else None
812
863
  if c._week_ref_has_reset_event(conn, week_ref):
813
- cost_usd = c._compute_cost_for_weekref(week_ref)
864
+ if (
865
+ use_weekref_cost_cache
866
+ and week_ref.week_start_at
867
+ and week_ref.week_end_at
868
+ ):
869
+ # #269 §4: a closed subscription week's cost is immutable, so
870
+ # route the reset-event live-cost through the shared per-weekref
871
+ # cache (the OPEN week is decided per call and never cached).
872
+ # `compute` is the from-scratch closure — invoked synchronously
873
+ # inside this iteration, so `week_ref` capture is safe — and
874
+ # `skip_sync=True` unconditionally (the dashboard already synced
875
+ # once at the top of the rebuild; the flag is only True there).
876
+ _sc = _load_lib("_lib_snapshot_cache")
877
+ cost_usd = _sc.cached_weekref_cost(
878
+ week_start_at=parse_iso(
879
+ week_ref.week_start_at, "week_start_at"
880
+ ),
881
+ week_end_at=parse_iso(week_ref.week_end_at, "week_end_at"),
882
+ now_utc=now_utc,
883
+ compute=lambda: c._compute_cost_for_weekref(
884
+ week_ref, skip_sync=True
885
+ ),
886
+ )
887
+ else:
888
+ cost_usd = c._compute_cost_for_weekref(
889
+ week_ref, skip_sync=skip_sync
890
+ )
814
891
  cost_captured_at = (
815
892
  usage_captured_at if cost_usd is not None else None
816
893
  )
@@ -1039,10 +1116,21 @@ class SessionsView:
1039
1116
  display_tz_label: str = ""
1040
1117
 
1041
1118
 
1042
- def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="auto"):
1119
+ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None,
1120
+ mode="auto", aggregated_override=None):
1043
1121
  """Build a ``SessionsView`` from joined Claude session entries
1044
1122
  (spec §5.5).
1045
1123
 
1124
+ ``aggregated_override`` (#268 Group B): when provided, SKIP the
1125
+ ``_aggregate_claude_sessions(entries)`` re-aggregation and use this
1126
+ pre-aggregated ``list[ClaudeSessionUsage]`` verbatim (the caller has
1127
+ already assembled + sorted-by-last_activity-desc the full session set
1128
+ from the module-level ``SessionCache``). ``entries`` is then unused for
1129
+ aggregation. The ``limit`` truncation + the per-row ``TuiSessionRow``
1130
+ derivation below run identically over the override, so a cached rebuild
1131
+ is byte-identical to the from-scratch pass. ``None`` (CLI ``session`` /
1132
+ share / cold paths) is unchanged — the aggregator runs on ``entries``.
1133
+
1046
1134
  ``entries`` is the ``list[_JoinedClaudeEntry]`` from
1047
1135
  ``get_claude_session_entries(range_start, range_end)``. The caller
1048
1136
  controls the date window + ``skip_sync`` semantics; the builder
@@ -1069,8 +1157,11 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="
1069
1157
  is informational only.
1070
1158
  """
1071
1159
  import os as _os # late: keep top-level imports lean.
1072
- _agg = _load_lib("_lib_aggregators")
1073
- aggregated = _agg._aggregate_claude_sessions(entries, mode=mode)
1160
+ if aggregated_override is not None:
1161
+ aggregated = list(aggregated_override)
1162
+ else:
1163
+ _agg = _load_lib("_lib_aggregators")
1164
+ aggregated = _agg._aggregate_claude_sessions(entries, mode=mode)
1074
1165
  # Apply limit truncation up front so `rows` and `aggregated` stay
1075
1166
  # in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
1076
1167
  # == len(aggregated)`). limit=None → keep everything.
@@ -1507,6 +1598,7 @@ def build_forecast_view(
1507
1598
  targets=(100, 90),
1508
1599
  skip_sync: bool = False,
1509
1600
  display_tz=None,
1601
+ use_weekref_cost_cache: bool = False,
1510
1602
  ):
1511
1603
  """Build a ``ForecastView`` (issue #57).
1512
1604
 
@@ -1525,7 +1617,10 @@ def build_forecast_view(
1525
1617
  refresh skip).
1526
1618
  """
1527
1619
  c = _cctally()
1528
- inputs = c._load_forecast_inputs(conn, now_utc, skip_sync=skip_sync)
1620
+ inputs = c._load_forecast_inputs(
1621
+ conn, now_utc, skip_sync=skip_sync,
1622
+ use_weekref_cost_cache=use_weekref_cost_cache,
1623
+ )
1529
1624
  if inputs is None:
1530
1625
  return ForecastView(
1531
1626
  output=None,
package/bin/cctally CHANGED
@@ -487,6 +487,26 @@ build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
487
487
  CodexSessionView = _lib_view_models.CodexSessionView
488
488
  build_codex_session_view = _lib_view_models.build_codex_session_view
489
489
 
490
+ # Snapshot-rebuild cache infrastructure (#268) — the composite data-version
491
+ # signature, the new-entry watermark, the generation counter, the Group A/B
492
+ # cache holders, and the Group A cached-bucket recompute helper. Registered
493
+ # here (before _cctally_dashboard / _cctally_tui load) so those siblings can
494
+ # `from _lib_snapshot_cache import ...` at module top under the _load_sibling
495
+ # loader, which does not place bin/ on sys.path. _load_sibling dedups via
496
+ # sys.modules, so this is the same instance pytest imports directly.
497
+ _lib_snapshot_cache = _load_sibling("_lib_snapshot_cache")
498
+ SnapshotSignature = _lib_snapshot_cache.SnapshotSignature
499
+ compute_signature = _lib_snapshot_cache.compute_signature
500
+ new_min_timestamp = _lib_snapshot_cache.new_min_timestamp
501
+ bump_generation = _lib_snapshot_cache.bump_generation
502
+ current_generation = _lib_snapshot_cache.current_generation
503
+ BucketCache = _lib_snapshot_cache.BucketCache
504
+ SessionCache = _lib_snapshot_cache.SessionCache
505
+ cached_buckets = _lib_snapshot_cache.cached_buckets
506
+ build_cached_group_a = _lib_snapshot_cache.build_cached_group_a
507
+ group_a_cache = _lib_snapshot_cache.group_a_cache
508
+ reset_group_a_state = _lib_snapshot_cache.reset_group_a_state
509
+
490
510
  _lib_render = _load_sibling("_lib_render")
491
511
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
492
512
  _render_blocks_table = _lib_render._render_blocks_table
@@ -665,6 +685,26 @@ _cctally_doctor = _load_sibling("_cctally_doctor")
665
685
  doctor_gather_state = _cctally_doctor.doctor_gather_state
666
686
  cmd_doctor = _cctally_doctor.cmd_doctor
667
687
 
688
+ # Maintainer-local preview channel (hidden `__preview` subcommand). The pure
689
+ # kernel `_lib_preview` loads FIRST so `_cctally_preview`'s late-bound `_lp()`
690
+ # accessor resolves it via sys.modules; `cmd_preview` is the parser's
691
+ # `set_defaults(func=c.cmd_preview)` target. Both modules are excluded from the
692
+ # public mirror (maintainer-only, like the release tooling) — but the public
693
+ # `bin/cctally` still carries THESE load lines (the mirror emits full public
694
+ # file content on the next non-skip commit that touches this file). Tolerate
695
+ # the modules being absent so a public npm/brew build never bricks at import:
696
+ # on failure `cmd_preview` is None and the parser skips registering the hidden
697
+ # `__preview` subcommand entirely (see `_cctally_parser.py`'s
698
+ # `getattr(c, "cmd_preview", None)` guard). In the private tree the modules ARE
699
+ # present, so this is a no-op — `cmd_preview` is defined and `__preview`
700
+ # registers and works exactly as before.
701
+ try:
702
+ _lib_preview = _load_sibling("_lib_preview")
703
+ _cctally_preview = _load_sibling("_cctally_preview")
704
+ cmd_preview = _cctally_preview.cmd_preview
705
+ except Exception:
706
+ cmd_preview = None
707
+
668
708
  # config.json reader/writer/lock + validators + `cctally config` entry point.
669
709
  # Eager-loaded with per-symbol re-export so bare-name callers in cctally
670
710
  # (dashboard `/api/settings`, `cmd_record_usage` reading `load_config()`,
@@ -794,6 +834,9 @@ _discover_codex_session_files = _cctally_cache._discover_codex_session_files
794
834
  IngestStats = _cctally_cache.IngestStats
795
835
  _progress_stderr = _cctally_cache._progress_stderr
796
836
  _ensure_session_files_row = _cctally_cache._ensure_session_files_row
837
+ _acquire_cache_flock = _cctally_cache._acquire_cache_flock
838
+ PruneResult = _cctally_cache.PruneResult
839
+ _prune_orphaned_cache_entries = _cctally_cache._prune_orphaned_cache_entries
797
840
  sync_cache = _cctally_cache.sync_cache
798
841
  backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
799
842
  backfill_ai_titles = _cctally_cache.backfill_ai_titles
@@ -811,6 +854,7 @@ get_codex_entries = _cctally_cache.get_codex_entries
811
854
  _sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
812
855
  get_entries = _cctally_cache.get_entries
813
856
  open_cache_db = _cctally_cache.open_cache_db
857
+ _reset_orphan_warning_throttle = _cctally_cache._reset_orphan_warning_throttle
814
858
  cmd_cache_sync = _cctally_cache.cmd_cache_sync
815
859
 
816
860
 
@@ -1041,6 +1085,8 @@ snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
1041
1085
  _session_detail_to_envelope = _cctally_dashboard._session_detail_to_envelope
1042
1086
  _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = _cctally_dashboard._DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS
1043
1087
  DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
1088
+ _dashboard_self_heal_orphans = _cctally_dashboard._dashboard_self_heal_orphans
1089
+ _register_faulthandler_sigusr1 = _cctally_dashboard._register_faulthandler_sigusr1
1044
1090
  cmd_dashboard = _cctally_dashboard.cmd_dashboard
1045
1091
 
1046
1092
 
@@ -2707,6 +2753,11 @@ def main(argv: list[str] | None = None) -> int:
2707
2753
  base += f" (dev — {_cctally_core.APP_DIR})"
2708
2754
  elif _cctally_core._is_dev_checkout():
2709
2755
  base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
2756
+ # Preview channel (CCTALLY_CHANNEL=preview): append the channel marker
2757
+ # so diagnostics reveal a preview-channel binary. Gated → prod output
2758
+ # is byte-identical when the marker is unset.
2759
+ if _cctally_core.is_preview_channel():
2760
+ base += " (preview)"
2710
2761
  print(base)
2711
2762
  return 0
2712
2763
  if not getattr(args, "func", None):
@@ -2796,6 +2847,12 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2796
2847
  return
2797
2848
  if command == "repair-symlinks":
2798
2849
  return
2850
+ if command == "__preview":
2851
+ # Maintainer-local dev tooling: must not load_config()/spawn a
2852
+ # background update-check or write anything outside the preview dir
2853
+ # (it runs before the wrapper sets CCTALLY_DATA_DIR, so APP_DIR is
2854
+ # still cctally-dev at this point). Same rationale class as doctor.
2855
+ return
2799
2856
  # Self-heal: reconcile current_version with the running binary's
2800
2857
  # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
2801
2858
  # command after a manual upgrade). Runs before load_config so a