cctally 1.64.0 → 1.66.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +262 -40
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +827 -4911
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +487 -254
  24. package/bin/_cctally_update.py +5 -0
  25. package/bin/_lib_alert_dispatch.py +1 -1
  26. package/bin/_lib_blocks.py +6 -1
  27. package/bin/_lib_conversation_query.py +349 -36
  28. package/bin/_lib_credit.py +133 -0
  29. package/bin/_lib_doctor.py +150 -1
  30. package/bin/_lib_five_hour.py +34 -0
  31. package/bin/_lib_forecast.py +144 -0
  32. package/bin/_lib_json_envelope.py +38 -0
  33. package/bin/_lib_jsonl.py +115 -73
  34. package/bin/_lib_log.py +96 -0
  35. package/bin/_lib_perf.py +180 -0
  36. package/bin/_lib_pricing.py +47 -1
  37. package/bin/_lib_pricing_check.py +25 -3
  38. package/bin/_lib_record.py +179 -0
  39. package/bin/_lib_render.py +18 -10
  40. package/bin/_lib_snapshot_cache.py +61 -0
  41. package/bin/_lib_transcript_access.py +23 -0
  42. package/bin/cctally +51 -7
  43. package/bin/cctally-dashboard +2 -2
  44. package/bin/cctally-statusline +1 -0
  45. package/bin/cctally-tui +2 -2
  46. package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
  47. package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
  48. package/dashboard/static/dashboard.html +2 -2
  49. package/package.json +11 -1
  50. package/dashboard/static/assets/index-0jzYm75p.css +0 -1
  51. package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
@@ -222,6 +222,42 @@ 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
230
+
231
+ import importlib.util as _ilu
232
+
233
+
234
+ def _ensure_sibling_loaded(name: str) -> None:
235
+ """Register a NON-eager-loaded ``_lib_*`` sibling in ``sys.modules``.
236
+
237
+ ``_lib_forecast`` (#279 S4 F2) is a NEW consumer-only sibling — kept out
238
+ of ``bin/cctally``'s eager-load block so ``bin/cctally`` stays
239
+ byte-untouched (spec §2). Under the ``SourceFileLoader`` harness path
240
+ (``bin/`` absent from ``sys.path``) a bare ``from _lib_forecast import``
241
+ would miss, so this pre-registers the sibling ``__file__``-relative when
242
+ it is not already importable (mirrors ``_cctally_cache._load_lib``). The
243
+ honest import that follows is a ``sys.modules`` hit in every load context.
244
+ """
245
+ if name in sys.modules:
246
+ return
247
+ try:
248
+ __import__(name) # bin/ on sys.path: prod script / conftest / pytest
249
+ return
250
+ except ModuleNotFoundError:
251
+ pass
252
+ _p = os.path.join(os.path.dirname(__file__), f"{name}.py")
253
+ _spec = _ilu.spec_from_file_location(name, _p)
254
+ _mod = _ilu.module_from_spec(_spec)
255
+ sys.modules[name] = _mod
256
+ _spec.loader.exec_module(_mod)
257
+
258
+
259
+ _ensure_sibling_loaded("_lib_forecast")
260
+ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow
225
261
 
226
262
 
227
263
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
@@ -252,10 +288,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
252
288
  return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
253
289
 
254
290
 
255
- def _compute_forecast(*args, **kwargs):
256
- return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
257
-
258
-
259
291
  def _resolve_forecast_now(*args, **kwargs):
260
292
  return sys.modules["cctally"]._resolve_forecast_now(*args, **kwargs)
261
293
 
@@ -308,20 +340,11 @@ def sync_cache(*args, **kwargs):
308
340
  return sys.modules["cctally"].sync_cache(*args, **kwargs)
309
341
 
310
342
 
311
- # Forecast dataclass shims used as bare-name constructors inside
312
- # ``_tui_build_forecast``. The classes themselves stay in bin/cctally
313
- # alongside the forecast subcommand (``cmd_forecast``); call-time
314
- # resolution keeps monkeypatches in sync.
315
- def ForecastInputs(*args, **kwargs):
316
- return sys.modules["cctally"].ForecastInputs(*args, **kwargs)
317
-
318
-
319
- def ForecastOutput(*args, **kwargs):
320
- return sys.modules["cctally"].ForecastOutput(*args, **kwargs)
321
-
322
-
323
- def BudgetRow(*args, **kwargs):
324
- return sys.modules["cctally"].BudgetRow(*args, **kwargs)
343
+ # ``_compute_forecast`` + the forecast dataclasses (``ForecastInputs`` /
344
+ # ``ForecastOutput`` / ``BudgetRow``) now live in ``bin/_lib_forecast.py``
345
+ # (#279 S4 F2) and are honest-imported at module top — used as bare-name
346
+ # constructors inside ``_tui_build_forecast``. Single class def per name in
347
+ # ``_lib_forecast``; everything imports it, so class identity stays unique.
325
348
 
