cctally 1.68.0 → 1.69.1

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 +61 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  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 +168 -5
  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 +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -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 +202 -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 +324 -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
@@ -280,10 +280,15 @@ def _handle_get_conversations_impl(handler) -> None:
280
280
 
281
281
  def _handle_get_conversations_facets_impl(handler) -> None:
282
282
  """``GET /api/conversations/facets`` — distinct project labels + their
283
- conversation counts, for the browse filter's project multi-select (spec
284
- §2). Behind the SAME loopback/Host privacy gate as the list route; a
285
- cheap indexed GROUP BY over the rollup. The popover loads its options
286
- once from here (deriving from a paginated page would be incomplete).
283
+ conversation counts AND per-model-family session counts, for the browse
284
+ filter's project + model multi-selects (spec §2 + #278 Theme C). Behind the
285
+ SAME loopback/Host privacy gate as the list route. The projects half is a
286
+ cheap GROUP BY over the ``conversation_sessions`` rollup; the model-family
287
+ half is an index-only scan of ``conversation_messages(model, session_id)``
288
+ via the partial covering index ``idx_conversation_messages_model_session``
289
+ (#301 — a full heap-walk of the whole table before that index). The popover
290
+ loads its options once from here (deriving from a paginated page would be
291
+ incomplete).
287
292
  """
288
293
  if not handler._require_transcripts_allowed():
289
294
  return
@@ -42,6 +42,7 @@ import datetime as dt
42
42
  import importlib.util as _ilu
43
43
  import os
44
44
  import sys
45
+ from collections.abc import Mapping
45
46
  from zoneinfo import ZoneInfo
46
47
 
