cctally 1.67.1 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -283,6 +283,7 @@ import urllib.parse
283
283
  import urllib.request
284
284
  import webbrowser as _wb
285
285
  from dataclasses import dataclass, field, replace
286
+ from collections.abc import Mapping
286
287
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
287
288
  from typing import Any, NamedTuple
288
289
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -460,6 +461,505 @@ from _cctally_dashboard_envelope import (
460
461
  _model_breakdowns_to_models,
461
462
  )
462
463
 
464
+ _ensure_sibling_loaded("_cctally_dashboard_sources")
465
+ from _cctally_dashboard_sources import (
466
+ SourceCapabilityUnavailable,
467
+ SourceResourceNotFound,
468
+ source_detail_lookup,
469
+ )
470
+ from _lib_dashboard_sources import dashboard_resource_key
471
+
472
+
473
+ def _source_safe_native_detail(row: Mapping, *, source: str,
474
+ resource: str, key: str) -> dict[str, Any]:
475
+ """Return a provider-native detail envelope without raw source identity.
476
+
477
+ The published source adapter supplies resource-specific normalized facts;
478
+ details deliberately omit display-only labels and every raw identity field.
479
+ This keeps an opaque key opaque while preserving the provider's native
480
+ vocabulary (inclusive Codex tokens, qualified project totals, or quota
481
+ window state).
482
+ """
483
+ common = {"detail_kind": f"{source}_{resource}", "key": key}
484
+ allowed = {
485
+ "session": (
486
+ "last_activity", "cost_usd", "input_tokens", "cached_input_tokens",
487
+ "output_tokens", "reasoning_output_tokens", "total_tokens", "models",
488
+ ),
489
+ "project": (
490
+ "first_seen", "last_seen", "cost_usd", "input_tokens",
491
+ "cached_input_tokens", "output_tokens", "reasoning_output_tokens",
492
+ "total_tokens", "session_count", "sessions_count",
493
+ ),
494
+ "block": (
495
+ "resets_at", "current_percent", "orphaned", "forecast",
496
+ "window_minutes", "observed_slot", "milestones",
497
+ ),
498
+ }[resource]
499
+ common.update({name: row[name] for name in allowed if name in row})
500
+ return common
501
+
502
+
503
+ def _codex_model_breakdowns(rows) -> list[dict[str, Any]]:
504
+ """Copy canonical Codex model totals into the safe dashboard vocabulary."""
505
+ result: list[dict[str, Any]] = []
506
+ for row in rows or ():
507
+ if not isinstance(row, Mapping):
508
+ continue
509
+ result.append({
510
+ name: row[name]
511
+ for name in (
512
+ "modelName", "inputTokens", "cachedInputTokens", "outputTokens",
513
+ "reasoningOutputTokens", "totalTokens", "cost", "isFallback",
514
+ )
515
+ if name in row
516
+ })
517
+ return result
518
+
519
+
520
+ def _codex_totals_wire(totals) -> dict[str, Any]:
521
+ return {
522
+ "cost_usd": float(getattr(totals, "cost_usd", 0.0)),
523
+ "input_tokens": int(getattr(totals, "input_tokens", 0)),
524
+ "cached_input_tokens": int(getattr(totals, "cached_input_tokens", 0)),
525
+ "output_tokens": int(getattr(totals, "output_tokens", 0)),
526
+ "reasoning_output_tokens": int(getattr(totals, "reasoning_output_tokens", 0)),
527
+ "total_tokens": int(getattr(totals, "total_tokens", 0)),
528
+ }
529
+
530
+
531
+ def _codex_detail_inputs(snapshot):
532
+ """Open one bounded, sync-free Codex relational read for a detail route."""
533
+ from _cctally_cache import open_cache_db
534
+ from _cctally_dashboard_sources import (
535
+ DASHBOARD_QUOTA_OBSERVATION_LIMIT,
536
+ DASHBOARD_QUOTA_RECENT_DAYS,
537
+ DashboardReadContext,
538
+ _codex_entries_from_qualified,
539
+ resolve_dashboard_source_semantics,
540
+ )
541
+ from _cctally_quota import load_codex_quota_observations
542
+ from _cctally_source_analytics import load_qualified_codex_entries
543
+
544
+ now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
545
+ if now_utc.tzinfo is None or now_utc.utcoffset() is None:
546
+ now_utc = now_utc.replace(tzinfo=dt.timezone.utc)
547
+ now_utc = now_utc.astimezone(dt.timezone.utc)
548
+ range_start = now_utc - dt.timedelta(days=365)
549
+ config = sys.modules["cctally"].load_config()
550
+ codex_state = snapshot.source_bundle.sources["codex"]
551
+ data = codex_state.data if isinstance(codex_state.data, Mapping) else {}
552
+ periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
553
+ daily = periods.get("daily") if isinstance(periods.get("daily"), Mapping) else {}
554
+ display_tz_name = daily.get("display_tz")
555
+ if not isinstance(display_tz_name, str) or not display_tz_name:
556
+ display_tz_name = "UTC"
557
+ semantics = resolve_dashboard_source_semantics(
558
+ config, display_tz_name=display_tz_name,
559
+ )
560
+ cache_conn = open_cache_db()
561
+ stats_conn = open_db()
562
+ try:
563
+ context = DashboardReadContext(
564
+ cache_conn=cache_conn,
565
+ stats_conn=stats_conn,
566
+ range_start=range_start,
567
+ now_utc=now_utc,
568
+ display_tz_name=semantics.display_tz_name,
569
+ week_start_idx=semantics.week_start_idx,
570
+ week_start_name=semantics.week_start_name,
571
+ speed=semantics.speed,
572
+ codex_budget=semantics.codex_budget,
573
+ )
574
+ qualified = load_qualified_codex_entries(
575
+ range_start,
576
+ now_utc + dt.timedelta(microseconds=1),
577
+ speed=semantics.speed,
578
+ sync=False,
579
+ cache_conn=cache_conn,
580
+ )
581
+ entries = _codex_entries_from_qualified(qualified)
582
+ active_roots = tuple(sorted(
583
+ str(row[0]) for row in cache_conn.execute(
584
+ "SELECT source_root_key FROM codex_source_roots"
585
+ )
586
+ ))
587
+ observations = load_codex_quota_observations(
588
+ source_root_keys=active_roots,
589
+ cache_conn=cache_conn,
590
+ captured_at_or_after=(
591
+ now_utc - dt.timedelta(days=DASHBOARD_QUOTA_RECENT_DAYS)
592
+ ),
593
+ active_at=now_utc,
594
+ max_rows=DASHBOARD_QUOTA_OBSERVATION_LIMIT,
595
+ )
596
+ yield context, qualified, entries, observations
597
+ finally:
598
+ cache_conn.close()
599
+ stats_conn.close()
600
+
601
+
602
+ def _build_codex_session_detail(context, entries, *, key: str) -> dict[str, Any]:
603
+ view = sys.modules["cctally"].build_codex_session_view(
604
+ entries,
605
+ now_utc=context.now_utc,
606
+ tz_name=context.display_tz_name,
607
+ speed=context.speed,
608
+ )
609
+ for row in view.rows:
610
+ candidate = dashboard_resource_key(
611
+ "session", "codex", row.codex_root or "single-root", row.session_id_path,
612
+ )
613
+ if candidate != key:
614
+ continue
615
+ return {
616
+ "detail_kind": "codex_session",
617
+ "key": key,
618
+ "last_activity": row.last_activity.astimezone(dt.timezone.utc).isoformat(),
619
+ "cost_usd": row.cost_usd,
620
+ "input_tokens": row.input_tokens,
621
+ "cached_input_tokens": row.cached_input_tokens,
622
+ "output_tokens": row.output_tokens,
623
+ "reasoning_output_tokens": row.reasoning_output_tokens,
624
+ "total_tokens": row.total_tokens,
625
+ "models": list(row.models),
626
+ "model_breakdowns": _codex_model_breakdowns(row.model_breakdowns),
627
+ }
628
+ raise SourceResourceNotFound()
629
+
630
+
631
+ def _build_codex_project_detail(context, qualified, observations, *, key: str) -> dict[str, Any]:
632
+ from _lib_quota import build_blocks
633
+ from _lib_source_analytics import build_codex_project_result
634
+
635
+ result = build_codex_project_result(
636
+ qualified,
637
+ range_start=context.range_start,
638
+ range_end=context.now_utc + dt.timedelta(microseconds=1),
639
+ blocks=build_blocks(observations),
640
+ as_of=context.now_utc,
641
+ allocation_entries=qualified,
642
+ include_breakdown=True,
643
+ )
644
+ data = result.data
645
+ if data is None:
646
+ raise SourceResourceNotFound()
647
+ for row in data.projects:
648
+ if dashboard_resource_key("project", "codex", row.project_key) != key:
649
+ continue
650
+ selected = tuple(entry for entry in qualified if entry.project_key == row.project_key)
651
+ by_session: dict[str, list[object]] = {}
652
+ for entry in selected:
653
+ by_session.setdefault(entry.conversation_key, []).append(entry)
654
+ sessions = []
655
+ for ordinal, values in enumerate(
656
+ sorted(by_session.values(), key=lambda group: max(item.timestamp for item in group), reverse=True),
657
+ start=1,
658
+ ):
659
+ totals = sys.modules["_lib_source_analytics"]._totals(values)
660
+ sessions.append({
661
+ "label": f"Session {ordinal}",
662
+ "last_activity": max(item.timestamp for item in values).astimezone(dt.timezone.utc).isoformat(),
663
+ **_codex_totals_wire(totals),
664
+ })
665
+ return {
666
+ "detail_kind": "codex_project",
667
+ "key": key,
668
+ "range_start": data.range_start.astimezone(dt.timezone.utc).isoformat(),
669
+ "range_end": data.range_end.astimezone(dt.timezone.utc).isoformat(),
670
+ "first_seen": row.first_seen.astimezone(dt.timezone.utc).isoformat(),
671
+ "last_seen": row.last_seen.astimezone(dt.timezone.utc).isoformat(),
672
+ "session_count": row.session_count,
673
+ **_codex_totals_wire(row.totals),
674
+ "models": [
675
+ {"model": model, **_codex_totals_wire(totals)}
676
+ for model, totals in row.models
677
+ ],
678
+ "sessions": sessions,
679
+ }
680
+ raise SourceResourceNotFound()
681
+
682
+
683
+ def _build_codex_block_detail(context, observations, *, key: str) -> dict[str, Any]:
684
+ from _lib_quota import build_blocks, forecast_quota, percent_milestones, quota_freshness
685
+
686
+ rows = context.stats_conn.execute(
687
+ "SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, "
688
+ "limit_name, resets_at_utc, current_percent, orphaned_at "
689
+ "FROM quota_window_blocks WHERE source='codex' "
690
+ "ORDER BY resets_at_utc DESC, source_root_key, logical_limit_key, observed_slot LIMIT 250"
691
+ ).fetchall()
692
+ matched = None
693
+ for row in rows:
694
+ candidate = dashboard_resource_key(
695
+ "block", "codex", row[0], row[1], row[2], row[3], row[5],
696
+ )
697
+ if candidate == key:
698
+ matched = row
699
+ break
700
+ if matched is None:
701
+ raise SourceResourceNotFound()
702
+ reset_at = parse_iso_datetime(str(matched[5]), "codex.block.resets_at")
703
+ matching_observations = tuple(
704
+ observation for observation in observations
705
+ if observation.identity.source_root_key == matched[0]
706
+ and observation.identity.logical_limit_key == matched[1]
707
+ and observation.identity.observed_slot == matched[2]
708
+ and observation.identity.window_minutes == matched[3]
709
+ and observation.resets_at == reset_at
710
+ )
711
+ block = next(
712
+ (item for item in build_blocks(matching_observations) if item.resets_at == reset_at),
713
+ None,
714
+ )
715
+ if block is None:
716
+ raise SourceResourceNotFound()
717
+ forecast = forecast_quota(block.observations, context.now_utc)
718
+ freshness = quota_freshness(block.observations, context.now_utc)
719
+ return {
720
+ "detail_kind": "codex_block",
721
+ "key": key,
722
+ "label": matched[4] or "Codex quota",
723
+ "observed_slot": matched[2],
724
+ "window_minutes": matched[3],
725
+ "resets_at": reset_at.astimezone(dt.timezone.utc).isoformat(),
726
+ "current_percent": matched[6],
727
+ "orphaned": matched[7] is not None,
728
+ "freshness": freshness.state,
729
+ "observations": [
730
+ {
731
+ "captured_at": item.captured_at.astimezone(dt.timezone.utc).isoformat(),
732
+ "used_percent": item.used_percent,
733
+ "resets_at": item.resets_at.astimezone(dt.timezone.utc).isoformat(),
734
+ }
735
+ for item in block.observations[-250:]
736
+ ],
737
+ "milestones": [
738
+ {
739
+ "percent": milestone.percent,
740
+ "captured_at": milestone.captured_at.astimezone(dt.timezone.utc).isoformat(),
741
+ }
742
+ for milestone in percent_milestones(block)
743
+ ],
744
+ "forecast": {
745
+ "status": forecast.status,
746
+ "current_percent": forecast.current_percent,
747
+ "projected_percent": forecast.projected_percent,
748
+ "resets_at": (
749
+ forecast.resets_at.astimezone(dt.timezone.utc).isoformat()
750
+ if forecast.resets_at else None
751
+ ),
752
+ },
753
+ }
754
+
755
+
756
+ def _build_codex_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
757
+ for context, qualified, entries, observations in _codex_detail_inputs(snapshot):
758
+ if resource == "session":
759
+ return _build_codex_session_detail(context, entries, key=key)
760
+ if resource == "project":
761
+ return _build_codex_project_detail(context, qualified, observations, key=key)
762
+ return _build_codex_block_detail(context, observations, key=key)
763
+ raise SourceResourceNotFound()
764
+
765
+
766
+ def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
767
+ """Adapt the existing Claude session detail builder to S4's safe route."""
768
+ payload = _session_detail_to_envelope(detail)
769
+ return {
770
+ "detail_kind": "claude_session",
771
+ "key": key,
772
+ "started_utc": payload["started_utc"],
773
+ "last_activity_utc": payload["last_activity_utc"],
774
+ "duration_min": payload["duration_min"],
775
+ "models": payload["models"],
776
+ "input_tokens": payload["input_tokens"],
777
+ "cache_creation_tokens": payload["cache_creation_tokens"],
778
+ "cache_read_tokens": payload["cache_read_tokens"],
779
+ "output_tokens": payload["output_tokens"],
780
+ "cache_hit_pct": payload["cache_hit_pct"],
781
+ "cost_per_model": payload["cost_per_model"],
782
+ "cost_total_usd": payload["cost_total_usd"],
783
+ }
784
+
785
+
786
+ def _source_safe_claude_project_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
787
+ """Drop legacy raw bucket/session identifiers from a Claude project drill."""
788
+ return {
789
+ "detail_kind": "claude_project",
790
+ "key": key,
791
+ "window_weeks": detail.get("window_weeks"),
792
+ "window_cost_usd": detail.get("window_cost_usd"),
793
+ "window_attributed_pct": detail.get("window_attributed_pct"),
794
+ "models": detail.get("models", []),
795
+ "sessions": [
796
+ {
797
+ "started_at": item.get("started_at"),
798
+ "last_activity_at": item.get("last_activity_at"),
799
+ "primary_model": item.get("primary_model"),
800
+ "cost_usd": item.get("cost_usd"),
801
+ }
802
+ for item in detail.get("sessions", [])
803
+ if isinstance(item, Mapping)
804
+ ],
805
+ "models_total": detail.get("models_total", 0),
806
+ "sessions_total": detail.get("sessions_total", 0),
807
+ }
808
+
809
+
810
+ def _source_safe_claude_block_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
811
+ """Reuse the shipped block builder while retaining source-safe fields."""
812
+ allowed = (
813
+ "start_at", "end_at", "actual_end_at", "anchor", "is_active",
814
+ "entries_count", "cost_usd", "total_tokens", "input_tokens",
815
+ "output_tokens", "cache_creation_tokens", "cache_read_tokens",
816
+ "cache_hit_pct", "models", "burn_rate", "projection", "samples",
817
+ )
818
+ return {
819
+ "detail_kind": "claude_block",
820
+ "key": key,
821
+ **{name: detail[name] for name in allowed if name in detail},
822
+ }
823
+
824
+
825
+ def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
826
+ for row in getattr(snapshot, "sessions", ()) or ():
827
+ session_id = getattr(row, "session_id", None)
828
+ if isinstance(session_id, str) and session_id and (
829
+ dashboard_resource_key("session", "claude", session_id) == key
830
+ ):
831
+ return session_id
832
+ return None
833
+
834
+
835
+ def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
836
+ env = getattr(snapshot, "projects_envelope", None)
837
+ if not isinstance(env, Mapping):
838
+ return None
839
+ candidates: list[object] = []
840
+ current = env.get("current_week")
841
+ if isinstance(current, Mapping):
842
+ candidates.extend(current.get("rows") or ())
843
+ trend = env.get("trend")
844
+ if isinstance(trend, Mapping):
845
+ candidates.extend(trend.get("projects") or ())
846
+ for row in candidates:
847
+ if not isinstance(row, Mapping):
848
+ continue
849
+ project_key = row.get("key")
850
+ if isinstance(project_key, str) and project_key and (
851
+ dashboard_resource_key("project", "claude", project_key) == key
852
+ ):
853
+ return project_key
854
+ return None
855
+
856
+
857
+ def _claude_block_start_for_source_key(snapshot, key: str) -> dt.datetime | None:
858
+ for row in getattr(snapshot, "blocks_panel", ()) or ():
859
+ raw_start = getattr(row, "start_at", None)
860
+ if not isinstance(raw_start, str):
861
+ continue
862
+ try:
863
+ start_at = parse_iso_datetime(raw_start, "source.block.start_at")
864
+ except ValueError:
865
+ continue
866
+ identities = (raw_start, start_at.astimezone(dt.timezone.utc).isoformat(), start_at)
867
+ if any(dashboard_resource_key("block", "claude", value) == key for value in identities):
868
+ return start_at.astimezone(dt.timezone.utc)
869
+ return None
870
+
871
+
872
+ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
873
+ """Map a bounded legacy row to its existing, cache-only detail builder."""
874
+ now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
875
+ if resource == "session":
876
+ session_id = _claude_session_id_for_source_key(snapshot, key)
877
+ if session_id is None:
878
+ raise SourceResourceNotFound()
879
+ detail = sys.modules["cctally"]._tui_build_session_detail(
880
+ session_id, now_utc=now_utc,
881
+ )
882
+ if detail is None:
883
+ raise SourceResourceNotFound()
884
+ return _source_safe_claude_session_detail(detail, key=key)
885
+
886
+ if resource == "project":
887
+ project_key = _claude_project_key_for_source_key(snapshot, key)
888
+ if project_key is None:
889
+ raise SourceResourceNotFound()
890
+ conn = open_db()
891
+ try:
892
+ conn.execute("ATTACH DATABASE ? AS cache_db", (str(_cctally_core.CACHE_DB_PATH),))
893
+ conn.execute("CREATE TEMP VIEW session_entries AS SELECT * FROM cache_db.session_entries")
894
+ conn.execute("CREATE TEMP VIEW session_files AS SELECT * FROM cache_db.session_files")
895
+ detail = _project_detail_for_window(
896
+ conn,
897
+ project_key=project_key,
898
+ weeks_back=1,
899
+ now_utc=now_utc,
900
+ current_week=getattr(snapshot, "current_week", None),
901
+ projects_envelope=getattr(snapshot, "projects_envelope", None),
902
+ )
903
+ finally:
904
+ try:
905
+ conn.execute("DROP VIEW IF EXISTS session_entries")
906
+ conn.execute("DROP VIEW IF EXISTS session_files")
907
+ conn.execute("DETACH DATABASE cache_db")
908
+ except sqlite3.Error:
909
+ pass
910
+ conn.close()
911
+ if detail is None:
912
+ raise SourceResourceNotFound()
913
+ return _source_safe_claude_project_detail(detail, key=key)
914
+
915
+ start_at = _claude_block_start_for_source_key(snapshot, key)
916
+ if start_at is None:
917
+ raise SourceResourceNotFound()
918
+ end_at = start_at + BLOCK_DURATION
919
+ cache_conn = open_cache_db()
920
+ try:
921
+ entries = list(iter_entries(cache_conn, start_at, end_at))
922
+ finally:
923
+ cache_conn.close()
924
+ recorded_windows, block_start_overrides, canonical_intervals = (
925
+ _load_recorded_five_hour_windows(start_at - BLOCK_DURATION, end_at + BLOCK_DURATION)
926
+ )
927
+ blocks = _group_entries_into_blocks(
928
+ entries,
929
+ mode="auto",
930
+ recorded_windows=recorded_windows,
931
+ block_start_overrides=block_start_overrides,
932
+ canonical_intervals=canonical_intervals,
933
+ now=now_utc,
934
+ )
935
+ target = next(
936
+ (block for block in blocks if not block.is_gap and block.start_time == start_at),
937
+ None,
938
+ )
939
+ if target is None:
940
+ raise SourceResourceNotFound()
941
+ block_entries = [
942
+ entry for entry in entries if target.start_time <= entry.timestamp < target.end_time
943
+ ]
944
+ return _source_safe_claude_block_detail(
945
+ _build_block_detail(target, block_entries), key=key,
946
+ )
947
+
948
+
949
+ def build_source_detail(*, snapshot, source: str, resource: str,
950
+ key: str) -> dict[str, Any]:
951
+ """Resolve one qualified opaque key to a provider-native read-only detail.
952
+
953
+ The frozen published bundle is the first bounded key index. Claude then
954
+ invokes the existing detail builders with cache-only reads; Codex returns
955
+ the native S1-S3 adapter detail without a Claude fallback or a rollout
956
+ parse. Every branch preserves the generic handler errors above it.
957
+ """
958
+ row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
959
+ if source == "claude":
960
+ return _build_claude_source_detail(snapshot, resource=resource, key=key)
961
+ return _build_codex_source_detail(snapshot, resource=resource, key=key)
962
+
463
963
  _ensure_sibling_loaded("_cctally_dashboard_share")
