cctally 1.59.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.
@@ -264,6 +264,16 @@ class TuiSessionRow:
264
264
  # without re-reading ``session_files`` and so the share path can
265
265
  # privacy-scrub via ``_lib_share._scrub``.
266
266
  project_path: str | None = None
267
+ # Dashboard/TUI-only: the human-readable session title (AI-generated
268
+ # title when present, else the first non-marker user prompt). Populated
269
+ # by the dashboard/TUI snapshot wrapper (``_cctally_tui._tui_build_sessions``)
270
+ # via ``_lib_conversation_query._session_titles_map``; left ``None`` on the
271
+ # CLI ``session`` / share paths (they call ``build_sessions_view`` directly).
272
+ # This is transcript-derived content — it rides the per-request transcript
273
+ # privacy gate at envelope serialization (never emitted when the gate is
274
+ # closed). Defaults ``None`` so fixture rows built positionally stay
275
+ # compatible.
276
+ title: str | None = None
267
277
 
268
278
 
269
279
  # === Internal helpers ======================================================
@@ -325,7 +335,8 @@ class DailyView:
325
335
  display_tz_label: str = ""
326
336
 
327
337
 
328
- 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):
329
340
  """Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
330
341
 
331
342
  Gap-free: only days with entries appear in ``view.rows`` /
@@ -341,9 +352,22 @@ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
341
352
  Leaves ``DailyPanelRow.label`` and ``intensity_bucket`` at dataclass
342
353
  defaults — the dashboard envelope adapter populates them. CLI /
343
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.
344
365
  """
345
366
  _agg = _load_lib("_lib_aggregators")
346
- 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)
347
371
  if not buckets:
348
372
  return DailyView(
349
373
  rows=(),
@@ -434,7 +458,8 @@ class MonthlyView:
434
458
  display_tz_label: str = ""
435
459
 
436
460
 
437
- 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):
438
463
  """Build a ``MonthlyView`` for the trailing ``n`` calendar months
439
464
  (spec §5.2).
440
465
 
@@ -445,9 +470,21 @@ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
445
470
  Totals (``total_cost_usd`` / ``total_tokens``) sum over the
446
471
  truncated row set so the React panel sees the same number as the
447
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.
448
482
  """
449
483
  _agg = _load_lib("_lib_aggregators")
450
- 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)
451
488
  if not buckets:
452
489
  return MonthlyView(
453
490
  rows=(), aggregated=(),
@@ -535,7 +572,7 @@ class WeeklyView:
535
572
 
536
573
 
537
574
  def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
538
- as_of_utc=None, mode="auto"):
575
+ as_of_utc=None, mode="auto", aggregated_override=None):
539
576
  """Build a ``WeeklyView`` from subscription-week boundaries
540
577
  (spec §5.3).
541
578
 
@@ -552,10 +589,22 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
552
589
 
553
590
  Output is newest-first across ``rows`` / ``aggregated`` /
554
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.
555
601
  """
556
602
  _agg = _load_lib("_lib_aggregators")
557
603
  _cct_core = _load_lib("_cctally_core")
558
- 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)
559
608
  if not buckets_asc:
560
609
  return WeeklyView(
561
610
  rows=(), aggregated=(), overlay=(),
@@ -709,7 +758,8 @@ class TrendView:
709
758
  display_tz_label: str = ""
710
759
 
711
760
 
712
- 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):
713
763
  """Build a ``TrendView`` of the last ``n`` subscription weeks
714
764
  (spec §5.4).
715
765
 
@@ -721,6 +771,17 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
721
771
  cmd_report path (``bin/cctally:~7969-7979``) and ``_tui_build_trend``
722
772
  (``bin/_cctally_tui.py:~1515-1524``).
723
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
+
724
785
  Rows are emitted oldest-first (chronological); each row populates
725
786
  all 17 ``TuiTrendRow`` fields (7 historical + 10 extended).
726
787
  ``delta_dpp`` is computed against the prior chronological row's
@@ -800,7 +861,33 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
800
861
  )
801
862
  usage_captured_at = usage["captured_at_utc"] if usage else None
802
863
  if c._week_ref_has_reset_event(conn, week_ref):
803
- 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
+ )
804
891
  cost_captured_at = (
805
892
  usage_captured_at if cost_usd is not None else None
806
893
  )
@@ -1029,10 +1116,21 @@ class SessionsView:
1029
1116
  display_tz_label: str = ""
1030
1117
 
1031
1118
 
1032
- 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):
1033
1121
  """Build a ``SessionsView`` from joined Claude session entries
1034
1122
  (spec §5.5).
1035
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
+
1036
1134
  ``entries`` is the ``list[_JoinedClaudeEntry]`` from
1037
1135
  ``get_claude_session_entries(range_start, range_end)``. The caller
1038
1136
  controls the date window + ``skip_sync`` semantics; the builder
@@ -1059,8 +1157,11 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="
1059
1157
  is informational only.
1060
1158
  """
1061
1159
  import os as _os # late: keep top-level imports lean.
1062
- _agg = _load_lib("_lib_aggregators")
1063
- 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)
1064
1165
  # Apply limit truncation up front so `rows` and `aggregated` stay
1065
1166
  # in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
1066
1167
  # == len(aggregated)`). limit=None → keep everything.
@@ -1497,6 +1598,7 @@ def build_forecast_view(
1497
1598
  targets=(100, 90),
1498
1599
  skip_sync: bool = False,
1499
1600
  display_tz=None,
1601
+ use_weekref_cost_cache: bool = False,
1500
1602
  ):
1501
1603
  """Build a ``ForecastView`` (issue #57).
1502
1604
 
@@ -1515,7 +1617,10 @@ def build_forecast_view(
1515
1617
  refresh skip).
1516
1618
  """
1517
1619
  c = _cctally()
1518
- 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
+ )
1519
1624
  if inputs is None:
1520
1625
  return ForecastView(
1521
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