326
349
 
327
350
  # Dashboard back-refs consumed by the TUI's snapshot builders.
@@ -1104,6 +1127,22 @@ class DataSnapshot:
1104
1127
  # keep working, and both are carried forward on a sync crash (Codex F6).
1105
1128
  doctor_payload: dict | None = None
1106
1129
  envelope_precompute: dict | None = None
1130
+ # ---- #278 Theme A: first-paint hydration latch ----
1131
+ # ``True`` ONLY on data that is genuinely still being assembled — the
1132
+ # cheap first-paint seed (``_dashboard_initial_snapshot`` on a normal
1133
+ # launch) and A2's throttled partial republishes (set on a
1134
+ # ``dataclasses.replace`` copy for the PUBLISH only, never dirtying the
1135
+ # object the dispatch memo retains). Every path that yields
1136
+ # complete/stable data leaves/forces it ``False``: a fresh full build is
1137
+ # ``False`` for free (this default), and each ``dataclasses.replace``
1138
+ # clone site that copies a prior snapshot's value (idle short-circuit,
1139
+ # update-check republish, run-sync/settings republish) forces it back to
1140
+ # ``False``. Serialized into the dashboard envelope by
1141
+ # ``snapshot_to_envelope`` (additive key ``"hydrating"``); consumers
1142
+ # tolerate unknown keys, and it appears in NO ``--json``/CLI surface.
1143
+ # Placed LAST with a default so positional fixture constructors keep
1144
+ # working.
1145
+ hydrating: bool = False
1107
1146
 
1108
1147
  @classmethod