464
964
  from _cctally_dashboard_share import (
465
965
  # accessor shims (still forward late-binding to sys.modules["cctally"])
@@ -3492,6 +3992,100 @@ def _debug_cache_table_counts(cache_conn) -> dict:
3492
3992
  return counts
3493
3993
 
3494
3994
 
3995
+ _DEBUG_SOURCE_CACHE_TABLES = {
3996
+ "claude": (("session_entries", None), ("session_files", None)),
3997
+ "codex": (
3998
+ ("codex_session_entries", None),
3999
+ ("codex_session_files", None),
4000
+ ("codex_source_roots", None),
4001
+ ("quota_window_snapshots", "source='codex'"),
4002
+ ),
4003
+ }
4004
+
4005
+ _DEBUG_SOURCE_STATS_TABLES = {
4006
+ "codex": (
4007
+ ("quota_window_blocks", "source='codex'"),
4008
+ ("quota_percent_milestones", "source='codex'"),
4009
+ ("quota_threshold_events", "source='codex'"),
4010
+ ("budget_milestones", "vendor='codex'"),
4011
+ ("projected_milestones", "metric='codex_budget_usd'"),
4012
+ ),
4013
+ }
4014
+
4015
+
4016
+ def _debug_source_state_wire(bundle, source: str) -> dict:
4017
+ """Return only public availability/version state from a published bundle."""
4018
+ try:
4019
+ state = bundle.sources[source]
4020
+ availability = getattr(state, "availability", "unavailable")
4021
+ freshness = getattr(state, "freshness", "stale")
4022
+ version = getattr(state, "data_version", None)
4023
+ data = getattr(state, "data", None)
4024
+ except (AttributeError, KeyError, TypeError):
4025
+ availability, freshness, version, data = "unavailable", "stale", None, None
4026
+ def resource_count(domain: str) -> int:
4027
+ try:
4028
+ value = data[domain]["rows"]
4029
+ return len(value) if isinstance(value, (list, tuple)) else 0
4030
+ except (KeyError, TypeError):
4031
+ return 0
4032
+ return {
4033
+ "availability": availability if availability in ("ok", "empty", "partial") else "unavailable",
4034
+ "freshness": freshness if freshness in ("fresh", "stale") else "stale",
4035
+ "data_version": version if isinstance(version, str) else None,
4036
+ "tables": {},
4037
+ "resources": {
4038
+ "projects": resource_count("projects"),
4039
+ "alerts": resource_count("alerts"),
4040
+ },
4041
+ }
4042
+
4043
+
4044
+ def _debug_source_counts(cache_conn, bundle) -> dict:
4045
+ """Bounded, source-owned counts and opaque state for the debug endpoint.
4046
+
4047
+ Every table and predicate is fixed here. This deliberately reports no
4048
+ values from rows: roots, paths, logical limits, conversation IDs, and
4049
+ project labels never cross the diagnostic boundary.
4050
+ """
4051
+ result = {
4052
+ source: _debug_source_state_wire(bundle, source)
4053
+ for source in ("claude", "codex", "all")
4054
+ }
4055
+ if cache_conn is None:
4056
+ return result
4057
+ for source, tables in _DEBUG_SOURCE_CACHE_TABLES.items():
4058
+ for table, where in tables:
4059
+ try:
4060
+ predicate = f" WHERE {where}" if where else ""
4061
+ row = cache_conn.execute(
4062
+ f"SELECT COUNT(*) FROM {table}{predicate}" # noqa: S608 -- fixed allowlist
4063
+ ).fetchone()
4064
+ result[source]["tables"][table] = int(row[0])
4065
+ except sqlite3.Error:
4066
+ pass
4067
+ stats_conn = None
4068
+ try:
4069
+ stats_conn = sqlite3.connect(
4070
+ f"{_cctally_core.DB_PATH.as_uri()}?mode=ro", uri=True
4071
+ )
4072
+ for source, tables in _DEBUG_SOURCE_STATS_TABLES.items():
4073
+ for table, where in tables:
4074
+ try:
4075
+ row = stats_conn.execute(
4076
+ f"SELECT COUNT(*) FROM {table} WHERE {where}" # noqa: S608 -- fixed allowlist
4077
+ ).fetchone()
4078
+ result[source]["tables"][table] = int(row[0])
4079
+ except sqlite3.Error:
4080
+ pass
4081
+ except sqlite3.Error:
4082
+ pass
4083
+ finally:
4084
+ if stats_conn is not None:
4085
+ stats_conn.close()
4086
+ return result
4087
+
4088
+
3495
4089
  def _debug_cache_state(cache_conn) -> dict:
3496
4090
  """On-demand signature legs + pending-reingest flags + generation.
3497
4091
 
@@ -3528,8 +4122,8 @@ def _debug_cache_state(cache_conn) -> dict:
3528
4122
  "max_entry_id": sc._max_id(cache_conn, "session_entries"),
3529
4123
  "entry_mutation_seq": sc._entry_mutation_seq(cache_conn),
3530
4124
  }
3531
- except sqlite3.Error as exc:
3532
- state["signature"] = {"_error": f"{type(exc).__name__}: {exc}"}
4125
+ except sqlite3.Error:
4126
+ state["signature"] = {"status": "unavailable"}
3533
4127
  finally:
3534
4128
  if stats_conn is not None:
3535
4129
  stats_conn.close()
@@ -3587,6 +4181,7 @@ def _debug_tool_version() -> str:
3587
4181
  _GET_ROUTES = (
3588
4182
  ("exact", "/api/data", "_serve_api_data", None, False),
3589
4183
  ("exact", "/api/events", "_serve_api_events", None, False),
4184
+ ("prefix", "/api/source/", "_handle_get_source_detail", None, True),
3590
4185
  ("prefix", "/api/session/", "_handle_get_session_detail", None, True),
3591
4186
  ("prefix", "/api/project/", "_handle_get_project_detail", None, False),
3592
4187
  ("prefix", "/api/block/", "_handle_get_block_detail", None, True),
@@ -4052,15 +4647,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4052
4647
  last = self._perf_gate().last_backend_perf()
4053
4648
  dataset: dict = {}
4054
4649
  cache_state: dict = {}
4650
+ try:
4651
+ source_bundle = self.snapshot_ref.get().source_bundle
4652
+ except Exception: # noqa: BLE001 -- diagnostics fail closed.
4653
+ source_bundle = None
4654
+ sources = _debug_source_counts(None, source_bundle)
4055
4655
  try:
4056
4656
  conn = open_cache_db()
4057
4657
  try:
4058
4658
  dataset = _debug_cache_table_counts(conn)
4059
4659
  cache_state = _debug_cache_state(conn)
4660
+ sources = _debug_source_counts(conn, source_bundle)
4060
4661
  finally:
4061
4662
  conn.close()
4062
- except Exception as exc: # noqa: BLE001 a diagnostic must not 500 loudly
4063
- cache_state = {"_error": f"{type(exc).__name__}: {exc}"}
4663
+ except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
4664
+ cache_state = {"status": "unavailable"}
4064
4665
  body = {
4065
4666
  "schemaVersion": 1,
4066
4667
  "version": _debug_tool_version(),
@@ -4068,6 +4669,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4068
4669
  "dataset": dataset,
4069
4670
  "phases": (last or {}).get("phases"),
4070
4671
  "cache_state": cache_state,
4672
+ "sources": sources,
4071
4673
  }
4072
4674
  if body["phases"] is None:
4073
4675
  body["note"] = "tracing_disabled"
@@ -5124,6 +5726,78 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5124
5726
  self.end_headers()
5125
5727
  self.wfile.write(body)
5126
5728
 
5729
+ def _handle_get_source_detail(self, path: str) -> None:
5730
+ """Serve one source-qualified provider detail with read-only access."""
5731
+ import re
5732
+ import urllib.parse as _urlparse
5733
+
5734
+ raw = path[len("/api/source/"):]
5735
+ parts = raw.split("/", 2)
5736
+ if len(parts) != 3:
5737
+ self._respond_json(400, {
5738
+ "code": "source_capability_unavailable",
5739
+ "error": "source capability unavailable",
5740
+ })
5741
+ return
5742
+ source, resource, raw_key = parts
5743
+ if (
5744
+ source not in ("claude", "codex")
5745
+ or resource not in ("session", "project", "block")
5746
+ or not raw_key
5747
+ or re.search(r"%(?![0-9A-Fa-f]{2})", raw_key)
5748
+ ):
5749
+ self._respond_json(400, {
5750
+ "code": "source_capability_unavailable",
5751
+ "error": "source capability unavailable",
5752
+ })
5753
+ return
5754
+ try:
5755
+ key = _urlparse.unquote_to_bytes(raw_key).decode("utf-8", "strict")
5756
+ except UnicodeDecodeError:
5757
+ self._respond_json(400, {
5758
+ "code": "source_capability_unavailable",
5759
+ "error": "source capability unavailable",
5760
+ })
5761
+ return
5762
+ if not key or "/" in key:
5763
+ self._respond_json(400, {
5764
+ "code": "source_capability_unavailable",
5765
+ "error": "source capability unavailable",
5766
+ })
5767
+ return
5768
+ try:
5769
+ snap = self.snapshot_ref.get()
5770
+ detail = build_source_detail(
5771
+ snapshot=snap,
5772
+ source=source,
5773
+ resource=resource,
5774
+ key=key,
5775
+ )
5776
+ except SourceResourceNotFound:
5777
+ self._respond_json(404, {
5778
+ "code": "source_resource_not_found",
5779
+ "error": "source resource not found",
5780
+ })
5781
+ return
5782
+ except SourceCapabilityUnavailable:
5783
+ self._respond_json(400, {
5784
+ "code": "source_capability_unavailable",
5785
+ "error": "source capability unavailable",
5786
+ })
5787
+ return
5788
+ except Exception as exc: # noqa: BLE001 — detailed diagnostics stay server-only.
5789
+ self.log_error("/api/source/%s/%s failed: %r", source, resource, exc)
5790
+ self._respond_json(400, {
5791
+ "code": "source_capability_unavailable",
5792
+ "error": "source capability unavailable",
5793
+ })
5794
+ return
5795
+ self._respond_json(200, {
5796
+ "source": source,
5797
+ "resource": resource,
5798
+ "data": detail,
5799
+ })
5800
+
5127
5801
 
5128
5802
  # ---- conversation viewer (spec §6) — thin delegators to the F4 sibling ----
5129
5803
  # Bodies live in bin/_cctally_dashboard_conversation.py as *_impl(handler, …)
@@ -5770,6 +6444,7 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
5770
6444
  doctor_payload=doctor_payload,
5771
6445
  envelope_precompute=envelope_precompute,
5772
6446
  hydrating=True,
6447
+ source_bundle=tui._tui_hydrating_source_bundle(),
5773
6448
  )
5774
6449
 
5775
6450
 
@@ -6062,4 +6737,3 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6062
6737
  http_thread.join(timeout=2)
6063
6738
  print("dashboard: stopped", flush=True)
6064
6739
  return 0
6065
-