cctally 1.64.0 → 1.65.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.
@@ -222,6 +222,11 @@ from _lib_display_tz import (
222
222
  )
223
223
  from _lib_aggregators import _aggregate_monthly
224
224
  from _lib_fmt import stable_sum
225
+ # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
226
+ # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
227
+ # shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
228
+ # nothing on the default path.
229
+ import _lib_perf as _perf
225
230
 
226
231
 
227
232
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
@@ -1104,6 +1109,22 @@ class DataSnapshot:
1104
1109
  # keep working, and both are carried forward on a sync crash (Codex F6).
1105
1110
  doctor_payload: dict | None = None
1106
1111
  envelope_precompute: dict | None = None
1112
+ # ---- #278 Theme A: first-paint hydration latch ----
1113
+ # ``True`` ONLY on data that is genuinely still being assembled — the
1114
+ # cheap first-paint seed (``_dashboard_initial_snapshot`` on a normal
1115
+ # launch) and A2's throttled partial republishes (set on a
1116
+ # ``dataclasses.replace`` copy for the PUBLISH only, never dirtying the
1117
+ # object the dispatch memo retains). Every path that yields
1118
+ # complete/stable data leaves/forces it ``False``: a fresh full build is
1119
+ # ``False`` for free (this default), and each ``dataclasses.replace``
1120
+ # clone site that copies a prior snapshot's value (idle short-circuit,
1121
+ # update-check republish, run-sync/settings republish) forces it back to
1122
+ # ``False``. Serialized into the dashboard envelope by
1123
+ # ``snapshot_to_envelope`` (additive key ``"hydrating"``); consumers
1124
+ # tolerate unknown keys, and it appears in NO ``--json``/CLI surface.
1125
+ # Placed LAST with a default so positional fixture constructors keep
1126
+ # working.
1127
+ hydrating: bool = False
1107
1128
 
1108
1129
  @classmethod