1109
1148
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -2092,6 +2131,13 @@ def _tui_build_snapshot(
2092
2131
  """
2093
2132
  import time
2094
2133
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
2134
+ # #276 perf: reset any prior tree on this thread, then bracket the whole
2135
+ # snapshot build as the "snapshot" root phase. Opened via the context-
2136
+ # manager protocol so the long body below is not reindented; closed +
2137
+ # stashed at each return. Near-noop when CCTALLY_PERF_TRACE is unset.
2138
+ _perf.reset_thread()
2139
+ _p_snapshot = _perf.phase("snapshot")
2140
+ _p_snapshot.__enter__()
2095
2141
  # Read config ONCE per rebuild and reuse it for the display-tz resolution
2096
2142
  # here AND the envelope precompute below (#268 M4) — the envelope used to
2097
2143
  # call ``load_config()`` twice per SSE client per tick.
@@ -2134,15 +2180,17 @@ def _tui_build_snapshot(
2134
2180
  # builders always read pure regardless (skip_sync is reassigned to
2135
2181
  # True below), so --no-sync means "no ingest, still read".
2136
2182
  do_ingest = not skip_sync
2137
- if do_ingest:
2138
- try:
2139
- cache_conn = _cctally().open_cache_db()
2183
+ with _perf.phase("sync") as _p_sync:
2184
+ _p_sync.set_meta(ingest=do_ingest)
2185
+ if do_ingest:
2140
2186
  try:
2141
- sync_cache(cache_conn)
2142
- finally:
2143
- cache_conn.close()
2144
- except Exception as exc:
2145
- errors.append(f"sync-cache: {exc}")
2187
+ cache_conn = _cctally().open_cache_db()
2188
+ try:
2189
+ sync_cache(cache_conn)
2190
+ finally:
2191
+ cache_conn.close()
2192
+ except Exception as exc:
2193
+ errors.append(f"sync-cache: {exc}")
2146
2194
  # Force pure reads for every view builder below, independent of the
2147
2195
  # caller's flag: the single ingest above is the only glob per tick.
2148
2196
  skip_sync = True
@@ -2193,11 +2241,12 @@ def _tui_build_snapshot(
2193
2241
  dispatch_key = None
2194
2242
  if precompute_envelope:
2195
2243
  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
2244
+ with _perf.phase("signature"):
2245
+ try:
2246
+ dispatch_sig = _tui_compute_dispatch_signature(conn)
2247
+ except Exception as exc:
2248
+ errors.append(f"dispatch-signature: {exc}")
2249
+ dispatch_sig = None
2201
2250
  if dispatch_sig is not None:
2202
2251
  # The idle decision keys on the DB signature AND a render key
2203
2252
  # that captures the config-derived inputs the composite
@@ -2220,13 +2269,20 @@ def _tui_build_snapshot(
2220
2269
  and dispatch_key == prior_key
2221
2270
  and not _snapshot_period_rolled_over(
2222
2271
  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)
2272
+ with _perf.phase("idle-decision"):
2273
+ idle_snap = _tui_build_idle_snapshot(
2274
+ prior_snap, now_utc=now_utc,
2275
+ precompute_envelope=precompute_envelope,
2276
+ runtime_bind=runtime_bind, raw_config=raw_config,
2277
+ errors=errors,
2278
+ )
2279
+ _sc.store_dispatch_state(dispatch_key, idle_snap)
2280
+ _p_snapshot.__exit__(None, None, None)
2281
+ if _perf.enabled():
2282
+ _perf.stash_last(
2283
+ _perf.current_root(), generation=None,
2284
+ generated_at=now_utc.isoformat(),
2285
+ )
2230
2286
  return idle_snap
2231
2287
  # ── #269 M3.1: non-idle rebuild — reconcile the shared
2232
2288
  # per-weekref immutable-cost cache ONCE here (spec §4/§6),
@@ -2241,39 +2297,48 @@ def _tui_build_snapshot(
2241
2297
  try:
2242
2298
  _rc_cache_conn = _cctally().open_cache_db()
2243
2299
  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
2300
+ with _perf.phase("reconcile.weekref") as _pr:
2301
+ _pr.set_meta(hit=False)
2302
+ _sc.reconcile_weekref_cache(
2303
+ _rc_cache_conn,
2304
+ max_entry_id=dispatch_sig.max_entry_id,
2305
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2306
+ reset_sig=dispatch_sig.reset_sig,
2307
+ )
2308
+ use_weekref_cost_cache = True
2309
+ _pr.set_meta(hit=True)
2251
2310
  # #269 M4.5: reconcile the projects-envelope cache with
2252
2311
  # the SAME short-lived cache conn (session_files_sig +
2253
2312
  # the new-entry watermark both live in cache.db). Only
2254
2313
  # after this succeeds does `_build_projects_envelope`
2255
2314
  # 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
2315
+ with _perf.phase("reconcile.projects_env") as _pr:
2316
+ _pr.set_meta(hit=False)
2317
+ _sc.reconcile_projects_env_cache(
2318
+ _rc_cache_conn,
2319
+ max_entry_id=dispatch_sig.max_entry_id,
2320
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2321
+ max_wus_id=dispatch_sig.max_wus_id,
2322
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2323
+ )
2324
+ use_projects_env_cache = True
2325
+ _pr.set_meta(hit=True)
2264
2326
  # #271 M3: reconcile the Bug-K pre-credit segment cache
2265
2327
  # with the SAME short-lived cache conn (its watermark
2266
2328
  # query lives in cache.db), using the dispatch-signature
2267
2329
  # legs already computed. Only after this succeeds does
2268
2330
  # `_dashboard_build_weekly_periods` opt into the cache
2269
2331
  # 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
2332
+ with _perf.phase("reconcile.bugk") as _pr:
2333
+ _pr.set_meta(hit=False)
2334
+ _sc.reconcile_bugk_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
+ )
2340
+ use_bugk_segment_cache = True
2341
+ _pr.set_meta(hit=True)
2277
2342
  # #272: reconcile the cache-report per-day cache with the
2278
2343
  # SAME short-lived cache conn (its watermark query +
2279
2344
  # session_files_sig both live in cache.db), using the
@@ -2282,95 +2347,104 @@ def _tui_build_snapshot(
2282
2347
  # `tz_key` full-clears on a display-tz change (every
2283
2348
  # calendar-day key shifts). Only after this succeeds does
2284
2349
  # `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
2350
+ with _perf.phase("reconcile.cache_report") as _pr:
2351
+ _pr.set_meta(hit=False)
2352
+ _sc.reconcile_cache_report_cache(
2353
+ _rc_cache_conn,
2354
+ max_entry_id=dispatch_sig.max_entry_id,
2355
+ max_mutation_seq=dispatch_sig.entry_mutation_seq,
2356
+ reset_sig=dispatch_sig.reset_sig,
2357
+ sf_sig=_sc.session_files_sig(_rc_cache_conn),
2358
+ # `_load_sibling` is the canonical idempotent
2359
+ # sibling-access idiom (it returns the already-loaded
2360
+ # cctally-bound kernel instance). `_lib_cache_report`
2361
+ # is loaded eagerly at import (bin/cctally), so this
2362
+ # is a plain re-fetch of a populated `sys.modules`
2363
+ # entry — NOT a first-tick KeyError guard.
2364
+ bucket_tz=_cctally()._load_sibling(
2365
+ "_lib_cache_report"
2366
+ )._resolve_bucket_tz(_build_display_tz),
2367
+ tz_key=str(_build_display_tz),
2368
+ )
2369
+ use_cache_report_cache = True
2370
+ _pr.set_meta(hit=True)
2303
2371
  finally:
2304
2372
  _rc_cache_conn.close()
2305
2373
  except Exception as exc:
2306
2374
  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}")
2375
+ with _perf.phase("build.current_week"):
2376
+ try:
2377
+ cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
2378
+ except Exception as exc:
2379
+ errors.append(f"current-week: {exc}")
2311
2380
  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}")
2381
+ with _perf.phase("build.forecast"):
2382
+ try:
2383
+ # Issue #57: build the ForecastView once so we capture both
2384
+ # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
2385
+ # the many TUI panel consumers still read) and the surface
2386
+ # fields the envelope adapter used to re-derive inline.
2387
+ fc_view = _tui_build_forecast_view(
2388
+ conn, now_utc, skip_sync=skip_sync,
2389
+ use_weekref_cost_cache=use_weekref_cost_cache,
2390
+ )
2391
+ fc = fc_view.output if fc_view is not None else None
2392
+ except Exception as exc:
2393
+ errors.append(f"forecast: {exc}")
2324
2394
  # Trend: source from build_trend_view so we capture the 3-sample
2325
2395
  # avg_dollars_per_pct alongside the rows. The TUI build path
2326
2396
  # historically called _tui_build_trend (which now wraps the
2327
2397
  # builder); calling the builder directly here saves one
2328
2398
  # `_aggregate_*` round-trip.
2329
2399
  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}")
2400
+ with _perf.phase("build.trend"):
2401
+ try:
2402
+ c = _cctally()
2403
+ _trend_view = c.build_trend_view(
2404
+ conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
2405
+ skip_sync=skip_sync,
2406
+ use_weekref_cost_cache=use_weekref_cost_cache,
2407
+ )
2408
+ trend = list(_trend_view.rows)
2409
+ trend_avg_dpp = _trend_view.avg_dollars_per_pct
2410
+ except Exception as exc:
2411
+ errors.append(f"trend: {exc}")
2412
+ with _perf.phase("build.sessions"):
2413
+ try:
2414
+ # The sessions aggregator goes through
2415
+ # `get_claude_session_entries`, which runs `sync_cache` unless
2416
+ # `skip_sync=True` is threaded through. Honor the caller's
2417
+ # intent so `--no-sync` and the initial cache-only paint
2418
+ # both avoid ingest latency/lock contention.
2419
+ sessions = _tui_build_sessions(
2420
+ now_utc, skip_sync=skip_sync, use_session_cache=True,
2421
+ )
2422
+ except Exception as exc:
2423
+ errors.append(f"sessions: {exc}")
2352
2424
  # ---- 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}")
2425
+ with _perf.phase("build.milestones"):
2426
+ try:
2427
+ if cw is not None:
2428
+ milestones = _tui_build_percent_milestones(conn)
2429
+ except Exception as exc:
2430
+ errors.append(f"milestones: {exc}")
2358
2431
  history: list = []
2359
2432
  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}")
2433
+ with _perf.phase("build.weekly_history"):
2434
+ try:
2435
+ # Issue #59: build the full TrendView so we capture the
2436
+ # pre-computed 4-week-median-non-current scalar alongside
2437
+ # the row list; the dashboard envelope adapter surfaces
2438
+ # the scalar as ``trend.history_median_dpp`` so
2439
+ # TrendModal.tsx stops re-deriving it client-side.
2440
+ history_view = _tui_build_weekly_history_view(
2441
+ conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
2442
+ use_weekref_cost_cache=use_weekref_cost_cache,
2443
+ )
2444
+ history = list(history_view.rows)
2445
+ history_median_dpp = history_view.median_dpp_non_current_4w
2446
+ except Exception as exc:
2447
+ errors.append(f"weekly-history: {exc}")
2374
2448
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
2375
2449
  # Sync-thread view-model totals (spec §6.6): sum directly over
2376
2450
  # the panel rows the dashboard ACTUALLY renders. The previous
@@ -2386,23 +2460,24 @@ def _tui_build_snapshot(
2386
2460
  # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
2387
2461
  weekly_total_cost_usd = 0.0
2388
2462
  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}")
2463
+ with _perf.phase("build.weekly_periods"):
2464
+ try:
2465
+ weekly_periods = _dashboard_build_weekly_periods(
2466
+ conn, now_utc, n=12, skip_sync=skip_sync,
2467
+ use_group_a_cache=True,
2468
+ use_bugk_segment_cache=use_bugk_segment_cache,
2469
+ )
2470
+ # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
2471
+ # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
2472
+ # (the dashboard fixture goldens assert exact JSON match).
2473
+ weekly_total_cost_usd = stable_sum(
2474
+ r.cost_usd for r in weekly_periods
2475
+ )
2476
+ weekly_total_tokens = sum(
2477
+ (r.total_tokens for r in weekly_periods), 0,
2478
+ )
2479
+ except Exception as exc:
2480
+ errors.append(f"weekly-periods: {exc}")
2406
2481
  # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
2407
2482
  # (same invariant as weekly above). Monthly has no Bug-K analogue,
2408
2483
  # but coupling the footer total to the panel-row source of truth
@@ -2410,20 +2485,21 @@ def _tui_build_snapshot(
2410
2485
  # same arithmetic with no behavioral upside.
2411
2486
  monthly_total_cost_usd = 0.0
2412
2487
  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}")
2488
+ with _perf.phase("build.monthly_periods"):
2489
+ try:
2490
+ monthly_periods = _dashboard_build_monthly_periods(
2491
+ conn, now_utc, n=12, skip_sync=skip_sync,
2492
+ use_group_a_cache=True,
2493
+ display_tz=_build_display_tz,
2494
+ )
2495
+ monthly_total_cost_usd = stable_sum(
2496
+ r.cost_usd for r in monthly_periods
2497
+ )
2498
+ monthly_total_tokens = sum(
2499
+ (r.total_tokens for r in monthly_periods), 0,
2500
+ )
2501
+ except Exception as exc:
2502
+ errors.append(f"monthly-periods: {exc}")
2427
2503
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
2428
2504
  # Issue #56: build the BlocksView once and read both rows
2429
2505
  # (presentation) and totals (envelope scalars) from the same
@@ -2432,20 +2508,21 @@ def _tui_build_snapshot(
2432
2508
  # kept as a thin shim for monkeypatch surfaces).
2433
2509
  blocks_total_cost_usd = 0.0
2434
2510
  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}")
2511
+ with _perf.phase("build.blocks"):
2512
+ try:
2513
+ if cw is not None:
2514
+ _blocks_view = _dashboard_build_blocks_view(
2515
+ conn, now_utc,
2516
+ week_start_at=cw.week_start_at,
2517
+ week_end_at=cw.week_end_at,
2518
+ skip_sync=skip_sync,
2519
+ display_tz=_build_display_tz,
2520
+ )
2521
+ blocks_panel = list(_blocks_view.rows)
2522
+ blocks_total_cost_usd = _blocks_view.total_cost_usd
2523
+ blocks_total_tokens = _blocks_view.total_tokens
2524
+ except Exception as exc:
2525
+ errors.append(f"blocks-panel: {exc}")
2449
2526
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
2450
2527
  # sum-over-visible-rows (same invariant as weekly/monthly above).
2451
2528
  # Gap days in the materialized panel carry ``cost_usd=0.0`` /
@@ -2454,42 +2531,45 @@ def _tui_build_snapshot(
2454
2531
  # ``build_daily_view`` pass that did the same arithmetic.
2455
2532
  daily_total_cost_usd = 0.0
2456
2533
  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}")
2534
+ with _perf.phase("build.daily"):
2535
+ try:
2536
+ daily_panel = _dashboard_build_daily_panel(
2537
+ conn, now_utc, n=30, skip_sync=skip_sync,
2538
+ use_group_a_cache=True,
2539
+ display_tz=_build_display_tz,
2540
+ )
2541
+ daily_total_cost_usd = stable_sum(
2542
+ r.cost_usd for r in daily_panel
2543
+ )
2544
+ daily_total_tokens = sum(
2545
+ (r.total_tokens for r in daily_panel), 0,
2546
+ )
2547
+ except Exception as exc:
2548
+ errors.append(f"daily-panel: {exc}")
2471
2549
  # ---- threshold-actions T5: alerts envelope array ----
2472
2550
  # Precomputed at sync time so `snapshot_to_envelope` stays a pure
2473
2551
  # renderer (no DB I/O on the dashboard hot path; mirrors how
2474
2552
  # `current_week.five_hour_block` is precomputed via
2475
2553
  # `_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}")
2554
+ with _perf.phase("build.alerts"):
2555
+ try:
2556
+ alerts = _build_alerts_envelope_array(conn)
2557
+ except Exception as exc:
2558
+ errors.append(f"alerts: {exc}")
2480
2559
  # ---- 5h in-place credit (v1.7.x) ----
2481
2560
  # Load 5h milestones (pre + post credit) for the current
2482
2561
  # block's window so CurrentWeekModal can render a merged
2483
2562
  # chronological timeline alongside its weekly milestones.
2484
2563
  # Spec §5.3 (Codex r1 finding 3).
2485
2564
  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}")
2565
+ with _perf.phase("build.five_hour_milestones"):
2566
+ try:
2567
+ win_key = None
2568
+ if cw is not None and isinstance(cw.five_hour_block, dict):
2569
+ win_key = cw.five_hour_block.get("five_hour_window_key")
2570
+ fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
2571
+ except Exception as exc:
2572
+ errors.append(f"five-hour-milestones: {exc}")
2493
2573
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
2494
2574
  # Per-tick aggregation lives on the sync thread; the dashboard's
2495
2575
  # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
@@ -2504,6 +2584,11 @@ def _tui_build_snapshot(
2504
2584
  # all three tables. ATTACH/DETACH is cheap and scoped to this
2505
2585
  # sub-build; no schema migration / lock acquisition is needed.
2506
2586
  projects_envelope_block: dict | None = None
2587
+ # #276 perf: time the projects-envelope build (ATTACH + walk + DETACH).
2588
+ # CM protocol, not a ``with`` block, to avoid reindenting the ~40-line
2589
+ # try/except/finally; the broad except + finally guarantee no escape.
2590
+ _p_pe = _perf.phase("build.projects_envelope")
2591
+ _p_pe.__enter__()
2507
2592
  try:
2508
2593
  c = _cctally()
2509
2594
  cache_db_path = _cctally_core.CACHE_DB_PATH
@@ -2545,6 +2630,7 @@ def _tui_build_snapshot(
2545
2630
  conn.execute("DETACH DATABASE cache_db")
2546
2631
  except Exception:
2547
2632
  pass
2633
+ _p_pe.__exit__(None, None, None)
2548
2634
  # Late-bind disambiguated `project_key` onto each SessionsPanel
2549
2635
  # row so the SessionsPanel → ProjectsModal cross-nav (spec §4.1)
2550
2636
  # routes by the same identity the Projects envelope emits.
@@ -2606,28 +2692,29 @@ def _tui_build_snapshot(
2606
2692
  # on the DataSnapshot field and the client renders the empty
2607
2693
  # state.
2608
2694
  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)
2695
+ with _perf.phase("build.cache_report"):
2612
2696
  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}")
2697
+ cfg_cr = load_config().get("cache_report") or {}
2698
+ threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
2699
+ try:
2700
+ threshold_pp = int(threshold_raw)
2701
+ except (TypeError, ValueError):
2702
+ threshold_pp = 15
2703
+ if threshold_pp < 1 or threshold_pp > 100:
2704
+ threshold_pp = 15
2705
+ _dash_mod = sys.modules["_cctally_dashboard"]
2706
+ _bcr = _dash_mod.build_cache_report_snapshot
2707
+ cache_report_block = _bcr(
2708
+ now_utc=now_utc,
2709
+ anomaly_threshold_pp=threshold_pp,
2710
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
2711
+ anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2712
+ display_tz=_build_display_tz,
2713
+ skip_sync=skip_sync,
2714
+ use_cache_report_cache=use_cache_report_cache,
2715
+ )
2716
+ except Exception as exc:
2717
+ errors.append(f"cache-report: {exc}")
2631
2718
 
2632
2719
  # ---- #268 M4: doctor / config / update-state precompute (spec §6) ----
2633
2720
  # Precompute the envelope's doctor / config / update-state reads ONCE
@@ -2639,18 +2726,20 @@ def _tui_build_snapshot(
2639
2726
  doctor_payload_block: "dict | None" = None
2640
2727
  envelope_precompute_block: "dict | None" = None
2641
2728
  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}")
2729
+ with _perf.phase("doctor"):
2730
+ try:
2731
+ doctor_payload_block = _tui_precompute_doctor_payload(
2732
+ now_utc, runtime_bind,
2733
+ )
2734
+ except Exception as exc:
2735
+ errors.append(f"doctor-precompute: {exc}")
2736
+ with _perf.phase("envelope.precompute"):
2737
+ try:
2738
+ envelope_precompute_block = _tui_precompute_envelope_config(
2739
+ raw_config,
2740
+ )
2741
+ except Exception as exc:
2742
+ errors.append(f"envelope-precompute: {exc}")
2654
2743
 
2655
2744
  snap = DataSnapshot(
2656
2745
  current_week=cw,
@@ -2692,6 +2781,16 @@ def _tui_build_snapshot(
2692
2781
  _cctally()._load_sibling("_lib_snapshot_cache").store_dispatch_state(
2693
2782
  dispatch_key, snap,
2694
2783
  )
2784
+ # #276 perf: close the "snapshot" root and freeze the completed tree
2785
+ # into the process-global slot for the loopback /api/debug/backend
2786
+ # endpoint. Whole-dict atomic assignment; never mutated after. No-op
2787
+ # when tracing is off.
2788
+ _p_snapshot.__exit__(None, None, None)
2789
+ if _perf.enabled():
2790
+ _perf.stash_last(
2791
+ _perf.current_root(), generation=None,
2792
+ generated_at=now_utc.isoformat(),
2793
+ )
2695
2794
  return snap
2696
2795
  finally:
2697
2796
  conn.close()
@@ -2894,6 +2993,10 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
2894
2993
  last_sync_error=("; ".join(errors) if errors else None),
2895
2994
  doctor_payload=doctor_payload,
2896
2995
  envelope_precompute=envelope_precompute,
2996
+ # #278 §1.4.1: an idle snapshot means the data-version signature is
2997
+ # unchanged (data stable) → force the hydration latch clear even if
2998
+ # ``prior`` was a hydrating seed/partial.
2999
+ hydrating=False,
2897
3000
  )
2898
3001
 
2899
3002
 
@@ -5090,6 +5193,69 @@ def _tui_refresh_interval_type(s: str) -> float:
5090
5193
  return v
5091
5194
 
5092
5195
 
5196
+ # ── #278 Theme A (A2): progressive first-run ingest fill ─────────────────────
5197
+ # A first-run / long-gap dashboard sync walks many files and takes seconds to
5198
+ # tens-of-seconds; before this it published exactly once at the end (empty →
5199
+ # single jump). A2 wires the existing ``sync_cache(progress=…)`` seam to a
5200
+ # THROTTLED partial republish so the heavy panels fill progressively. It is
5201
+ # self-limiting to first-run: a warm returning user's sync finishes under T, so
5202
+ # the throttle never fires and A2 is a pure no-op (exactly one publish). Not
5203
+ # user-configurable (YAGNI).
5204
+ _A2_PARTIAL_THROTTLE_S = 2.0 # T
5205
+
5206
+
5207
+ class _A2ThrottleClock:
5208
+ """Completion-measured throttle for A2 partial republishes.
5209
+
5210
+ ``should_fire(now)`` is True at most once per ``interval_s``, measured from
5211
+ the LAST fire's COMPLETION — call ``mark_done(now)`` after the partial build
5212
+ finishes so a slow partial self-spaces rather than stacking (spec §2.2).
5213
+ Seeded at the sync's START, so the first partial waits a full interval and a
5214
+ sub-T warm sync fires zero partials.
5215
+ """
5216
+ __slots__ = ("_interval", "_last")
5217
+
5218
+ def __init__(self, interval_s: float, *, start: float):
5219
+ self._interval = interval_s
5220
+ self._last = start
5221
+
5222
+ def should_fire(self, now: float) -> bool:
5223
+ return (now - self._last) >= self._interval
5224
+
5225
+ def mark_done(self, now: float) -> None:
5226
+ self._last = now
5227
+
5228
+
5229
+ def _make_a2_progress_cb(*, ref, hub, build_partial, throttle, monotonic,
5230
+ perf=_perf):
5231
+ """Build the A2 throttled ``sync_cache`` progress callback (extracted so the
5232
+ throttle + suppression logic is unit-testable with injected deps).
5233
+
5234
+ On proceed it builds a partial via ``build_partial()`` (a fresh, complete
5235
+ ``skip_sync=True`` snapshot over the current committed cache — NOT nested in
5236
+ another build) and stores the clean non-hydrating object on ``ref``, then
5237
+ publishes a ``hydrating=True`` COPY over the hub (§1.4.1 shared-object-leak:
5238
+ only the publish carries the latch, so the memo's retained object stays
5239
+ clean). The dispatch-memo write itself happens inside ``build_partial()`` /
5240
+ ``_tui_build_snapshot`` (its snapshot-cache reconcile step), NOT in this cb.
5241
+ Suppressed entirely while perf tracing is
5242
+ active (spec §2.2): progressive fill is a UX nicety, and a partial build's
5243
+ ``_perf.reset_thread`` would clobber the standalone ``sync_cache``'s
5244
+ in-flight trace phases. Trace-off (all normal use) is unaffected.
5245
+ """
5246
+ def cb(_stats) -> None:
5247
+ if perf.enabled():
5248
+ return
5249
+ now = monotonic()
5250
+ if not throttle.should_fire(now):
5251
+ return
5252
+ snap = build_partial()
5253
+ ref.set(snap)
5254
+ hub.publish(dataclasses.replace(snap, hydrating=True))
5255
+ throttle.mark_done(monotonic())
5256
+ return cb
5257
+
5258
+
5093
5259
  def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5094
5260
  runtime_bind=None):
5095
5261
  """Return a closure that does the snapshot-rebuild + SSE-publish work.
@@ -5107,25 +5273,84 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5107
5273
  DASHBOARD-only factory, so every rebuild here precomputes the envelope's
5108
5274
  doctor / config / update-state onto the snapshot (``precompute_envelope=True``)
5109
5275
  — keeping ``snapshot_to_envelope`` a pure renderer across all SSE clients.
5276
+
5277
+ #278 A2 (§2.1): the ``skip_sync=False`` path DECOUPLES the ingest from the
5278
+ build — it runs ``sync_cache`` STANDALONE (with a throttled progress cb),
5279
+ then builds the final snapshot with ``skip_sync=True``. Routing the ingest
5280
+ through ``_tui_build_snapshot`` instead would be unsafe: that function
5281
+ unconditionally ``_perf.reset_thread()``s (corrupting the trace §0 fixes),
5282
+ writes dispatch state, and mutates single-writer accelerator caches — so a
5283
+ re-entrant partial build from inside its ``sync`` phase would corrupt all
5284
+ three. The decoupled final build reads the same post-sync cache and computes
5285
+ the same post-sync dispatch signature, so it is byte-identical to today's
5286
+ ``_tui_build_snapshot(skip_sync=False)``. The ``skip_sync=True`` path (POST
5287
+ /api/settings) stays the single-build path.
5110
5288
  """
5289
+ def _build(skip_sync: bool):
5290
+ # Resolve _tui_build_snapshot via cctally's namespace so the eager
5291
+ # re-export AND ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)``
5292
+ # in tests propagate into this closure body (a bare-name lookup would
5293
+ # resolve in this sibling's __dict__ and miss the cctally-side patch).
5294
+ return sys.modules["cctally"]._tui_build_snapshot(
5295
+ now_utc=pinned_now, skip_sync=skip_sync,
5296
+ display_tz_pref_override=display_tz_pref_override,
5297
+ precompute_envelope=True, runtime_bind=runtime_bind,
5298
+ )
5299
+
5111
5300
  def _locked(skip_sync: bool) -> None:
5301
+ # #279 S5 F6.3 (gate P1-1): arm the snapshot-cache owner-thread tripwire
5302
+ # for whichever thread holds sync_lock for THIS rebuild — the periodic
5303
+ # sync thread, or a /api/sync / /api/settings request thread. Overwrite-
5304
+ # on-call, so ownership transfers to the current rebuilder; the guards in
5305
+ # _lib_snapshot_cache then catch a lock-bypassing foreign-thread mutation.
5306
+ _cctally()._load_sibling("_lib_snapshot_cache").mark_owner_thread()
5112
5307
  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)
5308
+ if not skip_sync:
5309
+ # ── Decoupled ingest + build (§2.1) ─────────────────────────
5310
+ import time as _time
5311
+ sync_error = None
5312
+ start = _time.monotonic()
5313
+ throttle = _A2ThrottleClock(_A2_PARTIAL_THROTTLE_S, start=start)
5314
+ cb = _make_a2_progress_cb(
5315
+ ref=ref, hub=hub,
5316
+ build_partial=lambda: _build(skip_sync=True),
5317
+ throttle=throttle, monotonic=_time.monotonic,
5318
+ )
5319
+ cache_conn = _cctally().open_cache_db()
5320
+ try:
5321
+ # Under CCTALLY_PERF_TRACE the phase tree this standalone
5322
+ # sync_cache builds is intentionally NOT surfaced in the live
5323
+ # dashboard trace: the final _build(skip_sync=True) below runs
5324
+ # _tui_build_snapshot, which resets the thread perf stack, so
5325
+ # these phases never reach an emitter. §0's trace-attribution
5326
+ # target is `cctally-bench --trace`, which builds directly
5327
+ # (no decoupled standalone ingest) and keeps its phase tree.
5328
+ sync_cache(cache_conn, progress=cb)
5329
+ except Exception as exc: # noqa: BLE001 — surfaced on the snap
5330
+ sync_error = f"sync-cache: {exc}"
5331
+ finally:
5332
+ cache_conn.close()
5333
+ snap = _build(skip_sync=True) # final: hydrating=False (default)
5334
+ if sync_error is not None:
5335
+ # Thread the standalone sync error into last_sync_error, sync
5336
+ # error FIRST (mirrors the internal path where the `sync`
5337
+ # phase's error is errors[0]) so the surface is unchanged.
5338
+ existing = snap.last_sync_error
5339
+ merged = (sync_error if not existing
5340
+ else f"{sync_error}; {existing}")
5341
+ snap = dataclasses.replace(snap, last_sync_error=merged)
5342
+ ref.set(snap)
5343
+ hub.publish(snap)
5344
+ return
5345
+ # ── skip_sync=True: single-build path (POST /api/settings) ──────
5346
+ snap = _build(skip_sync=True)
5347
+ # Mirror the startup override: suppress the monotonic sync stamp so
5348
+ # the envelope keeps emitting sync_age_s=None and the client keeps
5349
+ # rendering "sync paused" after the user hits r / clicks the sync
5350
+ # chip. #278 §1.4.1: force the hydration latch clear (default False
5351
+ # from _tui_build_snapshot, restated here since this is a replace()
5352
+ # clone site).
5353
+ snap = dataclasses.replace(snap, last_sync_at=None, hydrating=False)
5129
5354
  ref.set(snap)
5130
5355
  hub.publish(snap)
5131
5356
  except Exception as exc:
@@ -5134,6 +5359,10 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
5134
5359
  prev,
5135
5360
  last_sync_error=f"sync crashed: {exc}",
5136
5361
  generated_at=dt.datetime.now(dt.timezone.utc),
5362
+ # #278 §1.4.1: a crash-carry snapshot is stable (not mid-
5363
+ # assembly); clear the latch even if ``prev`` was a hydrating
5364
+ # seed/partial, so the client doesn't stay stuck in skeletons.
5365
+ hydrating=False,
5137
5366
  )
5138
5367
  ref.set(crashed)
5139
5368
  hub.publish(crashed)
@@ -5500,6 +5729,10 @@ def _tui_render_once(
5500
5729
  )
5501
5730
  except Exception:
5502
5731
  snap = _tui_empty_snapshot(now_utc)
5732
+ # #276 perf: flush the snapshot phase tree to stderr when tracing is on
5733
+ # (the rendered frame still goes to stdout only). No-op when off.
5734
+ if _perf.enabled():
5735
+ _perf.flush_stderr(_perf.current_root())
5503
5736
 
5504
5737
  # --- Render -----------------------------------------------------------
5505
5738
  # Drift guards run at module import + inside _tui_build_theme itself,