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.
@@ -247,7 +247,7 @@ import urllib.request
247
247
  import webbrowser as _wb
248
248
  from dataclasses import dataclass, field, replace
249
249
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
250
- from typing import Any
250
+ from typing import Any, NamedTuple
251
251
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
252
252
 
253
253
 
@@ -316,7 +316,30 @@ from _lib_subscription_weeks import _compute_subscription_weeks
316
316
  from _lib_blocks import _group_entries_into_blocks
317
317
  from _cctally_config import save_config, _load_config_unlocked
318
318
  from _cctally_db import _render_migration_error_banner
319
- from _cctally_cache import get_entries, open_cache_db, sync_cache
319
+ from _cctally_cache import (
320
+ get_entries, iter_entries, open_cache_db, sync_cache,
321
+ _prune_orphaned_cache_entries,
322
+ )
323
+ from _lib_snapshot_cache import (
324
+ build_cached_group_a,
325
+ bump_generation,
326
+ reset_group_a_state,
327
+ reset_projects_env_state,
328
+ reset_session_cache_state,
329
+ reset_weekref_cost_state,
330
+ _max_id as _snapshot_max_id,
331
+ _reset_sig as _snapshot_reset_sig,
332
+ )
333
+
334
+
335
+ # #268 Group A cached-bucket path master switch. Normally True: the
336
+ # calendar builders (daily / monthly / weekly) assemble their per-bucket
337
+ # aggregates through the module-level BucketCache and recompute only the
338
+ # current/dirty slice. Flip to False to force the from-scratch wide-fetch
339
+ # path (the pre-#268 behavior) — the parity tests toggle it to prove the
340
+ # cached and from-scratch rebuilds are byte-identical, and the builders
341
+ # fall back to it automatically if the cache DB can't be opened.
342
+ _GROUP_A_CACHE_ENABLED = True
320
343
 
321
344
 
322
345
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
@@ -351,6 +374,76 @@ def _make_run_sync_now_locked(*args, **kwargs):
351
374
  return sys.modules["cctally"]._make_run_sync_now_locked(*args, **kwargs)
352
375
 
353
376
 
377
+ def _register_faulthandler_sigusr1():
378
+ """Register a SIGUSR1 handler that dumps all-thread tracebacks (#268 M6.1).
379
+
380
+ So any future dashboard spin (a sync thread stuck in a rebuild) is
381
+ self-diagnosing — ``kill -USR1 <pid>`` prints every thread's stack to stderr
382
+ — without needing a root py-spy. Guarded for platforms lacking SIGUSR1
383
+ (Windows), where it is a silent no-op. Idempotent; returns True when the
384
+ handler was registered, False when SIGUSR1 is unavailable."""
385
+ import faulthandler
386
+ import signal
387
+ if not hasattr(signal, "SIGUSR1"):
388
+ return False
389
+ faulthandler.register(signal.SIGUSR1, all_threads=True)
390
+ return True
391
+
392
+
393
+ def _dashboard_self_heal_orphans(*, skip_sync):
394
+ """Prune removed-worktree orphans from the cache using a throwaway
395
+ connection. No-op under frozen (--no-sync) mode. Non-blocking on the
396
+ cache lock (retries on the next cadence if contended). Never raises.
397
+
398
+ #268 M5.2 (spec §7 / Codex F4): a prune that actually deleted rows rewrites
399
+ history in place — which `MAX(id)` alone can't detect (deleting a NON-max
400
+ row leaves the max unchanged). So on any real deletion, bump the
401
+ cache-generation counter (a composite-signature leg, so the next rebuild
402
+ can't idle-short-circuit) AND clear the Group A / session caches, forcing a
403
+ correct cold recompute on the next tick. The prune runs AFTER the tick's
404
+ publish on the same sync thread, so the very next tick recomputes."""
405
+ if skip_sync:
406
+ return None
407
+ try:
408
+ conn = open_cache_db()
409
+ try:
410
+ result = _prune_orphaned_cache_entries(conn, lock_timeout=None)
411
+ finally:
412
+ conn.close()
413
+ except Exception:
414
+ return None
415
+ if result is not None and (result.pruned_entries or result.pruned_files):
416
+ try:
417
+ bump_generation()
418
+ reset_group_a_state()
419
+ reset_session_cache_state()
420
+ # #269 M3.2 (spec §6): the shared per-weekref immutable-cost cache
421
+ # (B1 trend/weekly-history + B3 forecast) rides the SAME prune-site
422
+ # clear — a non-max deletion the reconcile's max-id regression check
423
+ # can't catch. (B2 cache-report cache was dropped at the M2.0 gate,
424
+ # so there is no reset_cache_report_state() to call here.)
425
+ reset_weekref_cost_state()
426
+ # #269 M4.5 (spec §14 Win 2): the projects-envelope per-(project,
427
+ # week) cache rides the same prune-site clear for the same reason.
428
+ reset_projects_env_state()
429
+ # #269 (final reviewer): the per-(project, week) cache clear above is
430
+ # NOT sufficient on its own. `_build_projects_envelope` consults the
431
+ # whole-envelope memo `_PROJECTS_ENV_MEMO` FIRST — and its memo_key is
432
+ # `(max_id, max_wus_id, cw_key, weeks_back)`, which carries NO
433
+ # generation counter. A prune that deletes only NON-max
434
+ # `session_entries` rows leaves `max_id` unchanged, so the memo_key
435
+ # still matches and the memo would stale-serve the pre-prune envelope
436
+ # (still showing the deleted project + its cost) before the fresh
437
+ # per-week cache path is ever reached. Clear the memo too so a real
438
+ # prune actually changes the envelope output.
439
+ _projects_reset_memo()
440
+ except Exception:
441
+ # Invalidation must never turn a successful prune into a failure;
442
+ # a stale-cache tick is self-corrected once the signature next moves.
443
+ pass
444
+ return result
445
+
446
+
354
447
  def _build_forecast_json_payload(*args, **kwargs):
355
448
  return sys.modules["cctally"]._build_forecast_json_payload(*args, **kwargs)
356
449
 
@@ -2464,10 +2557,98 @@ def _compute_intensity_buckets(rows: "list[DailyPanelRow]") -> list[float]:
2464
2557
  return thresholds
2465
2558
 
2466
2559
 
2560
+ def _group_a_monthly_buckets(now_utc, *, n, range_start, display_tz):
2561
+ """Assemble the monthly panel's per-month ``BucketUsage`` list via the
2562
+ #268 Group A cache, or ``None`` to fall back to the wide fetch (spec §5.1).
2563
+
2564
+ ``all_bucket_labels`` is the contiguous set of display-tz ``"%Y-%m"``
2565
+ months spanned by the wide window ``[range_start, now_utc]`` — from
2566
+ ``range_start``'s display-tz month (the oldest an entry in the window can
2567
+ bucket into, including the west-of-UTC boundary-spillover month) up to
2568
+ the current display-tz month — in ascending order. ``build_monthly_view``
2569
+ then reverses + caps to ``n``, dropping any spillover month exactly as
2570
+ the from-scratch path does. Each recompute fetches a ±2-day-padded window
2571
+ (clamped to the wide window) and filters ``_aggregate_monthly`` to the
2572
+ target month, so the boundary follows ``display_tz`` and per-bucket
2573
+ first-seen model order matches the wide pass byte-for-byte.
2574
+ """
2575
+ if not _GROUP_A_CACHE_ENABLED:
2576
+ return None
2577
+ try:
2578
+ cache_conn = open_cache_db()
2579
+ except Exception:
2580
+ return None
2581
+ try:
2582
+ def _tz_month(instant):
2583
+ local = (
2584
+ instant.astimezone(display_tz) if display_tz is not None
2585
+ # internal fallback: host-local intentional
2586
+ else instant.astimezone()
2587
+ )
2588
+ return local.year, local.month
2589
+
2590
+ oldest_y, oldest_m = _tz_month(range_start)
2591
+ newest_y, newest_m = _tz_month(now_utc)
2592
+ # Contiguous months oldest → newest (ascending).
2593
+ labels: list[str] = []
2594
+ y, m = oldest_y, oldest_m
2595
+ while (y, m) <= (newest_y, newest_m):
2596
+ labels.append(f"{y:04d}-{m:02d}")
2597
+ m += 1
2598
+ if m == 13:
2599
+ m = 1
2600
+ y += 1
2601
+ current_label = f"{newest_y:04d}-{newest_m:02d}"
2602
+ tz_sig = display_tz.key if display_tz is not None else "local"
2603
+
2604
+ def _month_bounds(label):
2605
+ yy, mm = (int(x) for x in label.split("-"))
2606
+ start = dt.datetime(yy, mm, 1, tzinfo=dt.timezone.utc)
2607
+ ny, nm = (yy + 1, 1) if mm == 12 else (yy, mm + 1)
2608
+ nxt = dt.datetime(ny, nm, 1, tzinfo=dt.timezone.utc)
2609
+ return start, nxt
2610
+
2611
+ def _fetch(label):
2612
+ start, nxt = _month_bounds(label)
2613
+ # ±2 days of slack covers the display-tz month boundary for any
2614
+ # offset; clamp to the wide window so nothing after now_utc /
2615
+ # before range_start leaks in.
2616
+ lo = max(start - dt.timedelta(days=2), range_start)
2617
+ hi = min(nxt + dt.timedelta(days=2), now_utc)
2618
+ if hi <= lo:
2619
+ return []
2620
+ return iter_entries(cache_conn, lo, hi)
2621
+
2622
+ def _agg_one(label, entries):
2623
+ for b in _aggregate_monthly(entries, tz=display_tz):
2624
+ if b.bucket == label:
2625
+ return b
2626
+ return None
2627
+
2628
+ def _end_of(label):
2629
+ _start, nxt = _month_bounds(label)
2630
+ return nxt + dt.timedelta(days=2)
2631
+
2632
+ buckets = build_cached_group_a(
2633
+ "monthly",
2634
+ cache_conn=cache_conn,
2635
+ all_bucket_labels=labels,
2636
+ current_label=current_label,
2637
+ bucket_end_of=_end_of,
2638
+ fetch_bucket_entries=_fetch,
2639
+ aggregate_one=_agg_one,
2640
+ extra_signature=("monthly", tz_sig),
2641
+ )
2642
+ return tuple(buckets)
2643
+ finally:
2644
+ cache_conn.close()
2645
+
2646
+
2467
2647
  def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