47
48
  from _cctally_core import (
@@ -628,6 +629,103 @@ _ENVELOPE_AXIS_MAPPERS = {
628
629
  }
629
630
 
630
631
 
632
+ def _source_wire_value(value: object) -> object:
633
+ """Copy one published source value into JSON-compatible wire material.
634
+
635
+ Source state is frozen with mapping proxies and tuples so request threads
636
+ cannot mutate it. The envelope must return fresh ordinary containers
637
+ without consulting the cache or stats databases.
638
+ """
639
+ if isinstance(value, Mapping):
640
+ return {str(key): _source_wire_value(item) for key, item in value.items()}
641
+ if isinstance(value, (tuple, list)):
642
+ return [_source_wire_value(item) for item in value]
643
+ if isinstance(value, dt.datetime):
644
+ return _iso_z(value)
645
+ return value
646
+
647
+
648
+ def _unavailable_source_wire() -> dict:
649
+ return {
650
+ "availability": "unavailable",
651
+ "freshness": "stale",
652
+ "warnings": [{
653
+ "code": "source_build_failed",
654
+ "message": "Source data could not be built.",
655
+ "domain": "read_model",
656
+ }],
657
+ "data_version": "",
658
+ "last_success_at": None,
659
+ "capabilities": {},
660
+ "data": None,
661
+ }
662
+
663
+
664
+ def _source_state_to_wire(state: object) -> dict:
665
+ """Serialize one immutable source state without exposing its storage ID."""
666
+ warnings = getattr(state, "warnings", ())
667
+ capabilities = getattr(state, "capabilities", {})
668
+ last_success_at = getattr(state, "last_success_at", None)
669
+ return {
670
+ "availability": getattr(state, "availability"),
671
+ "freshness": getattr(state, "freshness"),
672
+ "warnings": [
673
+ {
674
+ "code": warning.code,
675
+ "message": warning.message,
676
+ **({"domain": warning.domain} if warning.domain is not None else {}),
677
+ }
678
+ for warning in warnings
679
+ ],
680
+ "data_version": getattr(state, "data_version"),
681
+ "last_success_at": (
682
+ _iso_z(last_success_at) if isinstance(last_success_at, dt.datetime) else None
683
+ ),
684
+ "capabilities": {
685
+ str(name): {
686
+ "status": capability.status,
687
+ **({"semantics": capability.semantics}
688
+ if capability.semantics is not None else {}),
689
+ }
690
+ for name, capability in capabilities.items()
691
+ },
692
+ "data": _source_wire_value(getattr(state, "data", None)),
693
+ }
694
+
695
+
696
+ def _source_bundle_to_envelope(bundle: object | None) -> dict:
697
+ """Return S4's additive source contract from a published bundle only."""
698
+ unavailable = {source: _unavailable_source_wire() for source in ("claude", "codex", "all")}
699
+ if bundle is None:
700
+ return {
701
+ "source_schema_version": 1,
702
+ "default_source": "claude",
703
+ "source_order": ["claude", "codex", "all"],
704
+ "sources": unavailable,
705
+ }
706
+ try:
707
+ order = tuple(bundle.source_order)
708
+ sources = bundle.sources
709
+ if order != ("claude", "codex", "all") or set(sources) != set(order):
710
+ raise ValueError("invalid published source bundle")
711
+ return {
712
+ "source_schema_version": bundle.source_schema_version,
713
+ "default_source": bundle.default_source,
714
+ "source_order": list(order),
715
+ "sources": {
716
+ source: _source_state_to_wire(sources[source])
717
+ for source in order
718
+ },
719
+ }
720
+ except Exception: # Never turn an unexpected snapshot shape into request-thread I/O.
721
+ return {
722
+ "source_schema_version": 1,
723
+ "default_source": "claude",
724
+ "source_order": ["claude", "codex", "all"],
725
+ "sources": unavailable,
726
+ }
727
+
728
+
631
729
  def _build_alerts_envelope_array(
632
730
  conn: sqlite3.Connection,
633
731
  limit: int = 100,
@@ -1242,7 +1340,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1242
1340
  (r for r in reversed(snap.trend) if r.is_current), None
1243
1341
  ) if snap.trend else None
1244
1342
 
1245
- return {
1343
+ envelope = {
1246
1344
  "envelope_version": 2,
1247
1345
  # #278 Theme A: single additive first-paint hydration latch. True only
1248
1346
  # on the cheap seed + A2's partial republishes (data still being
@@ -1250,6 +1348,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1250
1348
  # default keeps positionally-constructed fixture snapshots serializing.
1251
1349
  "hydrating": bool(getattr(snap, "hydrating", False)),
1252
1350
  "generated_at": _iso_z(snap.generated_at),
1351
+ # #300: the all-inputs data-version string at build time (changes iff any
1352
+ # DB leg the detail endpoints read changed; flat on an idle tick). The
1353
+ # browser's lazy detail fetchers revalidate on THIS (an actual
1354
+ # data-change signal) instead of the 5s ``generated_at`` heartbeat, so a
1355
+ # finished/static session/project/conversation is not re-GET every tick.
1356
+ # ``getattr`` default keeps positionally-constructed fixture snapshots
1357
+ # serializing; "" is the client's "no signal" sentinel (falls back to
1358
+ # ``generated_at``).
1359
+ "data_version": str(getattr(snap, "data_version", "") or ""),
1253
1360
  # last_sync_at in DataSnapshot is a monotonic float, not a wall
1254
1361
  # clock — the envelope's wall-clock moment is unknowable from
1255
1362
  # here, so expose it as None and rely on sync_age_s for the UI.
@@ -1478,6 +1585,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1478
1585
  # PREVIEW badge when set.
1479
1586
  **sys.modules["_cctally_dashboard"]._channel_env_fragment(),
1480
1587
  }
1588
+ envelope.update(_source_bundle_to_envelope(getattr(snap, "source_bundle", None)))
1589
+ return envelope
1481
1590
 
1482
1591
 
1483
1592
  def _session_detail_to_envelope(detail: "TuiSessionDetail") -> dict: