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.
- package/CHANGELOG.md +31 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +913 -143
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +676 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +92 -2
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_snapshot_cache.py +988 -0
- package/bin/_lib_view_models.py +117 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/index-D_Ylyqsf.js +80 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BatfACUX.css +0 -1
- package/dashboard/static/assets/index-VUPDfWul.js +0 -80
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
2502
|
-
|
|
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
|
|
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
|
-
#
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
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
|
|
2619
|
-
#
|
|
2620
|
-
# re-aggregating cost /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3011
|
-
|
|
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
|
-
#
|
|
3224
|
-
#
|
|
3225
|
-
#
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
#
|
|
3230
|
-
|
|
3231
|
-
#
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
"
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
_update_state_envelope =
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
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
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
doctor_envelope =
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
"
|
|
4986
|
-
|
|
4987
|
-
|
|
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"] =
|
|
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
|
-
|
|
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]
|