2468
2648
  now_utc: "dt.datetime",
2469
2649
  *, n: int = 12,
2470
2650
  skip_sync: bool = False,
2651
+ use_group_a_cache: bool = False,
2471
2652
  display_tz: "ZoneInfo | None" = None) -> "list[MonthlyPeriodRow]":
2472
2653
  """Latest n calendar months as MonthlyPeriodRow, newest-first.
2473
2654
 
@@ -2496,17 +2677,136 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
2496
2677
  range_start = dt.datetime(sy, sm, 1, tzinfo=dt.timezone.utc)
2497
2678
  range_end = now_utc
2498
2679
 
2499
- entries = get_entries(range_start, range_end, skip_sync=skip_sync)
2680
+ # #268 Group A cache: assemble the per-month BucketUsage list through the
2681
+ # module cache (recompute only the current + watermark-dirty months;
2682
+ # serve immutable past months from memory) and hand it to
2683
+ # build_monthly_view as aggregated_override. Gated on ``use_group_a_cache``
2684
+ # — set ONLY by the sync-thread rebuild, never by ``skip_sync`` (the
2685
+ # share-period-override path also runs ``skip_sync=True`` but on an HTTP
2686
+ # thread with a shifted PAST now, which would pollute the shared cache with
2687
+ # a partial past-month bucket — see ``_dashboard_build_daily_panel``). Every
2688
+ # non-sync-thread caller falls through to the from-scratch wide fetch. Also
2689
+ # falls back when disabled / cache DB unavailable.
2690
+ aggregated_override = (
2691
+ _group_a_monthly_buckets(
2692
+ now_utc, n=n, range_start=range_start, display_tz=display_tz,
2693
+ )
2694
+ if use_group_a_cache else None
2695
+ )
2500
2696
  c = _cctally()
2501
- view = c.build_monthly_view(entries, now_utc=now_utc, n=n,
2502
- display_tz=display_tz)
2697
+ if aggregated_override is None:
2698
+ entries = get_entries(range_start, range_end, skip_sync=skip_sync)
2699
+ view = c.build_monthly_view(entries, now_utc=now_utc, n=n,
2700
+ display_tz=display_tz)
2701
+ else:
2702
+ view = c.build_monthly_view((), now_utc=now_utc, n=n,
2703
+ display_tz=display_tz,
2704
+ aggregated_override=aggregated_override)
2503
2705
  return list(view.rows)
2504
2706
 
2505
2707
 
2708
+ def _group_a_weekly_buckets(stats_conn, now_utc, *, weeks):
2709
+ """Assemble the weekly panel's per-week ``BucketUsage`` list via the #268
2710
+ Group A cache, or ``None`` to fall back to the wide fetch (spec §5.1).
2711
+
2712
+ ``all_bucket_labels`` = each SubWeek's ``start_date.isoformat()`` in
2713
+ ascending order (matching ``_aggregate_weekly``'s sorted-key output);
2714
+ ``current_label`` = the SubWeek containing ``now_utc`` (always recomputed
2715
+ as the open week). Each recompute fetches ``[week.start_ts, min(week.end_ts,
2716
+ now_utc)]`` (clamped to now, matching the wide fetch's ``range_end`` bound)
2717
+ and runs ``_aggregate_weekly`` over the FULL ``weeks`` list — extracting
2718
+ the target week's bucket — so overlapping SubWeeks (reset-day drift) keep
2719
+ their first-match-wins assignment byte-for-byte with the wide pass.
2720
+
2721
+ Weekly is the multi-table case (spec §5.1): the SubWeek boundaries derive
2722
+ from ``weekly_usage_snapshots`` + the reset-event tables, so the cached raw
2723
+ aggregate could shift when any of those move. ``extra_signature`` folds in
2724
+ ``MAX(weekly_usage_snapshots.id)`` / ``MAX(weekly_cost_snapshots.id)`` /
2725
+ the reset-event change-signal — a change to any full-invalidates the weekly
2726
+ namespace (the scoped M2.4 fallback: recompute-all on a weekly-relevant
2727
+ change, cache-hit only when nothing weekly-relevant moved — still the idle
2728
+ win, and trivially byte-identical). Pure session-entry adds stay per-week
2729
+ via the watermark. The overlay / Bug-K presentation reruns fresh each tick,
2730
+ so a snapshot change is reflected even on a cache-served bucket.
2731
+ """
2732
+ if not _GROUP_A_CACHE_ENABLED:
2733
+ return None
2734
+ try:
2735
+ cache_conn = open_cache_db()
2736
+ except Exception:
2737
+ return None
2738
+ try:
2739
+ sw_by_label = {w.start_date.isoformat(): w for w in weeks}
2740
+ labels = [w.start_date.isoformat() for w in weeks] # ascending
2741
+ # current_label = the SubWeek that contains now_utc (the open week);
2742
+ # fall back to the newest week if none contains it.
2743
+ current_label = None
2744
+ for w in weeks:
2745
+ try:
2746
+ s = parse_iso_datetime(w.start_ts, "week.start_ts")
2747
+ e = parse_iso_datetime(w.end_ts, "week.end_ts")
2748
+ except ValueError:
2749
+ continue
2750
+ if s <= now_utc < e:
2751
+ current_label = w.start_date.isoformat()
2752
+ break
2753
+ if current_label is None:
2754
+ current_label = max(
2755
+ weeks, key=lambda w: w.start_date
2756
+ ).start_date.isoformat()
2757
+
2758
+ # Weekly-relevant signature legs: any change full-invalidates.
2759
+ extra_signature = (
2760
+ _snapshot_max_id(stats_conn, "weekly_usage_snapshots"),
2761
+ _snapshot_max_id(stats_conn, "weekly_cost_snapshots"),
2762
+ _snapshot_reset_sig(stats_conn),
2763
+ )
2764
+
2765
+ def _fetch(label):
2766
+ w = sw_by_label[label]
2767
+ s = parse_iso_datetime(w.start_ts, "week.start_ts")
2768
+ e = parse_iso_datetime(w.end_ts, "week.end_ts")
2769
+ hi = min(e, now_utc) # cap the open week at now, like the wide fetch
2770
+ if hi <= s:
2771
+ return []
2772
+ return iter_entries(cache_conn, s, hi)
2773
+
2774
+ def _agg_one(label, entries):
2775
+ # Aggregate against the FULL weeks list so overlapping SubWeeks
2776
+ # resolve first-match-wins exactly as the wide pass does; extract
2777
+ # the target week's bucket.
2778
+ for b in _aggregate_weekly(entries, weeks):
2779
+ if b.bucket == label:
2780
+ return b
2781
+ return None
2782
+
2783
+ def _end_of(label):
2784
+ w = sw_by_label[label]
2785
+ try:
2786
+ return parse_iso_datetime(w.end_ts, "week.end_ts")
2787
+ except ValueError:
2788
+ return None
2789
+
2790
+ buckets = build_cached_group_a(
2791
+ "weekly",
2792
+ cache_conn=cache_conn,
2793
+ all_bucket_labels=labels,
2794
+ current_label=current_label,
2795
+ bucket_end_of=_end_of,
2796
+ fetch_bucket_entries=_fetch,
2797
+ aggregate_one=_agg_one,
2798
+ extra_signature=extra_signature,
2799
+ )
2800
+ return tuple(buckets)
2801
+ finally:
2802
+ cache_conn.close()
2803
+
2804
+
2506
2805
  def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2507
2806
  now_utc: "dt.datetime",
2508
2807
  *, n: int = 12,
2509
- skip_sync: bool = False) -> "list[WeeklyPeriodRow]":
2808
+ skip_sync: bool = False,
2809
+ use_group_a_cache: bool = False) -> "list[WeeklyPeriodRow]":
2510
2810
  """Latest n subscription weeks as WeeklyPeriodRow, newest-first.
2511
2811
 
2512
2812
  Thin builder-using prelude + Bug-K pre-credit synthesis on top of
@@ -2534,7 +2834,6 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2534
2834
  range_start,
2535
2835
  parse_iso_datetime(weeks[0].start_ts, "week_start_at"),
2536
2836
  )
2537
- entries = get_entries(fetch_start, range_end, skip_sync=skip_sync)
2538
2837
  as_of_utc = (
2539
2838
  range_end.astimezone(dt.timezone.utc)
2540
2839
  .replace(microsecond=0)
@@ -2542,12 +2841,37 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2542
2841
  .replace("+00:00", "Z")
2543
2842
  )
2544
2843
 
2545
- # Builder prelude: produce the natural newest-first rows + overlay.
2546
- c = _cctally()
2547
- view = c.build_weekly_view(
2548
- conn, entries, weeks=weeks, now_utc=now_utc,
2549
- display_tz=None, as_of_utc=as_of_utc,
2844
+ # #268 Group A cache: assemble the per-week BucketUsage list through the
2845
+ # module cache (recompute only the current + watermark-dirty weeks; serve
2846
+ # immutable past weeks from memory) and hand it to build_weekly_view as
2847
+ # aggregated_override. Gated on ``use_group_a_cache`` — set ONLY by the
2848
+ # sync-thread rebuild, never by ``skip_sync``. The share-period-override
2849
+ # path also runs ``skip_sync=True`` but on an HTTP thread with a shifted
2850
+ # PAST now; routing it through the shared cache would clamp the
2851
+ # now_override-current week to a partial aggregate and cache it under a real
2852
+ # PAST-week label (weekly is the worst case — its extra_signature is
2853
+ # stats-only, so nothing invalidates the polluted week until a data change
2854
+ # lands). Every non-sync-thread caller falls through to the from-scratch
2855
+ # wide fetch, as does the cache-disabled / cache-unavailable case. The
2856
+ # overlay + Bug-K + delta + is_current presentation always reruns fresh over
2857
+ # the assembled list.
2858
+ aggregated_override = (
2859
+ _group_a_weekly_buckets(conn, now_utc, weeks=weeks)
2860
+ if use_group_a_cache else None
2550
2861
  )
2862
+ c = _cctally()
2863
+ if aggregated_override is None:
2864
+ entries = get_entries(fetch_start, range_end, skip_sync=skip_sync)
2865
+ view = c.build_weekly_view(
2866
+ conn, entries, weeks=weeks, now_utc=now_utc,
2867
+ display_tz=None, as_of_utc=as_of_utc,
2868
+ )
2869
+ else:
2870
+ view = c.build_weekly_view(
2871
+ conn, (), weeks=weeks, now_utc=now_utc,
2872
+ display_tz=None, as_of_utc=as_of_utc,
2873
+ aggregated_override=aggregated_override,
2874
+ )
2551
2875
  if not view.rows:
2552
2876
  return []
2553
2877
 
@@ -2615,9 +2939,10 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2615
2939
  # _apply_reset_events_to_subweeks shifts the credited SubWeek's
2616
2940
  # start_ts to ``effective_reset_at_utc``, so _aggregate_weekly's
2617
2941
  # bucket for that SubWeek already covers ONLY the post-credit
2618
- # interval. We rebuild the pre-credit bucket here by filtering the
2619
- # same ``entries`` list to ``[original_start, effective)`` and
2620
- # re-aggregating cost / tokens / per-model.
2942
+ # interval. We rebuild the pre-credit bucket here by fetching the
2943
+ # ``[original_start, effective)`` window directly (#268: no longer a
2944
+ # wide ``entries`` list on the cached path) and re-aggregating cost /
2945
+ # tokens / per-model.
2621
2946
  #
2622
2947
  # The pre-credit row's ``used_pct`` comes from the
2623
2948
  # weekly_usage_snapshots row captured at-or-before the credit
@@ -2689,12 +3014,16 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2689
3014
  # No pre-credit interval to aggregate.
2690
3015
  continue
2691
3016
 
2692
- # Aggregate entries in [original_start, effective).
3017
+ # Aggregate entries in [original_start, effective). Fetch this
3018
+ # window directly (skip_sync=True — the rebuild already ingested,
3019
+ # or the fallback's wide fetch above did) rather than filtering a
3020
+ # wide `entries` list, so Bug-K works identically on the cached
3021
+ # path (where no wide entry list exists) and the fallback path.
2693
3022
  pre_input = pre_output = pre_cc = pre_cr = 0
2694
3023
  pre_cost = 0.0
2695
3024
  pre_models: dict[str, float] = {}
2696
3025
  pre_entry_count = 0
2697
- for e in entries:
3026
+ for e in get_entries(original_start_dt, eff_dt, skip_sync=True):
2698
3027
  if original_start_dt <= e.timestamp < eff_dt:
2699
3028
  usage = e.usage
2700
3029
  pre_input += usage.get("input_tokens", 0)
@@ -2965,11 +3294,95 @@ def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
2965
3294
  return list(view.rows)
2966
3295
 
2967
3296
 
3297
+ def _group_a_daily_buckets(now_utc, *, n, display_tz):
3298
+ """Assemble the daily panel's per-day ``BucketUsage`` list via the #268
3299
+ Group A cache, or ``None`` to signal the caller should take the
3300
+ from-scratch wide-fetch path (spec §5.1).
3301
+
3302
+ Returns a tuple of ``BucketUsage`` in ascending date order (matching
3303
+ ``_aggregate_daily``'s sorted output), so ``build_daily_view`` consumes
3304
+ it as ``aggregated_override`` with no reorder. Each recompute fetches a
3305
+ ±1-day-padded window and filters ``_aggregate_daily`` to the target date,
3306
+ so the day boundary follows ``display_tz`` exactly (DST-safe) and the
3307
+ per-bucket first-seen model order reproduces the wide pass byte-for-byte.
3308
+ ``None`` is returned when the cache path is disabled or the cache DB is
3309
+ unavailable — the builder then does the original wide aggregation.
3310
+ """
3311
+ if not _GROUP_A_CACHE_ENABLED:
3312
+ return None
3313
+ try:
3314
+ cache_conn = open_cache_db()
3315
+ except Exception:
3316
+ return None
3317
+ try:
3318
+ # The from-scratch path fetches exactly [range_start, now_utc]; the
3319
+ # per-day fetches below clamp to the SAME window so the current day's
3320
+ # bucket never picks up an entry after now_utc (and nothing outside
3321
+ # the wide window leaks in) — byte-identical to the wide fetch.
3322
+ range_start = now_utc - dt.timedelta(days=n + 1)
3323
+ today_local = (
3324
+ now_utc.astimezone(display_tz) if display_tz is not None
3325
+ # internal fallback: host-local intentional
3326
+ else now_utc.astimezone()
3327
+ ).date()
3328
+ # Contiguous n-day window, oldest → newest (ascending, so the
3329
+ # assembled list matches _aggregate_daily's sorted-key order).
3330
+ labels = [
3331
+ (today_local - dt.timedelta(days=i)).isoformat()
3332
+ for i in range(n - 1, -1, -1)
3333
+ ]
3334
+ current_label = today_local.isoformat()
3335
+ tz_sig = display_tz.key if display_tz is not None else "local"
3336
+
3337
+ def _fetch(label):
3338
+ d = dt.date.fromisoformat(label)
3339
+ base = dt.datetime(d.year, d.month, d.day, tzinfo=dt.timezone.utc)
3340
+ # ±1 day of slack fully covers the display-tz day for any tz
3341
+ # offset; the aggregate filter below selects exactly the label's
3342
+ # entries, so over-fetch is harmless and DST-robust. Clamp to the
3343
+ # wide [range_start, now_utc] window for exact parity.
3344
+ lo = max(base - dt.timedelta(days=1), range_start)
3345
+ hi = min(base + dt.timedelta(days=2), now_utc)
3346
+ if hi <= lo:
3347
+ return []
3348
+ return iter_entries(cache_conn, lo, hi)
3349
+
3350
+ def _agg_one(label, entries):
3351
+ for b in _aggregate_daily(entries, tz=display_tz):
3352
+ if b.bucket == label:
3353
+ return b
3354
+ return None
3355
+
3356
+ def _end_of(label):
3357
+ d = dt.date.fromisoformat(label)
3358
+ # Over-estimate the day's UTC end (true end ≤ (d+1) 12:00 UTC for
3359
+ # any tz); over-marking dirty is safe, under-marking is not.
3360
+ return (
3361
+ dt.datetime(d.year, d.month, d.day, tzinfo=dt.timezone.utc)
3362
+ + dt.timedelta(days=2)
3363
+ )
3364
+
3365
+ buckets = build_cached_group_a(
3366
+ "daily",
3367
+ cache_conn=cache_conn,
3368
+ all_bucket_labels=labels,
3369
+ current_label=current_label,
3370
+ bucket_end_of=_end_of,
3371
+ fetch_bucket_entries=_fetch,
3372
+ aggregate_one=_agg_one,
3373
+ extra_signature=("daily", tz_sig),
3374
+ )
3375
+ return tuple(buckets)
3376
+ finally:
3377
+ cache_conn.close()
3378
+
3379
+
2968
3380
  def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2969
3381
  now_utc: "dt.datetime",
2970
3382
  *,
2971
3383
  n: int = 30,
2972
3384
  skip_sync: bool = False,
3385
+ use_group_a_cache: bool = False,
2973
3386
  display_tz: "ZoneInfo | None" = None) -> "list[DailyPanelRow]":
2974
3387
  """Latest n display-tz dates as DailyPanelRow, newest-first.
2975
3388
 
@@ -3004,11 +3417,37 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
3004
3417
  # forgiving of tz boundary issues.
3005
3418
  range_start = now_utc - dt.timedelta(days=n + 1)
3006
3419
  range_end = now_utc
3007
- entries = get_entries(range_start, range_end, skip_sync=skip_sync)
3420
+
3421
+ # #268 Group A cache: assemble the per-day BucketUsage list through the
3422
+ # module-level cache (recompute only the current + watermark-dirty days;
3423
+ # serve immutable past days from memory) and hand it to build_daily_view
3424
+ # as ``aggregated_override`` so every downstream derivation stays
3425
+ # single-sourced. Gated on ``use_group_a_cache`` — set ONLY by the
3426
+ # sync-thread rebuild (``_tui_build_snapshot``), which runs on a
3427
+ # process-consistent ``now``. NOT gated on ``skip_sync``: the
3428
+ # share-period-override path (``_share_apply_period_override``) also runs
3429
+ # ``skip_sync=True`` but on an HTTP handler thread with a shifted (PAST)
3430
+ # ``now_override`` — routing it through the shared module cache would clamp
3431
+ # the now_override-current bucket to a partial aggregate and cache it under
3432
+ # a real PAST-period label, truncating the live dashboard's past day (and
3433
+ # would mutate the module cache unlocked, off the sync thread). So every
3434
+ # non-sync-thread caller (default ``use_group_a_cache=False``) takes the
3435
+ # original from-scratch wide fetch. Also falls back when the cache is
3436
+ # disabled (parity tests) or the cache DB can't be opened.
3437
+ aggregated_override = (
3438
+ _group_a_daily_buckets(now_utc, n=n, display_tz=display_tz)
3439
+ if use_group_a_cache else None
3440
+ )
3008
3441
 
3009
3442
  c = _cctally()
3010
- view = c.build_daily_view(entries, now_utc=now_utc,
3011
- display_tz=display_tz)
3443
+ if aggregated_override is None:
3444
+ entries = get_entries(range_start, range_end, skip_sync=skip_sync)
3445
+ view = c.build_daily_view(entries, now_utc=now_utc,
3446
+ display_tz=display_tz)
3447
+ else:
3448
+ view = c.build_daily_view((), now_utc=now_utc,
3449
+ display_tz=display_tz,
3450
+ aggregated_override=aggregated_override)
3012
3451
  if not view.rows:
3013
3452
  return []
3014
3453
 
@@ -3135,12 +3574,189 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
3135
3574
  yield row
3136
3575
 
3137
3576
 
3577
+ class _ProjWeekBucket(NamedTuple):
3578
+ """One (bucket_path, week) immutable aggregate for the projects-envelope
3579
+ per-week cache (#269 §14 Win 2).
3580
+
3581
+ All fields are entry-local — each entry belongs to exactly one bucket and
3582
+ one week — so a CLOSED week's values are stable and reproduce the
3583
+ full-window walk's contribution for that week byte-for-byte.
3584
+
3585
+ ``first_seen`` / ``last_seen`` are the min / max PARSED event datetimes
3586
+ (emitted via ``_iso_z``). ``first_order`` / ``first_id`` / ``first_key`` are
3587
+ the SQL-FIRST entry for this bucket in this week (first-encountered under
3588
+ ``ORDER BY timestamp_utc ASC, id ASC``): ``first_order`` is the RAW
3589
+ ``timestamp_utc`` string, so the ``key_by_bucket`` reconstruction argmin
3590
+ reproduces the from-scratch global first-seen exactly (Codex-M4 P1 — the
3591
+ no-git ``display_key`` makes ``key_by_bucket[bp]`` non-deterministic per
3592
+ bucket_path; the envelope picks by global first-seen order).
3593
+ """
3594
+ cost_usd: float
3595
+ sessions_count: int
3596
+ first_seen: "dt.datetime"
3597
+ last_seen: "dt.datetime"
3598
+ first_order: str
3599
+ first_id: int
3600
+ first_key: "Any"
3601
+
3602
+
3603
+ def _aggregate_projects_week(
3604
+ conn: "sqlite3.Connection",
3605
+ *,
3606
+ week_start: "dt.datetime",
3607
+ week_end: "dt.datetime",
3608
+ resolver_cache: dict,
3609
+ ) -> "tuple[dict[str, _ProjWeekBucket], float]":
3610
+ """Aggregate one Monday-anchored subscription week's entry slice.
3611
+
3612
+ Returns ``({bucket_path: _ProjWeekBucket}, week_total_cost)``. Byte-identical
3613
+ to the contribution the full-window walk in ``_build_projects_envelope``
3614
+ makes for this week: the per-week query returns entries in the same
3615
+ ``timestamp_utc ASC, id ASC`` order, filtered to those whose
3616
+ Monday-anchored week equals ``week_start``, so the per-bucket cost sum, the
3617
+ entry-order ``week_total`` sum, the session set, and the first/last-seen +
3618
+ SQL-first-entry captures all reproduce the full pass. ``week_end`` is the
3619
+ inclusive query bound (``<= ?``); the exact next-Monday boundary entry is
3620
+ fetched but filtered out (it belongs to the next week) — the next week's
3621
+ slice keeps it, so no entry is lost or double-counted.
3622
+ """
3623
+ c = _cctally()
3624
+ _resolve_project_key = c._resolve_project_key
3625
+ week_total = 0.0
3626
+ mut: "dict[str, dict]" = {}
3627
+ for row in _projects_iter_session_entries(
3628
+ conn, since=week_start, until=week_end,
3629
+ ):
3630
+ (entry_id, ts_iso, model, input_tok, output_tok,
3631
+ cache_create, cache_read, cost_raw, source_path,
3632
+ session_id, project_path) = row
3633
+ if model == "<synthetic>":
3634
+ continue
3635
+ ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3636
+ if _projects_week_start_monday_utc(ts) != week_start:
3637
+ continue
3638
+ entry_cost = _calculate_entry_cost(
3639
+ model,
3640
+ {
3641
+ "input_tokens": input_tok or 0,
3642
+ "output_tokens": output_tok or 0,
3643
+ "cache_creation_input_tokens": cache_create or 0,
3644
+ "cache_read_input_tokens": cache_read or 0,
3645
+ },
3646
+ mode="auto",
3647
+ cost_usd=cost_raw,
3648
+ )
3649
+ pkey = _resolve_project_key(project_path, "git-root", resolver_cache)
3650
+ bp = pkey.bucket_path
3651
+ a = mut.get(bp)
3652
+ if a is None:
3653
+ a = {
3654
+ "cost_usd": 0.0,
3655
+ "sessions": set(),
3656
+ "first_seen": ts,
3657
+ "last_seen": ts,
3658
+ "first_order": ts_iso,
3659
+ "first_id": entry_id,
3660
+ "first_key": pkey,
3661
+ }
3662
+ mut[bp] = a
3663
+ a["cost_usd"] += entry_cost
3664
+ if session_id:
3665
+ a["sessions"].add(session_id)
3666
+ elif source_path:
3667
+ a["sessions"].add(source_path)
3668
+ if ts < a["first_seen"]:
3669
+ a["first_seen"] = ts
3670
+ if ts > a["last_seen"]:
3671
+ a["last_seen"] = ts
3672
+ week_total += entry_cost
3673
+ out = {
3674
+ bp: _ProjWeekBucket(
3675
+ cost_usd=a["cost_usd"],
3676
+ sessions_count=len(a["sessions"]),
3677
+ first_seen=a["first_seen"],
3678
+ last_seen=a["last_seen"],
3679
+ first_order=a["first_order"],
3680
+ first_id=a["first_id"],
3681
+ first_key=a["first_key"],
3682
+ )
3683
+ for bp, a in mut.items()
3684
+ }
3685
+ return out, week_total
3686
+
3687
+
3688
+ def _assemble_projects_via_cache(
3689
+ conn: "sqlite3.Connection",
3690
+ *,
3691
+ weeks_full: "list[dt.datetime]",
3692
+ cw_start: "dt.datetime",
3693
+ cw_end: "dt.datetime",
3694
+ ) -> "tuple[dict, dict, dict]":
3695
+ """Flag-ON assembly (#269 §14 Win 2): recompute only the CURRENT week each
3696
+ warm tick; serve CLOSED weeks from the per-(project, week) cache on a hit and
3697
+ RECOMPUTE-AND-POPULATE on a miss (cold / Monday rollover / window slide,
3698
+ Codex-M4 P2). Returns ``(buckets, total_cost_by_week, key_by_bucket)`` in the
3699
+ exact shape the from-scratch walk produces, so the downstream disambiguation
3700
+ / attribution / trend assembly runs unchanged and byte-identically.
3701
+ """
3702
+ c = _cctally()
3703
+ sc = c._load_sibling("_lib_snapshot_cache")
3704
+ resolver_cache: dict = {}
3705
+ buckets: dict = {}
3706
+ total_cost_by_week: dict = {}
3707
+ key_by_bucket: dict = {}
3708
+ # Reconstruct key_by_bucket by the GLOBAL first-seen SQL order
3709
+ # (timestamp_utc ASC, id ASC): per bucket_path, the argmin of
3710
+ # (first_order, first_id) across its assembled weeks (Codex-M4 P1).
3711
+ best_order: dict = {}
3712
+
3713
+ def _merge_week(w, week_buckets, week_total):
3714
+ # `total_cost_by_week` mirrors the from-scratch dict, which is only
3715
+ # populated for weeks that had entries (an empty week stays absent and
3716
+ # falls back to `.get(w, 0.0)` downstream).
3717
+ if week_buckets:
3718
+ total_cost_by_week[w] = week_total
3719
+ for bp, wb in week_buckets.items():
3720
+ buckets[(bp, w)] = {
3721
+ "cost_usd": wb.cost_usd,
3722
+ # `sessions` is only ever `len()`-d downstream; a `range` of the
3723
+ # cached count reproduces that without storing the id set.
3724
+ "sessions": range(wb.sessions_count),
3725
+ "first_seen": wb.first_seen,
3726
+ "last_seen": wb.last_seen,
3727
+ }
3728
+ cand = (wb.first_order, wb.first_id)
3729
+ if bp not in best_order or cand < best_order[bp]:
3730
+ best_order[bp] = cand
3731
+ key_by_bucket[bp] = wb.first_key
3732
+
3733
+ for w in weeks_full:
3734
+ if w == cw_start:
3735
+ week_buckets, week_total = _aggregate_projects_week(
3736
+ conn, week_start=w, week_end=cw_end, resolver_cache=resolver_cache,
3737
+ )
3738
+ else:
3739
+ week_iso = sc.projects_env_week_key(w)
3740
+ hit = sc.projects_env_week_get(week_iso)
3741
+ if hit is not None:
3742
+ week_buckets, week_total = hit
3743
+ else:
3744
+ week_buckets, week_total = _aggregate_projects_week(
3745
+ conn, week_start=w, week_end=w + dt.timedelta(days=7),
3746
+ resolver_cache=resolver_cache,
3747
+ )
3748
+ sc.projects_env_week_put(week_iso, week_buckets, week_total)
3749
+ _merge_week(w, week_buckets, week_total)
3750
+ return buckets, total_cost_by_week, key_by_bucket
3751
+
3752
+
3138
3753
  def _build_projects_envelope(
3139
3754
  conn: "sqlite3.Connection",
3140
3755
  *,
3141
3756
  now_utc: "dt.datetime",
3142
3757
  current_week: "Any | None" = None,
3143
3758
  weeks_back: int = 12,
3759
+ use_projects_env_cache: bool = False,
3144
3760
  ) -> dict:
3145
3761
  """Build the ``projects.{current_week, trend}`` envelope block.
3146
3762
 
@@ -3220,95 +3836,105 @@ def _build_projects_envelope(
3220
3836
  until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
3221
3837
 
3222
3838
  # ---- Bucket entries per (ProjectKey, week_start) --------------------
3223
- # `_resolve_project_key` is the production resolver; we use git-root
3224
- # mode (default for `cmd_project --group` absent) — matches the
3225
- # CLI's default.
3226
- _resolve_project_key = c._resolve_project_key
3227
- resolver_cache: dict = {}
3228
-
3229
- # buckets[(bucket_path, week_start)] -> {key, cost, sessions, ...}
3230
- buckets: dict[tuple[str, dt.datetime], dict] = {}
3231
- # Track total cost per week across ALL entries (attribution denominator).
3232
- total_cost_by_week: dict[dt.datetime, float] = {}
3233
- # Track first-seen ProjectKey instance per bucket_path so we can pass
3234
- # the dict to `_project_disambiguate_labels` (which keys on `key.bucket_path`
3235
- # equality via ProjectKey.__eq__).
3236
- key_by_bucket: dict[str, Any] = {}
3237
-
3238
- def _week_for(ts: dt.datetime) -> "dt.datetime | None":
3239
- wstart = _projects_week_start_monday_utc(ts)
3240
- if wstart < weeks_full[0] or wstart > weeks_full[-1]:
3241
- return None
3242
- return wstart
3243
-
3244
- # Orphan handling: `_projects_iter_session_entries` LEFT JOINs
3245
- # `session_files` so entries whose source_path has no
3246
- # `session_files` row return `project_path = NULL`. Below,
3247
- # `_resolve_project_key(None, ...)` maps that to the
3248
- # `(unknown)` bucket — same identity the drill-down's explicit
3249
- # orphan scan in `_project_detail_for_window` (see the
3250
- # ``if unknown_bucket:`` branch around the
3251
- # `orphan_cur` SELECT) collects via a NULL-side LEFT JOIN. The
3252
- # two paths converge on the same `(unknown)` source_path set.
3253
- for row in _projects_iter_session_entries(
3254
- conn, since=since_dt, until=until_dt,
3255
- ):
3256
- (entry_id, ts_iso, model, input_tok, output_tok,
3257
- cache_create, cache_read, cost_raw, source_path,
3258
- session_id, project_path) = row
3259
- if model == "<synthetic>":
3260
- continue
3261
- # Parse timestamp; assume Z / +00:00 — production iterators do
3262
- # the same via `parse_iso_datetime`.
3263
- ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3264
- wstart = _week_for(ts)
3265
- if wstart is None:
3266
- continue
3267
-
3268
- # Entry cost via the shared pricing chokepoint.
3269
- entry_cost = _calculate_entry_cost(
3270
- model,
3271
- {
3272
- "input_tokens": input_tok or 0,
3273
- "output_tokens": output_tok or 0,
3274
- "cache_creation_input_tokens": cache_create or 0,
3275
- "cache_read_input_tokens": cache_read or 0,
3276
- },
3277
- mode="auto",
3278
- cost_usd=cost_raw,
3839
+ # The three structures the downstream disambiguation / attribution /
3840
+ # trend assembly consume:
3841
+ # buckets[(bucket_path, week_start)] -> {cost_usd, sessions, first/last_seen}
3842
+ # total_cost_by_week[week_start] -> attribution denominator
3843
+ # key_by_bucket[bucket_path] -> global first-seen ProjectKey
3844
+ # Flag ON (#269 §14 Win 2, sync-thread only): the CURRENT week is recomputed
3845
+ # fresh each warm tick; CLOSED weeks are served from the per-(project, week)
3846
+ # cache (hit) or recomputed-and-populated (miss). Flag OFF (CLI / tests /
3847
+ # HTTP-drill): the original single full-window walk, byte-unchanged.
3848
+ if use_projects_env_cache:
3849
+ buckets, total_cost_by_week, key_by_bucket = _assemble_projects_via_cache(
3850
+ conn, weeks_full=weeks_full, cw_start=cw_start, cw_end=cw_end,
3279
3851
  )
3852
+ else:
3853
+ # `_resolve_project_key` is the production resolver; we use git-root
3854
+ # mode (default for `cmd_project --group` absent) — matches the
3855
+ # CLI's default.
3856
+ _resolve_project_key = c._resolve_project_key
3857
+ resolver_cache: dict = {}
3858
+
3859
+ buckets = {}
3860
+ total_cost_by_week = {}
3861
+ # First-seen ProjectKey per bucket_path (feeds `_project_disambiguate_labels`).
3862
+ key_by_bucket = {}
3863
+
3864
+ def _week_for(ts: dt.datetime) -> "dt.datetime | None":
3865
+ wstart = _projects_week_start_monday_utc(ts)
3866
+ if wstart < weeks_full[0] or wstart > weeks_full[-1]:
3867
+ return None
3868
+ return wstart
3869
+
3870
+ # Orphan handling: `_projects_iter_session_entries` LEFT JOINs
3871
+ # `session_files` so entries whose source_path has no
3872
+ # `session_files` row return `project_path = NULL`. Below,
3873
+ # `_resolve_project_key(None, ...)` maps that to the
3874
+ # `(unknown)` bucket — same identity the drill-down's explicit
3875
+ # orphan scan in `_project_detail_for_window` (see the
3876
+ # ``if unknown_bucket:`` branch around the
3877
+ # `orphan_cur` SELECT) collects via a NULL-side LEFT JOIN. The
3878
+ # two paths converge on the same `(unknown)` source_path set.
3879
+ for row in _projects_iter_session_entries(
3880
+ conn, since=since_dt, until=until_dt,
3881
+ ):
3882
+ (entry_id, ts_iso, model, input_tok, output_tok,
3883
+ cache_create, cache_read, cost_raw, source_path,
3884
+ session_id, project_path) = row
3885
+ if model == "<synthetic>":
3886
+ continue
3887
+ # Parse timestamp; assume Z / +00:00 — production iterators do
3888
+ # the same via `parse_iso_datetime`.
3889
+ ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3890
+ wstart = _week_for(ts)
3891
+ if wstart is None:
3892
+ continue
3280
3893
 
3281
- # Project-key identity (`git_root` mode = production default).
3282
- pkey = _resolve_project_key(project_path, "git-root", resolver_cache)
3283
- bkey = (pkey.bucket_path, wstart)
3284
- b = buckets.get(bkey)
3285
- if b is None:
3286
- b = {
3287
- "key": pkey,
3288
- "cost_usd": 0.0,
3289
- "sessions": set(),
3290
- "first_seen": ts,
3291
- "last_seen": ts,
3292
- }
3293
- buckets[bkey] = b
3294
- b["cost_usd"] += entry_cost
3295
- if session_id:
3296
- b["sessions"].add(session_id)
3297
- elif source_path:
3298
- # Fallback: treat one source_path as one session when
3299
- # session_files.session_id is NULL (lazy population).
3300
- b["sessions"].add(source_path)
3301
- if ts < b["first_seen"]:
3302
- b["first_seen"] = ts
3303
- if ts > b["last_seen"]:
3304
- b["last_seen"] = ts
3305
- total_cost_by_week[wstart] = (
3306
- total_cost_by_week.get(wstart, 0.0) + entry_cost
3307
- )
3308
- # Remember first-seen ProjectKey for each bucket_path so the
3309
- # disambiguator pass below sees consistent ProjectKey instances.
3310
- if pkey.bucket_path not in key_by_bucket:
3311
- key_by_bucket[pkey.bucket_path] = pkey
3894
+ # Entry cost via the shared pricing chokepoint.
3895
+ entry_cost = _calculate_entry_cost(
3896
+ model,
3897
+ {
3898
+ "input_tokens": input_tok or 0,
3899
+ "output_tokens": output_tok or 0,
3900
+ "cache_creation_input_tokens": cache_create or 0,
3901
+ "cache_read_input_tokens": cache_read or 0,
3902
+ },
3903
+ mode="auto",
3904
+ cost_usd=cost_raw,
3905
+ )
3906
+
3907
+ # Project-key identity (`git_root` mode = production default).
3908
+ pkey = _resolve_project_key(project_path, "git-root", resolver_cache)
3909
+ bkey = (pkey.bucket_path, wstart)
3910
+ b = buckets.get(bkey)
3911
+ if b is None:
3912
+ b = {
3913
+ "key": pkey,
3914
+ "cost_usd": 0.0,
3915
+ "sessions": set(),
3916
+ "first_seen": ts,
3917
+ "last_seen": ts,
3918
+ }
3919
+ buckets[bkey] = b
3920
+ b["cost_usd"] += entry_cost
3921
+ if session_id:
3922
+ b["sessions"].add(session_id)
3923
+ elif source_path:
3924
+ # Fallback: treat one source_path as one session when
3925
+ # session_files.session_id is NULL (lazy population).
3926
+ b["sessions"].add(source_path)
3927
+ if ts < b["first_seen"]:
3928
+ b["first_seen"] = ts
3929
+ if ts > b["last_seen"]:
3930
+ b["last_seen"] = ts
3931
+ total_cost_by_week[wstart] = (
3932
+ total_cost_by_week.get(wstart, 0.0) + entry_cost
3933
+ )
3934
+ # Remember first-seen ProjectKey for each bucket_path so the
3935
+ # disambiguator pass below sees consistent ProjectKey instances.
3936
+ if pkey.bucket_path not in key_by_bucket:
3937
+ key_by_bucket[pkey.bucket_path] = pkey
3312
3938
 
3313
3939
  # ---- Load weekly_usage_snapshots for attribution --------------------
3314
3940
  # weekly_percent keyed by week_start (UTC datetime, Monday). We
@@ -4495,15 +5121,49 @@ def _build_alerts_envelope_array(
4495
5121
  return out[:limit]
4496
5122
 
4497
5123
 
5124
+ def _resolve_dashboard_port(port_arg):
5125
+ """Effective dashboard port: an explicit --port always wins; otherwise
5126
+ 8790 under the preview channel (CCTALLY_CHANNEL=preview), else 8789.
5127
+ Byte-stable when the marker is unset because any explicit port (incl. 0)
5128
+ short-circuits before the env check — the golden harness passes --port 0,
5129
+ so the port default is never exercised there."""
5130
+ if port_arg is not None:
5131
+ return port_arg
5132
+ if _cctally_core.is_preview_channel():
5133
+ return 8790
5134
+ return 8789
5135
+
5136
+
5137
+ def _channel_env_fragment() -> dict:
5138
+ """`{'channel': 'preview'}` under the preview channel, else `{}` — so the
5139
+ envelope key is omitted when the marker is unset (additive-optional, keeps
5140
+ the dashboard goldens byte-identical). Spliced into the envelope via `**`."""
5141
+ if _cctally_core.is_preview_channel():
5142
+ return {"channel": "preview"}
5143
+ return {}
5144
+
5145
+
4498
5146
  def snapshot_to_envelope(snap: "DataSnapshot", *,
4499
5147
  now_utc: "dt.datetime",
4500
5148
  monotonic_now: "float | None" = None,
4501
5149
  oauth_usage_cfg: "dict | None" = None,
4502
5150
  display_tz_pref_override: "str | None" = None,
4503
- runtime_bind: "str | None" = None) -> dict:
5151
+ runtime_bind: "str | None" = None,
5152
+ transcripts_visible: bool = False) -> dict:
4504
5153
  """Serialize a DataSnapshot into the JSON envelope consumed by the
4505
5154
  browser (design spec §2.2).
4506
5155
 
5156
+ ``transcripts_visible`` gates the transcript-derived session ``title``
5157
+ (#264 S3): the key is emitted ONLY when the flag is True AND the row has
5158
+ a title, so False (the default) fails closed for any caller that forgets
5159
+ to pass it — no ``title`` key, no leaked prompt content. The two
5160
+ browser-serving emit sites (``GET /api/data`` + the SSE loop) pass the
5161
+ per-request ``_transcripts_visible_to_request()`` — the SAME predicate
5162
+ that drives ``transcriptsEnabled`` and the per-row "open conversation"
5163
+ button; every other caller (share builders, fixtures, tests) keeps the
5164
+ default. ``cache_hit_pct`` (sessions) and ``cost_usd`` (trend) are plain
5165
+ numbers and are never gated.
5166
+
4507
5167
  Pure function — no I/O on the snapshot data path. Reads
4508
5168
  ``config.json`` once for ``display.tz`` and once for the
4509
5169
  ``alerts_settings`` mirror (cheap atomic-rename reads); the
@@ -4578,8 +5238,16 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4578
5238
  # process. The override flows in as a canonical tz token; we layer it
4579
5239
  # onto the config dict via _apply_display_tz_override before resolving
4580
5240
  # so every reader downstream sees one zone.
5241
+ # #268 M4: read the config + update-state + doctor from the sync-thread
5242
+ # precompute (spec §6) so this stays a PURE renderer — no config.json read,
5243
+ # no `security` fork, no update-state file read per SSE client per tick.
5244
+ # When the fields are absent (fixtures / the initial empty snapshot /
5245
+ # positionally-constructed DataSnapshots) fall back to the inline reads so
5246
+ # behavior is byte-identical for those callers.
5247
+ _precomp = getattr(snap, "envelope_precompute", None)
5248
+ _raw_config = _precomp["config"] if _precomp is not None else load_config()
4581
5249
  config = _apply_display_tz_override(
4582
- load_config(), display_tz_pref_override
5250
+ _raw_config, display_tz_pref_override
4583
5251
  )
4584
5252
  display_block = _compute_display_block(config, snap.generated_at)
4585
5253
  resolved_tz_obj = ZoneInfo(display_block["resolved_tz"])
@@ -4854,7 +5522,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4854
5522
  # the entire snapshot — fall back to safe defaults and rely on
4855
5523
  # `_warn_alerts_bad_config_once` for the user-visible signal.
4856
5524
  alerts_array = list(getattr(snap, "alerts", []) or [])
4857
- _cfg_for_alerts = load_config()
5525
+ # #268 M4: reuse the precompute's config (or the fallback load_config()
5526
+ # resolved above) — the envelope used to call load_config() a SECOND time
5527
+ # here. Within one tick both reads returned the same file, so this is
5528
+ # byte-identical.
5529
+ _cfg_for_alerts = _raw_config
4858
5530
  try:
4859
5531
  _alerts_cfg = _get_alerts_config(_cfg_for_alerts)
4860
5532
  except _AlertsConfigError as exc:
@@ -4943,19 +5615,30 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4943
5615
  # produce a `null` block so a missing/corrupt state file doesn't
4944
5616
  # 500 the entire envelope; the client falls back to the defensive
4945
5617
  # null-state shape `coerceUpdateState` already understands.
4946
- try:
4947
- _update_state_envelope = _load_update_state()
4948
- except sys.modules["cctally"].UpdateError:
4949
- # _load_update_state() raises on truly malformed JSON. Surface
4950
- # an _error sentinel so the client renders "no update info" the
4951
- # same way it does for unreachable /api/update/status.
4952
- _update_state_envelope = {"_error": "update-state.json invalid"}
4953
- except Exception:
4954
- _update_state_envelope = {"_error": "update-state.json read failed"}
4955
- try:
4956
- _update_suppress_envelope = _load_update_suppress()
4957
- except Exception:
4958
- _update_suppress_envelope = {"skipped_versions": [], "remind_after": None}
5618
+ #
5619
+ # #268 M4: read from the sync-thread precompute (spec §6) when present so
5620
+ # this stays pure — no update-state file reads per SSE client. The inline
5621
+ # try/except is the fallback for fixtures / the initial snapshot, and keeps
5622
+ # the SAME error sentinels so the derived block is byte-identical.
5623
+ if _precomp is not None:
5624
+ _update_state_envelope = _precomp["update_state"]
5625
+ _update_suppress_envelope = _precomp["update_suppress"]
5626
+ else:
5627
+ try:
5628
+ _update_state_envelope = _load_update_state()
5629
+ except sys.modules["cctally"].UpdateError:
5630
+ # _load_update_state() raises on truly malformed JSON. Surface
5631
+ # an _error sentinel so the client renders "no update info" the
5632
+ # same way it does for unreachable /api/update/status.
5633
+ _update_state_envelope = {"_error": "update-state.json invalid"}
5634
+ except Exception:
5635
+ _update_state_envelope = {"_error": "update-state.json read failed"}
5636
+ try:
5637
+ _update_suppress_envelope = _load_update_suppress()
5638
+ except Exception:
5639
+ _update_suppress_envelope = {
5640
+ "skipped_versions": [], "remind_after": None,
5641
+ }
4959
5642
  update_envelope = {
4960
5643
  "state": _update_state_envelope,
4961
5644
  "suppress": _update_suppress_envelope,
@@ -4968,24 +5651,37 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4968
5651
  # `safety.dashboard_bind` reflects the running state, not just
4969
5652
  # `config.json`. Defensive: never crash the snapshot pipeline on
4970
5653
  # a doctor failure — surface a synthetic FAIL block with `_error`.
4971
- try:
4972
- _ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
4973
- _doc_state = doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
4974
- _doc_report = _ld.run_checks(_doc_state)
4975
- doctor_envelope: "dict" = {
4976
- "severity": _doc_report.overall_severity,
4977
- "counts": dict(_doc_report.counts),
4978
- "generated_at": _ld._iso_z(_doc_report.generated_at),
4979
- "fingerprint": _ld.fingerprint(_doc_report),
4980
- }
4981
- except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
4982
- doctor_envelope = {
4983
- "severity": "fail",
4984
- "counts": {"ok": 0, "warn": 0, "fail": 1},
4985
- "generated_at": _iso_z(now_utc),
4986
- "fingerprint": "sha1:" + ("0" * 40),
4987
- "_error": f"{type(exc).__name__}: {exc}",
4988
- }
5654
+ #
5655
+ # #268 M4: read the precomputed doctor block from the snapshot (spec §6)
5656
+ # so this render path never forks the `security` keychain subprocess. The
5657
+ # sync thread computes it ONCE per rebuild via `doctor_payload_memo`
5658
+ # (`_tui_precompute_doctor_payload`). The inline try/except below is the
5659
+ # fallback for fixtures / the initial snapshot / positionally-constructed
5660
+ # DataSnapshots (`doctor_payload=None`) and produces the SAME block, so
5661
+ # moving the computation is byte-identical. The lazy `GET /api/doctor`
5662
+ # endpoint keeps computing LIVE (an explicit user refresh must be fresh).
5663
+ _doctor_payload = getattr(snap, "doctor_payload", None)
5664
+ if _doctor_payload is not None:
5665
+ doctor_envelope: "dict" = _doctor_payload
5666
+ else:
5667
+ try:
5668
+ _ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
5669
+ _doc_state = doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
5670
+ _doc_report = _ld.run_checks(_doc_state)
5671
+ doctor_envelope = {
5672
+ "severity": _doc_report.overall_severity,
5673
+ "counts": dict(_doc_report.counts),
5674
+ "generated_at": _ld._iso_z(_doc_report.generated_at),
5675
+ "fingerprint": _ld.fingerprint(_doc_report),
5676
+ }
5677
+ except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
5678
+ doctor_envelope = {
5679
+ "severity": "fail",
5680
+ "counts": {"ok": 0, "warn": 0, "fail": 1},
5681
+ "generated_at": _iso_z(now_utc),
5682
+ "fingerprint": "sha1:" + ("0" * 40),
5683
+ "_error": f"{type(exc).__name__}: {exc}",
5684
+ }
4989
5685
 
4990
5686
  # B1 (#207): the "vs last week" header delta reuses the is_current trend
4991
5687
  # row's delta_dpp ($/1% vs the previous trend row — normally the prior
@@ -5101,6 +5797,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5101
5797
  "dollar_per_pct": w.dollars_per_percent,
5102
5798
  "delta": w.delta_dpp,
5103
5799
  "is_current": bool(w.is_current),
5800
+ # #264 S3: additive weekly cost (already on the row via
5801
+ # build_trend_view); rendered by the Trend modal's Cost
5802
+ # column. ``getattr`` (like ``project_key`` /
5803
+ # ``trend_history_median_dpp``) tolerates minimal trend
5804
+ # shapes — SimpleNamespace test rows / older fixtures —
5805
+ # that predate this nullable field. ``None`` when the
5806
+ # week has no cost snapshot.
5807
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
5104
5808
  }
5105
5809
  for w in snap.trend
5106
5810
  ],
@@ -5112,6 +5816,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5112
5816
  "dollar_per_pct": w.dollars_per_percent,
5113
5817
  "delta": w.delta_dpp,
5114
5818
  "is_current": bool(w.is_current),
5819
+ # #264 S3: additive weekly cost (see weeks[] above; same
5820
+ # getattr tolerance for minimal/older trend shapes).
5821
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
5115
5822
  }
5116
5823
  for w in (snap.weekly_history or [])
5117
5824
  ],
@@ -5155,6 +5862,20 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5155
5862
  # resolve (the React cell falls back to plain text).
5156
5863
  "project_key": getattr(s, "project_key", None),
5157
5864
  "cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
5865
+ # #264 S3: cache-hit % is a plain metric — always serialized
5866
+ # (never gated). ``None`` when the row has no denominator.
5867
+ "cache_hit_pct": round(s.cache_hit_pct, 1) if s.cache_hit_pct is not None else None,
5868
+ # The session title is transcript-derived content: emit the
5869
+ # `title` KEY only when the per-request transcript gate is
5870
+ # open AND a title exists. Omitted (not null) otherwise, so
5871
+ # a gated-off or untitled session renders the client's
5872
+ # em-dash fallback and committed goldens (empty conversation
5873
+ # fixtures -> no title) never carry a `title` key. Default
5874
+ # transcripts_visible=False therefore fails fully closed.
5875
+ **({"title": getattr(s, "title", None)}
5876
+ if (transcripts_visible
5877
+ and getattr(s, "title", None) is not None)
5878
+ else {}),
5158
5879
  }
5159
5880
  for s in snap.sessions
5160
5881
  ],
@@ -5198,6 +5919,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5198
5919
  # Doctor aggregate-only block (spec §5.5). Full per-check
5199
5920
  # report fetched lazily via GET /api/doctor.
5200
5921
  "doctor": doctor_envelope,
5922
+
5923
+ # Preview channel (CCTALLY_CHANNEL=preview): additive-optional
5924
+ # `channel` key. Omitted when the marker is unset so prod payloads
5925
+ # (and the dashboard goldens) stay byte-identical; feeds the header
5926
+ # PREVIEW badge when set.
5927
+ **_channel_env_fragment(),
5201
5928
  }
5202
5929
 
5203
5930
 
@@ -7356,6 +8083,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7356
8083
  cfg_oauth = _get_oauth_usage_config(load_config())
7357
8084
  except OauthUsageConfigError:
7358
8085
  cfg_oauth = dict(sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS)
8086
+ # Resolve the per-request transcript gate ONCE and use it for both the
8087
+ # in-envelope session `title` gate (#264 S3) and the `transcriptsEnabled`
8088
+ # client signal below — one predicate, two consumers, desync impossible.
8089
+ visible = self._transcripts_visible_to_request()
7359
8090
  env = snapshot_to_envelope(
7360
8091
  snap,
7361
8092
  # `_now_utc()` honors CCTALLY_AS_OF for harness determinism;
@@ -7368,6 +8099,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7368
8099
  oauth_usage_cfg=cfg_oauth,
7369
8100
  display_tz_pref_override=type(self).display_tz_pref_override,
7370
8101
  runtime_bind=type(self).cctally_host,
8102
+ transcripts_visible=visible,
7371
8103
  )
7372
8104
  # Conversation viewer (Plan 2, spec §5): inject the client signal
7373
8105
  # PER-REQUEST and Host-aware — NOT inside snapshot_to_envelope (the
@@ -7376,7 +8108,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7376
8108
  # request that the transcript GETs would 403 shows
7377
8109
  # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
7378
8110
  # finding) — one predicate, two consumers, desync impossible.
7379
- env["transcriptsEnabled"] = self._transcripts_visible_to_request()
8111
+ env["transcriptsEnabled"] = visible
7380
8112
  body = json.dumps(env, ensure_ascii=False).encode("utf-8")
7381
8113
  self.send_response(200)
7382
8114
  self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -8295,6 +9027,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8295
9027
  oauth_usage_cfg=cfg_oauth,
8296
9028
  display_tz_pref_override=type(self).display_tz_pref_override,
8297
9029
  runtime_bind=type(self).cctally_host,
9030
+ # #264 S3: gate the in-envelope session `title` on the same
9031
+ # connection-scoped predicate that drives transcriptsEnabled.
9032
+ transcripts_visible=transcripts_enabled,
8298
9033
  )
8299
9034
  env["transcriptsEnabled"] = transcripts_enabled
8300
9035
  msg = (
@@ -8592,6 +9327,13 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
8592
9327
  return sys.modules["cctally"]._tui_build_snapshot(
8593
9328
  now_utc=pinned_now, skip_sync=True,
8594
9329
  display_tz_pref_override=display_tz_pref_override,
9330
+ # #268 M4: precompute doctor / config / update-state on the initial
9331
+ # snapshot too, so the envelope is pure from the very first paint AND
9332
+ # under --no-sync (where no later sync tick would set them). One
9333
+ # `security` fork here is negligible next to the heavy sync #179 moved
9334
+ # to the background thread. ``getattr`` so minimal test ``args``
9335
+ # namespaces (no ``host``) still build an initial snapshot.
9336
+ precompute_envelope=True, runtime_bind=getattr(args, "host", None),
8595
9337
  )
8596
9338
 
8597
9339
 
@@ -8602,6 +9344,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8602
9344
  import time as _time
8603
9345
  import webbrowser as _wb
8604
9346
 
9347
+ # #268 M6.1: arm the SIGUSR1 all-thread traceback dump so any future spin is
9348
+ # self-diagnosing (`kill -USR1 <pid>`) without root py-spy. No-op where
9349
+ # SIGUSR1 is unavailable (Windows).
9350
+ _register_faulthandler_sigusr1()
9351
+
8605
9352
  # Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
8606
9353
  # at boot so the in-place ``execvp`` after a successful update
8607
9354
  # re-enters the user-facing wrapper (npm Node shim → CCTALLY_PYTHON
@@ -8678,6 +9425,15 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8678
9425
  # _DashboardSyncThread (started below, before the bind) owns the first full
8679
9426
  # sync_cache — including any pending conversation-enrichment reingest — and
8680
9427
  # SSE-pushes the populated snapshot on completion.
9428
+ # Self-heal removed-worktree orphans BEFORE building the first snapshot so
9429
+ # the initial render already excludes stale sessions from a deleted
9430
+ # worktree (rather than showing them until the first periodic tick).
9431
+ # Gated off under --no-sync (a frozen dashboard mutates nothing).
9432
+ _heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
9433
+ if _heal is not None and _heal.pruned_files:
9434
+ print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
9435
+ f"from removed sessions on startup", flush=True)
9436
+
8681
9437
  initial = _dashboard_initial_snapshot(
8682
9438
  args, pinned_now=pinned_now,
8683
9439
  display_tz_pref_override=display_tz_pref_override,
@@ -8710,6 +9466,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8710
9466
  _run_sync_now_locked = _make_run_sync_now_locked(
8711
9467
  ref=ref, hub=hub, pinned_now=pinned_now,
8712
9468
  display_tz_pref_override=display_tz_pref_override,
9469
+ # #268 M4: the dashboard's bound host, threaded into the sync-thread
9470
+ # doctor precompute so `safety.dashboard_bind` reflects the running
9471
+ # bind and the envelope reads the precomputed doctor block.
9472
+ runtime_bind=args.host,
8713
9473
  )
8714
9474
  _run_sync_now = _make_run_sync_now(
8715
9475
  sync_lock=sync_lock, ref=ref, hub=hub, pinned_now=pinned_now,
@@ -8752,8 +9512,17 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8752
9512
 
8753
9513
  class _DashboardSyncThread(_c_for_subclass._TuiSyncThread):
8754
9514
  def _run(self) -> None:
9515
+ last_heal = _time.monotonic()
8755
9516
  while not self._stop.is_set():
8756
9517
  _run_sync_now(skip_sync=self._skip_sync)
9518
+ # Self-heal removed-worktree orphans on a ~60s cadence (far
9519
+ # rarer than the sync tick — a deleted worktree is not urgent).
9520
+ # Non-blocking on the flock, so a contended tick just retries
9521
+ # next cadence; gated off under --no-sync.
9522
+ if (not self._skip_sync
9523
+ and _time.monotonic() - last_heal >= 60.0):
9524
+ last_heal = _time.monotonic()
9525
+ _dashboard_self_heal_orphans(skip_sync=self._skip_sync)
8757
9526
  for _ in range(int(max(1, self._interval * 10))):
8758
9527
  if self._stop.is_set():
8759
9528
  return
@@ -8783,7 +9552,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8783
9552
  # HTTP server on its own thread so the main thread can block on signal.
8784
9553
  # `_QuietThreadingHTTPServer` folds in `daemon_threads = True` and silences
8785
9554
  # client-disconnect tracebacks (spec §5).
8786
- srv = _QuietThreadingHTTPServer((args.host, args.port), DashboardHTTPHandler)
9555
+ resolved_port = _resolve_dashboard_port(args.port)
9556
+ srv = _QuietThreadingHTTPServer((args.host, resolved_port), DashboardHTTPHandler)
8787
9557
 
8788
9558
  bind_host = args.host
8789
9559
  bind_port = srv.server_address[1]