1109
1130
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -2092,6 +2113,13 @@ def _tui_build_snapshot(
2092
2113
  """
2093
2114
  import time
2094
2115
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
2116
+ # #276 perf: reset any prior tree on this thread, then bracket the whole
2117
+ # snapshot build as the "snapshot" root phase. Opened via the context-
2118
+ # manager protocol so the long body below is not reindented; closed +
2119
+ # stashed at each return. Near-noop when CCTALLY_PERF_TRACE is unset.
2120
+ _perf.reset_thread()
2121
+ _p_snapshot = _perf.phase("snapshot")
2122
+ _p_snapshot.__enter__()
2095
2123
  # Read config ONCE per rebuild and reuse it for the display-tz resolution
2096
2124
  # here AND the envelope precompute below (#268 M4) — the envelope used to
2097
2125
  # call ``load_config()`` twice per SSE client per tick.
@@ -2134,15 +2162,17 @@ def _tui_build_snapshot(
2134
2162
  # builders always read pure regardless (skip_sync is reassigned to
2135
2163
  # True below), so --no-sync means "no ingest, still read".
2136
2164
  do_ingest = not skip_sync
2137
- if do_ingest:
2138
- try:
2139
- cache_conn = _cctally().open_cache_db()
2165
+ with _perf.phase("sync") as _p_sync:
2166
+ _p_sync.set_meta(ingest=do_ingest)
2167
+ if do_ingest:
2140
2168
  try:
2141
- sync_cache(cache_conn)
2142
- finally:
2143
- cache_conn.close()
2144
- except Exception as exc:
2145
- errors.append(f"sync-cache: {exc}")
2169
+ cache_conn = _cctally().open_cache_db()
2170
+ try:
2171
+ sync_cache(cache_conn)
2172
+ finally:
2173
+ cache_conn.close()
2174
+ except Exception as exc:
2175
+ errors.append(f"sync-cache: {exc}")
2146
2176
  # Force pure reads for every view builder below, independent of the
2147
2177
  # caller's flag: the single ingest above is the only glob per tick.
2148
2178
  skip_sync = True
@@ -2193,11 +2223,12 @@ def _tui_build_snapshot(
2193
2223
  dispatch_key = None
2194
2224
  if precompute_envelope:
2195
2225
  dispatch_sig = None
2196
- try:
2197
- dispatch_sig = _tui_compute_dispatch_signature(conn)
2198
- except Exception as exc:
2199
- errors.append(f"dispatch-signature: {exc}")
2200
- dispatch_sig = None
2226
+ with _perf.phase("signature"):
2227
+ try:
2228
+ dispatch_sig = _tui_compute_dispatch_signature(conn)
2229
+ except Exception as exc:
2230
+ errors.append(f"dispatch-signature: {exc}")
2231
+ dispatch_sig = None
2201
2232
  if dispatch_sig is not None:
2202
2233
  # The idle decision keys on the DB signature AND a render key
2203
2234
  # that captures the config-derived inputs the composite
@@ -2220,13 +2251,20 @@ def _tui_build_snapshot(
2220
2251
  and dispatch_key == prior_key
2221
2252
  and not _snapshot_period_rolled_over(
2222
2253
  prior_snap, now_utc, _build_display_tz)):
2223
- idle_snap = _tui_build_idle_snapshot(
2224
- prior_snap, now_utc=now_utc,
2225
- precompute_envelope=precompute_envelope,
2226
- runtime_bind=runtime_bind, raw_config=raw_config,
2227
- errors=errors,
2228
- )
2229
- _sc.store_dispatch_state(dispatch_key, idle_snap)
2254
+ with _perf.phase("idle-decision"):
2255
+ idle_snap = _tui_build_idle_snapshot(
2256
+ prior_snap, now_utc=now_utc,
2257
+ precompute_envelope=precompute_envelope,
2258
+ runtime_bind=runtime_bind, raw_config=raw_config,
2259
+ errors=errors,
2260
+ )
2261
+ _sc.store_dispatch_state(dispatch_key, idle_snap)
2262
+ _p_snapshot.__exit__(None, None, None)
2263
+ if _perf.enabled():
2264
+ _perf.stash_last(
2265
+ _perf.current_root(), generation=None,
2266
+ generated_at=now_utc.isoformat(),
2267
+ )
2230
2268
  return idle_snap
2231
2269
  # ── #269 M3.1: non-idle rebuild — reconcile the shared
2232
2270
  # per-weekref immutable-cost cache ONCE here (spec §4/§6),
@@ -2241,39 +2279,48 @@ def _tui_build_snapshot(
2241
2279
  try:
2242
2280
  _rc_cache_conn = _cctally().open_cache_db()
2243
2281
  try:
2244
- _sc.reconcile_weekref_cache(
2245
- _rc_cache_conn,
2246
- max_entry_id=dispatch_sig.max_entry_id,
2247
- max_mutation_seq=dispatch_sig.entry_mutation_seq,
2248
- reset_sig=dispatch_sig.reset_sig,
2249
- )
2250
- use_weekref_cost_cache = True
2282
+ with _perf.phase("reconcile.weekref") as _pr:
2283
+ _pr.set_meta(hit=False)
2284
+ _sc.reconcile_weekref_cache(
2285
+ _rc_cache_conn,
2286
+ max_entry_id=dispatch_sig.max_entry_id,
2287
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2288
+ reset_sig=dispatch_sig.reset_sig,
2289
+ )
2290
+ use_weekref_cost_cache = True
2291
+ _pr.set_meta(hit=True)
2251
2292
  # #269 M4.5: reconcile the projects-envelope cache with
2252
2293
  # the SAME short-lived cache conn (session_files_sig +
2253
2294
  # the new-entry watermark both live in cache.db). Only
2254
2295
  # after this succeeds does `_build_projects_envelope`
2255
2296
  # opt into the cache below.
2256
- _sc.reconcile_projects_env_cache(
2257
- _rc_cache_conn,
2258
- max_entry_id=dispatch_sig.max_entry_id,
2259
- max_mutation_seq=dispatch_sig.entry_mutation_seq,
2260
- max_wus_id=dispatch_sig.max_wus_id,
2261
- sf_sig=_sc.session_files_sig(_rc_cache_conn),
2262
- )
2263
- use_projects_env_cache = True
2297
+ with _perf.phase("reconcile.projects_env") as _pr:
2298
+ _pr.set_meta(hit=False)
2299
+ _sc.reconcile_projects_env_cache(
2300
+ _rc_cache_conn,
2301
+ max_entry_id=dispatch_sig.max_entry_id,
2302
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2303
+ max_wus_id=dispatch_sig.max_wus_id,
2304
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2305
+ )
2306
+ use_projects_env_cache = True
2307
+ _pr.set_meta(hit=True)
2264
2308
  # #271 M3: reconcile the Bug-K pre-credit segment cache
2265
2309
  # with the SAME short-lived cache conn (its watermark
2266
2310
  # query lives in cache.db), using the dispatch-signature
2267
2311
  # legs already computed. Only after this succeeds does
2268
2312
  # `_dashboard_build_weekly_periods` opt into the cache
2269
2313
  # below (`use_bugk_segment_cache=True`).
2270
- _sc.reconcile_bugk_cache(
2271
- _rc_cache_conn,
2272
- max_entry_id=dispatch_sig.max_entry_id,
2273
- max_mutation_seq=dispatch_sig.entry_mutation_seq,
2274
- reset_sig=dispatch_sig.reset_sig,
2275
- )
2276
- use_bugk_segment_cache = True
2314
+ with _perf.phase("reconcile.bugk") as _pr:
2315
+ _pr.set_meta(hit=False)
2316
+ _sc.reconcile_bugk_cache(
2317
+ _rc_cache_conn,
2318
+ max_entry_id=dispatch_sig.max_entry_id,
2319
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2320
+ reset_sig=dispatch_sig.reset_sig,
2321
+ )
2322
+ use_bugk_segment_cache = True
2323
+ _pr.set_meta(hit=True)
2277
2324
  # #272: reconcile the cache-report per-day cache with the
2278
2325
  # SAME short-lived cache conn (its watermark query +
2279
2326
  # session_files_sig both live in cache.db), using the
@@ -2282,95 +2329,104 @@ def _tui_build_snapshot(
2282
2329
  # `tz_key` full-clears on a display-tz change (every
2283
2330
  # calendar-day key shifts). Only after this succeeds does
2284
2331
  # `build_cache_report_snapshot` opt into the cache below.
2285
- _sc.reconcile_cache_report_cache(
2286
- _rc_cache_conn,
2287
- max_entry_id=dispatch_sig.max_entry_id,
2288
- max_mutation_seq=dispatch_sig.entry_mutation_seq,
2289
- reset_sig=dispatch_sig.reset_sig,
2290
- sf_sig=_sc.session_files_sig(_rc_cache_conn),
2291
- # `_load_sibling` is the canonical idempotent
2292
- # sibling-access idiom (it returns the already-loaded
2293
- # cctally-bound kernel instance). `_lib_cache_report`
2294
- # is loaded eagerly at import (bin/cctally), so this
2295
- # is a plain re-fetch of a populated `sys.modules`
2296
- # entry NOT a first-tick KeyError guard.
2297
- bucket_tz=_cctally()._load_sibling(
2298
- "_lib_cache_report"
2299
- )._resolve_bucket_tz(_build_display_tz),
2300
- tz_key=str(_build_display_tz),
2301
- )
2302
- use_cache_report_cache = True
2332
+ with _perf.phase("reconcile.cache_report") as _pr:
2333
+ _pr.set_meta(hit=False)
2334
+ _sc.reconcile_cache_report_cache(
2335
+ _rc_cache_conn,
2336
+ max_entry_id=dispatch_sig.max_entry_id,
2337
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2338
+ reset_sig=dispatch_sig.reset_sig,
2339
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2340
+ # `_load_sibling` is the canonical idempotent
2341
+ # sibling-access idiom (it returns the already-loaded
2342
+ # cctally-bound kernel instance). `_lib_cache_report`
2343
+ # is loaded eagerly at import (bin/cctally), so this
2344
+ # is a plain re-fetch of a populated `sys.modules`
2345
+ # entry — NOT a first-tick KeyError guard.
2346
+ bucket_tz=_cctally()._load_sibling(
2347
+ "_lib_cache_report"
2348
+ )._resolve_bucket_tz(_build_display_tz),
2349
+ tz_key=str(_build_display_tz),
2350
+ )
2351
+ use_cache_report_cache = True
2352
+ _pr.set_meta(hit=True)
2303
2353
  finally:
2304
2354
  _rc_cache_conn.close()
2305
2355
  except Exception as exc:
2306
2356
  errors.append(f"snapshot-cache-reconcile: {exc}")
2307
- try:
2308
- cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
2309
- except Exception as exc:
2310
- errors.append(f"current-week: {exc}")
2357
+ with _perf.phase("build.current_week"):
2358
+ try:
2359
+ cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
2360
+ except Exception as exc:
2361
+ errors.append(f"current-week: {exc}")
2311
2362
  fc_view = None
2312
- try:
2313
- # Issue #57: build the ForecastView once so we capture both
2314
- # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
2315
- # the many TUI panel consumers still read) and the surface
2316
- # fields the envelope adapter used to re-derive inline.
2317
- fc_view = _tui_build_forecast_view(
2318
- conn, now_utc, skip_sync=skip_sync,
2319
- use_weekref_cost_cache=use_weekref_cost_cache,
2320
- )
2321
- fc = fc_view.output if fc_view is not None else None
2322
- except Exception as exc:
2323
- errors.append(f"forecast: {exc}")
2363
+ with _perf.phase("build.forecast"):
2364
+ try:
2365
+ # Issue #57: build the ForecastView once so we capture both
2366
+ # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
2367
+ # the many TUI panel consumers still read) and the surface
2368
+ # fields the envelope adapter used to re-derive inline.
2369
+ fc_view = _tui_build_forecast_view(
2370
+ conn, now_utc, skip_sync=skip_sync,
2371
+ use_weekref_cost_cache=use_weekref_cost_cache,
2372
+ )
2373
+ fc = fc_view.output if fc_view is not None else None
2374
+ except Exception as exc:
2375
+ errors.append(f"forecast: {exc}")
2324
2376
  # Trend: source from build_trend_view so we capture the 3-sample
2325
2377
  # avg_dollars_per_pct alongside the rows. The TUI build path
2326
2378
  # historically called _tui_build_trend (which now wraps the
2327
2379
  # builder); calling the builder directly here saves one
2328
2380
  # `_aggregate_*` round-trip.
2329
2381
  trend_avg_dpp = None
2330
- try:
2331
- c = _cctally()
2332
- _trend_view = c.build_trend_view(
2333
- conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
2334
- skip_sync=skip_sync,
2335
- use_weekref_cost_cache=use_weekref_cost_cache,
2336
- )
2337
- trend = list(_trend_view.rows)
2338
- trend_avg_dpp = _trend_view.avg_dollars_per_pct
2339
- except Exception as exc:
2340
- errors.append(f"trend: {exc}")
2341
- try:
2342
- # The sessions aggregator goes through
2343
- # `get_claude_session_entries`, which runs `sync_cache` unless
2344
- # `skip_sync=True` is threaded through. Honor the caller's
2345
- # intent so `--no-sync` and the initial cache-only paint
2346
- # both avoid ingest latency/lock contention.
2347
- sessions = _tui_build_sessions(
2348
- now_utc, skip_sync=skip_sync, use_session_cache=True,
2349
- )
2350
- except Exception as exc:
2351
- errors.append(f"sessions: {exc}")
2382
+ with _perf.phase("build.trend"):
2383
+ try:
2384
+ c = _cctally()
2385
+ _trend_view = c.build_trend_view(
2386
+ conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
2387
+ skip_sync=skip_sync,
2388
+ use_weekref_cost_cache=use_weekref_cost_cache,
2389
+ )
2390
+ trend = list(_trend_view.rows)
2391
+ trend_avg_dpp = _trend_view.avg_dollars_per_pct
2392
+ except Exception as exc:
2393
+ errors.append(f"trend: {exc}")
2394
+ with _perf.phase("build.sessions"):
2395
+ try:
2396
+ # The sessions aggregator goes through
2397
+ # `get_claude_session_entries`, which runs `sync_cache` unless
2398
+ # `skip_sync=True` is threaded through. Honor the caller's
2399
+ # intent so `--no-sync` and the initial cache-only paint
2400
+ # both avoid ingest latency/lock contention.
2401
+ sessions = _tui_build_sessions(
2402
+ now_utc, skip_sync=skip_sync, use_session_cache=True,
2403
+ )
2404
+ except Exception as exc:
2405
+ errors.append(f"sessions: {exc}")
2352
2406
  # ---- v2 additions ----
2353
- try:
2354
- if cw is not None:
2355
- milestones = _tui_build_percent_milestones(conn)
2356
- except Exception as exc:
2357
- errors.append(f"milestones: {exc}")
2407
+ with _perf.phase("build.milestones"):
2408
+ try:
2409
+ if cw is not None:
2410
+ milestones = _tui_build_percent_milestones(conn)
2411
+ except Exception as exc:
2412
+ errors.append(f"milestones: {exc}")
2358
2413
  history: list = []
2359
2414
  history_median_dpp: "float | None" = None
2360
- try:
2361
- # Issue #59: build the full TrendView so we capture the
2362
- # pre-computed 4-week-median-non-current scalar alongside
2363
- # the row list; the dashboard envelope adapter surfaces
2364
- # the scalar as ``trend.history_median_dpp`` so
2365
- # TrendModal.tsx stops re-deriving it client-side.
2366
- history_view = _tui_build_weekly_history_view(
2367
- conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
2368
- use_weekref_cost_cache=use_weekref_cost_cache,
2369
- )
2370
- history = list(history_view.rows)
2371
- history_median_dpp = history_view.median_dpp_non_current_4w
2372
- except Exception as exc:
2373
- errors.append(f"weekly-history: {exc}")
2415
+ with _perf.phase("build.weekly_history"):
2416
+ try:
2417
+ # Issue #59: build the full TrendView so we capture the
2418
+ # pre-computed 4-week-median-non-current scalar alongside
2419
+ # the row list; the dashboard envelope adapter surfaces
2420
+ # the scalar as ``trend.history_median_dpp`` so
2421
+ # TrendModal.tsx stops re-deriving it client-side.
2422
+ history_view = _tui_build_weekly_history_view(
2423
+ conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
2424
+ use_weekref_cost_cache=use_weekref_cost_cache,
2425
+ )
2426
+ history = list(history_view.rows)
2427
+ history_median_dpp = history_view.median_dpp_non_current_4w
2428
+ except Exception as exc:
2429
+ errors.append(f"weekly-history: {exc}")
2374
2430
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
2375
2431
  # Sync-thread view-model totals (spec §6.6): sum directly over
2376
2432
  # the panel rows the dashboard ACTUALLY renders. The previous
@@ -2386,23 +2442,24 @@ def _tui_build_snapshot(
2386
2442
  # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
2387
2443
  weekly_total_cost_usd = 0.0
2388
2444
  weekly_total_tokens = 0
2389
- try:
2390
- weekly_periods = _dashboard_build_weekly_periods(
2391
- conn, now_utc, n=12, skip_sync=skip_sync,
2392
- use_group_a_cache=True,
2393
- use_bugk_segment_cache=use_bugk_segment_cache,
2394
- )
2395
- # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
2396
- # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
2397
- # (the dashboard fixture goldens assert exact JSON match).
2398
- weekly_total_cost_usd = stable_sum(
2399
- r.cost_usd for r in weekly_periods
2400
- )
2401
- weekly_total_tokens = sum(
2402
- (r.total_tokens for r in weekly_periods), 0,
2403
- )
2404
- except Exception as exc:
2405
- errors.append(f"weekly-periods: {exc}")
2445
+ with _perf.phase("build.weekly_periods"):
2446
+ try:
2447
+ weekly_periods = _dashboard_build_weekly_periods(
2448
+ conn, now_utc, n=12, skip_sync=skip_sync,
2449
+ use_group_a_cache=True,
2450
+ use_bugk_segment_cache=use_bugk_segment_cache,
2451
+ )
2452
+ # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
2453
+ # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
2454
+ # (the dashboard fixture goldens assert exact JSON match).
2455
+ weekly_total_cost_usd = stable_sum(
2456
+ r.cost_usd for r in weekly_periods
2457
+ )
2458
+ weekly_total_tokens = sum(
2459
+ (r.total_tokens for r in weekly_periods), 0,
2460
+ )
2461
+ except Exception as exc:
2462
+ errors.append(f"weekly-periods: {exc}")
2406
2463
  # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
2407
2464
  # (same invariant as weekly above). Monthly has no Bug-K analogue,
2408
2465
  # but coupling the footer total to the panel-row source of truth
@@ -2410,20 +2467,21 @@ def _tui_build_snapshot(
2410
2467
  # same arithmetic with no behavioral upside.
2411
2468
  monthly_total_cost_usd = 0.0
2412
2469
  monthly_total_tokens = 0
2413
- try:
2414
- monthly_periods = _dashboard_build_monthly_periods(
2415
- conn, now_utc, n=12, skip_sync=skip_sync,
2416
- use_group_a_cache=True,
2417
- display_tz=_build_display_tz,
2418
- )
2419
- monthly_total_cost_usd = stable_sum(
2420
- r.cost_usd for r in monthly_periods
2421
- )
2422
- monthly_total_tokens = sum(
2423
- (r.total_tokens for r in monthly_periods), 0,
2424
- )
2425
- except Exception as exc:
2426
- errors.append(f"monthly-periods: {exc}")
2470
+ with _perf.phase("build.monthly_periods"):
2471
+ try:
2472
+ monthly_periods = _dashboard_build_monthly_periods(
2473
+ conn, now_utc, n=12, skip_sync=skip_sync,
2474
+ use_group_a_cache=True,
2475
+ display_tz=_build_display_tz,
2476
+ )
2477
+ monthly_total_cost_usd = stable_sum(
2478
+ r.cost_usd for r in monthly_periods
2479
+ )
2480
+ monthly_total_tokens = sum(
2481
+ (r.total_tokens for r in monthly_periods), 0,
2482
+ )
2483
+ except Exception as exc:
2484
+ errors.append(f"monthly-periods: {exc}")
2427
2485
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
2428
2486
  # Issue #56: build the BlocksView once and read both rows
2429
2487
  # (presentation) and totals (envelope scalars) from the same
@@ -2432,20 +2490,21 @@ def _tui_build_snapshot(
2432
2490
  # kept as a thin shim for monkeypatch surfaces).
2433
2491
  blocks_total_cost_usd = 0.0
2434
2492
  blocks_total_tokens = 0
2435
- try:
2436
- if cw is not None:
2437
- _blocks_view = _dashboard_build_blocks_view(
2438
- conn, now_utc,
2439
- week_start_at=cw.week_start_at,
2440
- week_end_at=cw.week_end_at,
2441
- skip_sync=skip_sync,
2442
- display_tz=_build_display_tz,
2443
- )
2444
- blocks_panel = list(_blocks_view.rows)
2445
- blocks_total_cost_usd = _blocks_view.total_cost_usd
2446
- blocks_total_tokens = _blocks_view.total_tokens
2447
- except Exception as exc:
2448
- errors.append(f"blocks-panel: {exc}")
2493
+ with _perf.phase("build.blocks"):
2494
+ try:
2495
+ if cw is not None:
2496
+ _blocks_view = _dashboard_build_blocks_view(
2497
+ conn, now_utc,
2498
+ week_start_at=cw.week_start_at,
2499
+ week_end_at=cw.week_end_at,
2500
+ skip_sync=skip_sync,
2501
+ display_tz=_build_display_tz,
2502
+ )
2503
+ blocks_panel = list(_blocks_view.rows)
2504
+ blocks_total_cost_usd = _blocks_view.total_cost_usd
2505
+ blocks_total_tokens = _blocks_view.total_tokens
2506
+ except Exception as exc:
2507
+ errors.append(f"blocks-panel: {exc}")
2449
2508
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
2450
2509
  # sum-over-visible-rows (same invariant as weekly/monthly above).
2451
2510
  # Gap days in the materialized panel carry ``cost_usd=0.0`` /
@@ -2454,42 +2513,45 @@ def _tui_build_snapshot(
2454
2513
  # ``build_daily_view`` pass that did the same arithmetic.
2455
2514
  daily_total_cost_usd = 0.0
2456
2515
  daily_total_tokens = 0
2457
- try:
2458
- daily_panel = _dashboard_build_daily_panel(
2459
- conn, now_utc, n=30, skip_sync=skip_sync,
2460
- use_group_a_cache=True,
2461
- display_tz=_build_display_tz,
2462
- )
2463
- daily_total_cost_usd = stable_sum(
2464
- r.cost_usd for r in daily_panel
2465
- )
2466
- daily_total_tokens = sum(
2467
- (r.total_tokens for r in daily_panel), 0,
2468
- )
2469
- except Exception as exc:
2470
- errors.append(f"daily-panel: {exc}")
2516
+ with _perf.phase("build.daily"):
2517
+ try:
2518
+ daily_panel = _dashboard_build_daily_panel(
2519
+ conn, now_utc, n=30, skip_sync=skip_sync,
2520
+ use_group_a_cache=True,
2521
+ display_tz=_build_display_tz,
2522
+ )
2523
+ daily_total_cost_usd = stable_sum(
2524
+ r.cost_usd for r in daily_panel
2525
+ )
2526
+ daily_total_tokens = sum(
2527
+ (r.total_tokens for r in daily_panel), 0,
2528
+ )
2529
+ except Exception as exc:
2530
+ errors.append(f"daily-panel: {exc}")
2471
2531
  # ---- threshold-actions T5: alerts envelope array ----
2472
2532
  # Precomputed at sync time so `snapshot_to_envelope` stays a pure
2473
2533
  # renderer (no DB I/O on the dashboard hot path; mirrors how
2474
2534
  # `current_week.five_hour_block` is precomputed via
2475
2535
  # `_select_current_block_for_envelope`).
2476
- try:
2477
- alerts = _build_alerts_envelope_array(conn)
2478
- except Exception as exc:
2479
- errors.append(f"alerts: {exc}")
2536
+ with _perf.phase("build.alerts"):
2537
+ try:
2538
+ alerts = _build_alerts_envelope_array(conn)
2539
+ except Exception as exc:
2540
+ errors.append(f"alerts: {exc}")
2480
2541
  # ---- 5h in-place credit (v1.7.x) ----
2481
2542
  # Load 5h milestones (pre + post credit) for the current
2482
2543
  # block's window so CurrentWeekModal can render a merged
2483
2544
  # chronological timeline alongside its weekly milestones.
2484
2545
  # Spec §5.3 (Codex r1 finding 3).
2485
2546
  fh_milestones: list[dict] = []
2486
- try:
2487
- win_key = None
2488
- if cw is not None and isinstance(cw.five_hour_block, dict):
2489
- win_key = cw.five_hour_block.get("five_hour_window_key")
2490
- fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
2491
- except Exception as exc:
2492
- errors.append(f"five-hour-milestones: {exc}")
2547
+ with _perf.phase("build.five_hour_milestones"):
2548
+ try:
2549
+ win_key = None
2550
+ if cw is not None and isinstance(cw.five_hour_block, dict):
2551
+ win_key = cw.five_hour_block.get("five_hour_window_key")
2552
+ fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
2553
+ except Exception as exc:
2554
+ errors.append(f"five-hour-milestones: {exc}")
2493
2555
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
2494
2556
  # Per-tick aggregation lives on the sync thread; the dashboard's
2495
2557
  # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
@@ -2504,6 +2566,11 @@ def _tui_build_snapshot(
2504
2566
  # all three tables. ATTACH/DETACH is cheap and scoped to this
2505
2567
  # sub-build; no schema migration / lock acquisition is needed.
2506
2568
  projects_envelope_block: dict | None = None
2569
+ # #276 perf: time the projects-envelope build (ATTACH + walk + DETACH).
2570
+ # CM protocol, not a ``with`` block, to avoid reindenting the ~40-line
2571
+ # try/except/finally; the broad except + finally guarantee no escape.
2572
+ _p_pe = _perf.phase("build.projects_envelope")
2573
+ _p_pe.__enter__()
2507
2574
  try:
2508
2575
  c = _cctally()
2509
2576
  cache_db_path = _cctally_core.CACHE_DB_PATH
@@ -2545,6 +2612,7 @@ def _tui_build_snapshot(
2545
2612
  conn.execute("DETACH DATABASE cache_db")
2546
2613
  except Exception:
2547
2614
  pass
2615
+ _p_pe.__exit__(None, None, None)
2548
2616
  # Late-bind disambiguated `project_key` onto each SessionsPanel
2549
2617
  # row so the SessionsPanel → ProjectsModal cross-nav (spec §4.1)
2550
2618
  # routes by the same identity the Projects envelope emits.
@@ -2606,28 +2674,29 @@ def _tui_build_snapshot(
2606
2674
  # on the DataSnapshot field and the client renders the empty
2607
2675
  # state.
2608
2676
  cache_report_block = None
2609
- try:
2610
- cfg_cr = load_config().get("cache_report") or {}
2611
- threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
2677
+ with _perf.phase("build.cache_report"):
2612
2678
  try:
2613
- threshold_pp = int(threshold_raw)
2614
- except (TypeError, ValueError):
2615
- threshold_pp = 15
2616
- if threshold_pp < 1 or threshold_pp > 100:
2617
- threshold_pp = 15
2618
- _dash_mod = sys.modules["_cctally_dashboard"]
2619
- _bcr = _dash_mod.build_cache_report_snapshot
2620
- cache_report_block = _bcr(
2621
- now_utc=now_utc,
2622
- anomaly_threshold_pp=threshold_pp,
2623
- # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
2624
- anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2625
- display_tz=_build_display_tz,
2626
- skip_sync=skip_sync,
2627
- use_cache_report_cache=use_cache_report_cache,
2628
- )
2629
- except Exception as exc:
2630
- errors.append(f"cache-report: {exc}")
2679
+ cfg_cr = load_config().get("cache_report") or {}
2680
+ threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
2681
+ try:
2682
+ threshold_pp = int(threshold_raw)
2683
+ except (TypeError, ValueError):
2684
+ threshold_pp = 15
2685
+ if threshold_pp < 1 or threshold_pp > 100:
2686
+ threshold_pp = 15
2687
+ _dash_mod = sys.modules["_cctally_dashboard"]
2688
+ _bcr = _dash_mod.build_cache_report_snapshot
2689
+ cache_report_block = _bcr(
2690
+ now_utc=now_utc,
2691
+ anomaly_threshold_pp=threshold_pp,
2692
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
2693
+ anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2694
+ display_tz=_build_display_tz,
2695
+ skip_sync=skip_sync,
2696
+ use_cache_report_cache=use_cache_report_cache,
2697
+ )
2698
+ except Exception as exc:
2699
+ errors.append(f"cache-report: {exc}")
2631
2700
 
2632
2701
  # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
2633
2702
  # Precompute the envelope's doctor / config / update-state reads ONCE
@@ -2639,18 +2708,20 @@ def _tui_build_snapshot(
2639
2708
  doctor_payload_block: "dict | None" = None
2640
2709
  envelope_precompute_block: "dict | None" = None
2641
2710
  if precompute_envelope:
2642
- try:
2643
- doctor_payload_block = _tui_precompute_doctor_payload(
2644
- now_utc, runtime_bind,
2645
- )
2646
- except Exception as exc:
2647
- errors.append(f"doctor-precompute: {exc}")
2648
- try:
2649
- envelope_precompute_block = _tui_precompute_envelope_config(
2650
- raw_config,
2651
- )
2652
- except Exception as exc:
2653
- errors.append(f"envelope-precompute: {exc}")
2711
+ with _perf.phase("doctor"):
2712
+ try:
2713
+ doctor_payload_block = _tui_precompute_doctor_payload(
2714
+ now_utc, runtime_bind,
2715
+ )
2716
+ except Exception as exc:
2717
+ errors.append(f"doctor-precompute: {exc}")
2718
+ with _perf.phase("envelope.precompute"):
2719
+ try:
2720
+ envelope_precompute_block = _tui_precompute_envelope_config(
2721
+ raw_config,
2722
+ )
2723
+ except Exception as exc:
2724
+ errors.append(f"envelope-precompute: {exc}")
2654
2725
 
2655
2726
  snap = DataSnapshot(
2656
2727
  current_week=cw,
@@ -2692,6 +2763,16 @@ def _tui_build_snapshot(
2692
2763
  _cctally()._load_sibling("_lib_snapshot_cache").store_dispatch_state(
2693
2764
  dispatch_key, snap,
2694
2765
  )
2766
+ # #276 perf: close the "snapshot" root and freeze the completed tree
2767
+ # into the process-global slot for the loopback /api/debug/backend
2768
+ # endpoint. Whole-dict atomic assignment; never mutated after. No-op
2769
+ # when tracing is off.
2770
+ _p_snapshot.__exit__(None, None, None)
2771
+ if _perf.enabled():
2772
+ _perf.stash_last(
2773
+ _perf.current_root(), generation=None,
2774
+ generated_at=now_utc.isoformat(),
2775
+ )
2695
2776
  return snap
2696
2777
  finally:
2697
2778
  conn.close()
@@ -2894,6 +2975,10 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
2894
2975
  last_sync_error=("; ".join(errors) if errors else None),
2895
2976
  doctor_payload=doctor_payload,
2896
2977
  envelope_precompute=envelope_precompute,
2978
+ # #278 §1.4.1: an idle snapshot means the data-version signature is
2979
+ # unchanged (data stable) → force the hydration latch clear even if
2980
+ # ``prior`` was a hydrating seed/partial.
2981
+ hydrating=False,
2897
2982
  )
2898
2983
 
2899
2984
 
@@ -5090,6 +5175,69 @@ def _tui_refresh_interval_type(s: str) -> float:
5090
5175
  return v
5091
5176
 
5092
5177
 
5178
+ # ── #278 Theme A (A2): progressive first-run ingest fill ─────────────────────
5179
+ # A first-run / long-gap dashboard sync walks many files and takes seconds to
5180
+ # tens-of-seconds; before this it published exactly once at the end (empty →
5181
+ # single jump). A2 wires the existing ``sync_cache(progress=…)`` seam to a
5182
+ # THROTTLED partial republish so the heavy panels fill progressively. It is
5183
+ # self-limiting to first-run: a warm returning user's sync finishes under T, so
5184
+ # the throttle never fires and A2 is a pure no-op (exactly one publish). Not
5185
+ # user-configurable (YAGNI).
5186
+ _A2_PARTIAL_THROTTLE_S = 2.0 # T
5187
+
5188
+
5189
+ class _A2ThrottleClock:
5190
+ """Completion-measured throttle for A2 partial republishes.
5191
+
5192
+ ``should_fire(now)`` is True at most once per ``interval_s``, measured from
5193
+ the LAST fire's COMPLETION — call ``mark_done(now)`` after the partial build
5194
+ finishes so a slow partial self-spaces rather than stacking (spec §2.2).
5195
+ Seeded at the sync's START, so the first partial waits a full interval and a
5196
+ sub-T warm sync fires zero partials.
5197
+ """
5198
+ __slots__ = ("_interval", "_last")
5199
+
5200
+ def __init__(self, interval_s: float, *, start: float):
5201
+ self._interval = interval_s
5202
+ self._last = start
5203
+
5204
+ def should_fire(self, now: float) -> bool:
5205
+ return (now - self._last) >= self._interval
5206
+
5207
+ def mark_done(self, now: float) -> None:
5208
+ self._last = now
5209
+
5210
+
5211
+ def _make_a2_progress_cb(*, ref, hub, build_partial, throttle, monotonic,
5212
+ perf=_perf):
5213
+ """Build the A2 throttled ``sync_cache`` progress callback (extracted so the
5214
+ throttle + suppression logic is unit-testable with injected deps).
5215
+
5216
+ On proceed it builds a partial via ``build_partial()`` (a fresh, complete
5217
+ ``skip_sync=True`` snapshot over the current committed cache — NOT nested in
5218
+ another build) and stores the clean non-hydrating object on ``ref``, then
5219
+ publishes a ``hydrating=True`` COPY over the hub (§1.4.1 shared-object-leak:
5220
+ only the publish carries the latch, so the memo's retained object stays
5221
+ clean). The dispatch-memo write itself happens inside ``build_partial()`` /
5222
+ ``_tui_build_snapshot`` (its snapshot-cache reconcile step), NOT in this cb.
5223
+ Suppressed entirely while perf tracing is
5224
+ active (spec §2.2): progressive fill is a UX nicety, and a partial build's
5225
+ ``_perf.reset_thread`` would clobber the standalone ``sync_cache``'s
5226
+ in-flight trace phases. Trace-off (all normal use) is unaffected.
5227
+ """
5228
+ def cb(_stats) -> None:
5229
+ if perf.enabled():
5230
+ return
5231
+ now = monotonic()
5232
+ if not throttle.should_fire(now):
5233
+ return
5234
+ snap = build_partial()
5235
+ ref.set(snap)
5236
+ hub.publish(dataclasses.replace(snap, hydrating=True))
5237
+ throttle.mark_done(monotonic())
5238
+ return cb
5239
+
5240
+
5093
5241
  def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5094
5242
  runtime_bind=None):
5095
5243
  """Return a closure that does the snapshot-rebuild + SSE-publish work.
@@ -5107,25 +5255,78 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5107
5255
  DASHBOARD-only factory, so every rebuild here precomputes the envelope's
5108
5256
  doctor / config / update-state onto the snapshot (``precompute_envelope=True``)
5109
5257
  — keeping ``snapshot_to_envelope`` a pure renderer across all SSE clients.
5258
+
5259
+ #278 A2 (§2.1): the ``skip_sync=False`` path DECOUPLES the ingest from the
5260
+ build — it runs ``sync_cache`` STANDALONE (with a throttled progress cb),
5261
+ then builds the final snapshot with ``skip_sync=True``. Routing the ingest
5262
+ through ``_tui_build_snapshot`` instead would be unsafe: that function
5263
+ unconditionally ``_perf.reset_thread()``s (corrupting the trace §0 fixes),
5264
+ writes dispatch state, and mutates single-writer accelerator caches — so a
5265
+ re-entrant partial build from inside its ``sync`` phase would corrupt all
5266
+ three. The decoupled final build reads the same post-sync cache and computes
5267
+ the same post-sync dispatch signature, so it is byte-identical to today's
5268
+ ``_tui_build_snapshot(skip_sync=False)``. The ``skip_sync=True`` path (POST
5269
+ /api/settings) stays the single-build path.
5110
5270
  """
5271
+ def _build(skip_sync: bool):
5272
+ # Resolve _tui_build_snapshot via cctally's namespace so the eager
5273
+ # re-export AND ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)``
5274
+ # in tests propagate into this closure body (a bare-name lookup would
5275
+ # resolve in this sibling's __dict__ and miss the cctally-side patch).
5276
+ return sys.modules["cctally"]._tui_build_snapshot(
5277
+ now_utc=pinned_now, skip_sync=skip_sync,
5278
+ display_tz_pref_override=display_tz_pref_override,
5279
+ precompute_envelope=True, runtime_bind=runtime_bind,
5280
+ )
5281
+
5111
5282
  def _locked(skip_sync: bool) -> None:
5112
5283
  try:
5113
- # Resolve _tui_build_snapshot via cctally's namespace so the
5114
- # eager re-export AND ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)``
5115
- # in tests/test_dashboard_api_sync_refresh.py propagate into
5116
- # this closure body (the bare-name lookup would resolve in
5117
- # this sibling's __dict__ and miss the cctally-side patch).
5118
- snap = sys.modules["cctally"]._tui_build_snapshot(
5119
- now_utc=pinned_now, skip_sync=skip_sync,
5120
- display_tz_pref_override=display_tz_pref_override,
5121
- precompute_envelope=True, runtime_bind=runtime_bind,
5122
- )
5123
- if skip_sync:
5124
- # Mirror the startup override: suppress the monotonic sync
5125
- # stamp so the envelope keeps emitting sync_age_s=None and
5126
- # the client keeps rendering "sync paused" after the user
5127
- # hits r / clicks the sync chip.
5128
- snap = dataclasses.replace(snap, last_sync_at=None)
5284
+ if not skip_sync:
5285
+ # ── Decoupled ingest + build (§2.1) ─────────────────────────
5286
+ import time as _time
5287
+ sync_error = None
5288
+ start = _time.monotonic()
5289
+ throttle = _A2ThrottleClock(_A2_PARTIAL_THROTTLE_S, start=start)
5290
+ cb = _make_a2_progress_cb(
5291
+ ref=ref, hub=hub,
5292
+ build_partial=lambda: _build(skip_sync=True),
5293
+ throttle=throttle, monotonic=_time.monotonic,
5294
+ )
5295
+ cache_conn = _cctally().open_cache_db()
5296
+ try:
5297
+ # Under CCTALLY_PERF_TRACE the phase tree this standalone
5298
+ # sync_cache builds is intentionally NOT surfaced in the live
5299
+ # dashboard trace: the final _build(skip_sync=True) below runs
5300
+ # _tui_build_snapshot, which resets the thread perf stack, so
5301
+ # these phases never reach an emitter. §0's trace-attribution
5302
+ # target is `cctally-bench --trace`, which builds directly
5303
+ # (no decoupled standalone ingest) and keeps its phase tree.
5304
+ sync_cache(cache_conn, progress=cb)
5305
+ except Exception as exc: # noqa: BLE001 — surfaced on the snap
5306
+ sync_error = f"sync-cache: {exc}"
5307
+ finally:
5308
+ cache_conn.close()
5309
+ snap = _build(skip_sync=True) # final: hydrating=False (default)
5310
+ if sync_error is not None:
5311
+ # Thread the standalone sync error into last_sync_error, sync
5312
+ # error FIRST (mirrors the internal path where the `sync`
5313
+ # phase's error is errors[0]) so the surface is unchanged.
5314
+ existing = snap.last_sync_error
5315
+ merged = (sync_error if not existing
5316
+ else f"{sync_error}; {existing}")
5317
+ snap = dataclasses.replace(snap, last_sync_error=merged)
5318
+ ref.set(snap)
5319
+ hub.publish(snap)
5320
+ return
5321
+ # ── skip_sync=True: single-build path (POST /api/settings) ──────
5322
+ snap = _build(skip_sync=True)
5323
+ # Mirror the startup override: suppress the monotonic sync stamp so
5324
+ # the envelope keeps emitting sync_age_s=None and the client keeps
5325
+ # rendering "sync paused" after the user hits r / clicks the sync
5326
+ # chip. #278 §1.4.1: force the hydration latch clear (default False
5327
+ # from _tui_build_snapshot, restated here since this is a replace()
5328
+ # clone site).
5329
+ snap = dataclasses.replace(snap, last_sync_at=None, hydrating=False)
5129
5330
  ref.set(snap)
5130
5331
  hub.publish(snap)
5131
5332
  except Exception as exc:
@@ -5134,6 +5335,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5134
5335
  prev,
5135
5336
  last_sync_error=f"sync crashed: {exc}",
5136
5337
  generated_at=dt.datetime.now(dt.timezone.utc),
5338
+ # #278 §1.4.1: a crash-carry snapshot is stable (not mid-
5339
+ # assembly); clear the latch even if ``prev`` was a hydrating
5340
+ # seed/partial, so the client doesn't stay stuck in skeletons.
5341
+ hydrating=False,
5137
5342
  )
5138
5343
  ref.set(crashed)
5139
5344
  hub.publish(crashed)
@@ -5500,6 +5705,10 @@ def _tui_render_once(
5500
5705
  )
5501
5706
  except Exception:
5502
5707
  snap = _tui_empty_snapshot(now_utc)
5708
+ # #276 perf: flush the snapshot phase tree to stderr when tracing is on
5709
+ # (the rendered frame still goes to stdout only). No-op when off.
5710
+ if _perf.enabled():
5711
+ _perf.flush_stderr(_perf.current_root())
5503
5712
 
5504
5713
  # --- Render -----------------------------------------------------------
5505
5714
  # Drift guards run at module import + inside _tui_build_theme itself,