cctally 1.73.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.74.0] - 2026-07-19
9
+
10
+ ### Fixed
11
+ - Dashboard Daily, Weekly, and Monthly details now use the same bounded history density across Claude, Codex, and All sources—30 days, 12 weeks, and 8 months—with compact date labels and contained mobile tables; Codex Blocks remains explicitly unavailable when native 5-hour activity does not exist. (#319)
12
+ - Dashboard Trend, Projects, Cache Report, Forecast, and Alerts details now keep their canonical composed structure for Codex and All sources, show provider-native values or explicit unavailable reasons in every slot, and route provider-qualified project rows through the source-bound detail foundation. (#319)
13
+ - Dashboard source-qualified Session and Project details now preserve Codex accounting totals when project metadata is incomplete; the combined source retains server-issued Claude Block identities and opens the canonical Block modal; source details share the standard focus, Escape, return-focus, and scroll-lock lifecycle; and desktop Share previews retain the captured provider. (#319)
14
+ - Codex accounting no longer double-counts parent token events copied into a forked/subagent rollout before its first model context. Those records remain available to the physical conversation reader but are excluded from accounting; cache migration 027 safely replays retained Codex rollouts to remove existing `unknown` duplicates. Consequently, `cctally pricing-check` and `doctor` no longer report those ingest artifacts as a missing embedded price, while real unknown model IDs remain actionable. The complete scoped OpenAI model set and tracked rates were reverified against the live pricing sources, and the embedded pricing snapshot date is now 2026-07-19.
15
+ - Dashboard Codex Weekly rows now follow observed native seven-day quota-cycle re-anchors instead of calendar weeks, collapsing reset jitter and clipping an older cycle when OpenAI grants an early reset. Codex quota forecasts now use elapsed native-cycle pace rather than activity-only bursts, avoiding misleading projections pinned at 100%. (#312)
16
+ - Dashboard Codex source reads now retain accounting, quota, budget, session, and forensics panels when retained historical rows still lack project attribution. The cache replay path repairs this metadata when possible; until then the source is fresh-but-partial and disables Projects only, with a rebuild remedy. (#312)
17
+ - Dashboard Codex hero cost and token counters now cover the active native seven-day reset cycle, rather than an unrelated calendar slice. A missing or conflicting active seven-day boundary leaves quota and budget visible while clearly marking the hero unavailable; Codex panels regain bounded native chrome and source warnings degrade only the affected domain. (#312)
18
+
8
19
  ## [1.73.0] - 2026-07-18
9
20
 
10
21
  ### Added
@@ -813,8 +813,30 @@ def _delete_codex_file_derived_rows(
813
813
  )
814
814
 
815
815
 
816
- def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
817
- """Clear every re-derivable Codex row family in child-before-root order."""
816
+ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
817
+ """Clear every re-derivable Codex row family and report whether state changed.
818
+
819
+ The FTS5 ``delete-all`` command can increment SQLite's cumulative change
820
+ counter even when the semantic Codex surface was already empty. Callers
821
+ use this return value, rather than ``Connection.total_changes``, when
822
+ deciding whether to advance the physical-mutation sequence.
823
+ """
824
+ state_changed = any(
825
+ conn.execute(query).fetchone() is not None
826
+ for query in (
827
+ "SELECT 1 FROM codex_session_entries LIMIT 1",
828
+ "SELECT 1 FROM quota_window_snapshots WHERE source = 'codex' LIMIT 1",
829
+ "SELECT 1 FROM codex_conversation_threads LIMIT 1",
830
+ "SELECT 1 FROM codex_conversation_events LIMIT 1",
831
+ "SELECT 1 FROM codex_session_files LIMIT 1",
832
+ "SELECT 1 FROM codex_source_roots LIMIT 1",
833
+ "SELECT 1 FROM codex_conversation_messages LIMIT 1",
834
+ "SELECT 1 FROM codex_conversation_file_touches LIMIT 1",
835
+ "SELECT 1 FROM codex_conversation_rollups LIMIT 1",
836
+ "SELECT 1 FROM cache_meta "
837
+ "WHERE key='codex_quota_projection_certificate' LIMIT 1",
838
+ )
839
+ )
818
840
  conn.execute("DELETE FROM codex_session_entries")
819
841
  conn.execute("DELETE FROM quota_window_snapshots WHERE source = 'codex'")
820
842
  conn.execute("DELETE FROM codex_conversation_threads")
@@ -832,6 +854,7 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
832
854
  conn.execute(
833
855
  "DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
834
856
  )
857
+ return state_changed
835
858
 
836
859
 
837
860
  def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
@@ -3903,9 +3926,7 @@ def sync_codex_cache(
3903
3926
  # Clear INSIDE the lock — see sync_cache() for the full
3904
3927
  # rationale. Done before the existing SELECT so delta
3905
3928
  # detection sees an empty baseline.
3906
- before_clear = conn.total_changes
3907
- _clear_codex_derived_rows(conn)
3908
- if conn.total_changes != before_clear:
3929
+ if _clear_codex_derived_rows(conn):
3909
3930
  _bump_codex_physical_mutation_seq(conn)
3910
3931
  conn.commit()
3911
3932
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
@@ -763,6 +763,35 @@ def _build_codex_source_detail(snapshot, *, resource: str, key: str) -> dict[str
763
763
  raise SourceResourceNotFound()
764
764
 
765
765
 
766
+ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
767
+ key: str) -> dict[str, Any]:
768
+ """Keep a published cache-only row usable when project metadata is partial."""
769
+ detail = _source_safe_native_detail(
770
+ row, source="codex", resource=resource, key=key,
771
+ )
772
+ detail.update({
773
+ "metadata_availability": "partial",
774
+ "metadata_reason": (
775
+ "Codex project metadata is incomplete; available accounting totals "
776
+ "are preserved."
777
+ ),
778
+ })
779
+ if resource == "session":
780
+ detail.setdefault("models", [])
781
+ detail["model_breakdowns"] = []
782
+ elif resource == "project":
783
+ generated_at = getattr(snapshot, "generated_at", None)
784
+ detail.setdefault("range_start", detail.get("first_seen"))
785
+ detail.setdefault(
786
+ "range_end",
787
+ generated_at.astimezone(dt.timezone.utc).isoformat()
788
+ if isinstance(generated_at, dt.datetime) else detail.get("last_seen"),
789
+ )
790
+ detail["models"] = []
791
+ detail["sessions"] = []
792
+ return detail
793
+
794
+
766
795
  def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
767
796
  """Adapt the existing Claude session detail builder to S4's safe route."""
768
797
  payload = _session_detail_to_envelope(detail)
@@ -958,7 +987,12 @@ def build_source_detail(*, snapshot, source: str, resource: str,
958
987
  row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
959
988
  if source == "claude":
960
989
  return _build_claude_source_detail(snapshot, resource=resource, key=key)
961
- return _build_codex_source_detail(snapshot, resource=resource, key=key)
990
+ try:
991
+ return _build_codex_source_detail(snapshot, resource=resource, key=key)
992
+ except sys.modules["_cctally_source_analytics"].QualifiedMetadataUnavailable:
993
+ return _codex_partial_source_detail(
994
+ snapshot, row, resource=resource, key=key,
995
+ )
962
996
 
963
997
  _ensure_sibling_loaded("_cctally_dashboard_share")
964
998
  from _cctally_dashboard_share import (
@@ -1404,6 +1404,8 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1404
1404
  raise ValueError("source capability unavailable")
1405
1405
  required_domain = {
1406
1406
  "current-week": "hero",
1407
+ "trend": "periods",
1408
+ "forecast": "quota",
1407
1409
  "daily": "periods",
1408
1410
  "monthly": "periods",
1409
1411
  "weekly": "periods",
@@ -1425,14 +1427,47 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1425
1427
  source_rows = all_rows[-1:] if all_rows else ()
1426
1428
  command = "codex-weekly"
1427
1429
  display_tz = str(weekly.get("display_tz") or "UTC")
1428
- elif panel in ("daily", "monthly", "weekly"):
1430
+ elif panel in ("daily", "monthly", "weekly", "trend"):
1429
1431
  periods = data.get("periods")
1430
- panel_data = periods.get(panel) if isinstance(periods, Mapping) else {}
1432
+ period_key = "weekly" if panel == "trend" else panel
1433
+ panel_data = periods.get(period_key) if isinstance(periods, Mapping) else {}
1431
1434
  if not isinstance(panel_data, Mapping):
1432
1435
  raise ValueError("source capability unavailable")
1433
1436
  source_rows = tuple(panel_data.get("rows", ()))
1434
- command = f"codex-{panel}"
1437
+ command = f"codex-{period_key}"
1435
1438
  display_tz = str(panel_data.get("display_tz") or "UTC")
1439
+ elif panel == "forecast":
1440
+ quota = data.get("quota")
1441
+ panel_data = quota if isinstance(quota, Mapping) else {}
1442
+ source_rows = tuple(panel_data.get("histories", ())) if isinstance(panel_data, Mapping) else ()
1443
+ start, end = _share_codex_period_bounds(
1444
+ state=state, panel="weekly", options=options, rows=source_rows,
1445
+ )
1446
+ rows = []
1447
+ for row in source_rows:
1448
+ if not isinstance(row, Mapping):
1449
+ continue
1450
+ forecast = row.get("forecast") if isinstance(row.get("forecast"), Mapping) else {}
1451
+ current = row.get("current_percent")
1452
+ projected = forecast.get("projected_percent")
1453
+ rows.append(ls.Row(cells={
1454
+ "limit": ls.TextCell(str(row.get("label") or "Codex quota")),
1455
+ "current": ls.TextCell("—" if current is None else f"{float(current):.1f}%"),
1456
+ "projected": ls.TextCell("—" if projected is None else f"{float(projected):.1f}%"),
1457
+ }))
1458
+ return ls.ShareSnapshot(
1459
+ cmd="codex-quota", title="Codex Quota Forecast", subtitle=None,
1460
+ period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1461
+ columns=(
1462
+ ls.ColumnSpec(key="limit", label="Limit"),
1463
+ ls.ColumnSpec(key="current", label="Current", align="right"),
1464
+ ls.ColumnSpec(key="projected", label="Projected", align="right"),
1465
+ ),
1466
+ rows=tuple(rows), chart=None, totals=(), notes=(), generated_at=end,
1467
+ version=sys.modules["cctally"]._share_resolve_version(),
1468
+ template_id=template_id, source="codex", source_label="Codex",
1469
+ availability=availability, availability_reason=reason,
1470
+ )
1436
1471
  elif panel == "sessions":
1437
1472
  panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
1438
1473
  source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()