cctally 1.72.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -4,10 +4,11 @@ from __future__ import annotations
4
4
  import datetime as dt
5
5
  import hashlib
6
6
  import json
7
+ import pathlib
7
8
  import sqlite3
8
9
  import sys
9
10
  from collections.abc import Mapping
10
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, replace
11
12
  from types import MappingProxyType
12
13
  from typing import Any, Iterable
13
14
  from zoneinfo import ZoneInfo
@@ -18,11 +19,19 @@ from _cctally_quota import (
18
19
  load_codex_quota_observations,
19
20
  load_codex_quota_projection_certificate,
20
21
  )
21
- from _cctally_source_analytics import load_qualified_codex_entries
22
+ from _cctally_source_analytics import (
23
+ QualifiedMetadataUnavailable,
24
+ has_cached_codex_accounting_entries,
25
+ load_cached_rooted_codex_accounting_entries,
26
+ load_codex_project_metadata_health,
27
+ load_qualified_codex_entries,
28
+ )
29
+ import _lib_log
22
30
  from _lib_dashboard_sources import (
23
31
  CapabilityRecord,
24
32
  ProjectionCoherence,
25
33
  SourceDashboardState,
34
+ SourceDashboardWarning,
26
35
  assess_codex_projection_coherence,
27
36
  dashboard_resource_key,
28
37
  )
@@ -35,12 +44,16 @@ from _lib_quota import (
35
44
  select_baseline,
36
45
  )
37
46
  from _lib_jsonl import CodexEntry
47
+ from _lib_fmt import stable_sum
48
+ from _lib_aggregators import _aggregate_codex_buckets
49
+ from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
38
50
  from _lib_source_analytics import build_codex_project_result
39
51
  from _lib_view_models import (
52
+ CodexWeeklyView,
40
53
  build_codex_daily_view,
41
54
  build_codex_monthly_view,
55
+ build_rooted_codex_session_view,
42
56
  build_codex_session_view,
43
- build_codex_weekly_view,
44
57
  )
45
58
 
46
59
 
@@ -50,12 +63,12 @@ DASHBOARD_QUOTA_OBSERVATION_LIMIT = 1000
50
63
  DASHBOARD_QUOTA_RECENT_DAYS = 35
51
64
 
52
65
 
53
- class CodexProjectionIncoherent(RuntimeError):
54
- """Physical cache and S2 interpreted quota state cannot be mixed."""
66
+ class CodexCycleUnavailable(RuntimeError):
67
+ """No single active native seven-day boundary can bound hero accounting."""
55
68
 
56
- def __init__(self, reason: str | None) -> None:
69
+ def __init__(self, reason: str) -> None:
57
70
  self.reason = reason
58
- super().__init__("Codex quota projection is incoherent")
71
+ super().__init__(reason)
59
72
 
60
73
 
61
74
  class SourceCapabilityUnavailable(ValueError):
@@ -66,6 +79,157 @@ class SourceResourceNotFound(LookupError):
66
79
  """A valid opaque resource key has no row in its provider state."""
67
80
 
68
81
 
82
+ @dataclass(frozen=True)
83
+ class CodexCycleBoundary:
84
+ """The one active native subscription cycle usable for hero accounting."""
85
+
86
+ window_minutes: int
87
+ start_at: dt.datetime
88
+ resets_at: dt.datetime
89
+ # Root provenance is server-only accounting input, never public wire data.
90
+ source_root_keys: tuple[str, ...]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class CodexWeeklyPeriod:
95
+ """One non-overlapping observed native seven-day quota cycle segment."""
96
+
97
+ start_at: dt.datetime
98
+ end_at: dt.datetime
99
+ source_root_keys: tuple[str, ...]
100
+
101
+
102
+ def _resolve_codex_weekly_cycle(
103
+ observations: Iterable[object],
104
+ now_utc: dt.datetime,
105
+ ) -> CodexCycleBoundary:
106
+ """Select exactly one active account-level 10,080-minute native cycle."""
107
+ boundaries: dict[tuple[int, dt.datetime], set[str]] = {}
108
+ stale_weekly_evidence = False
109
+ for history in build_history(tuple(observations)):
110
+ if history.identity.window_minutes != 10_080:
111
+ continue
112
+ baseline = select_baseline(history.observations, now_utc)
113
+ if baseline is None or baseline.resets_at <= now_utc:
114
+ continue
115
+ if quota_freshness(history.physical_observations, now_utc).state != "fresh":
116
+ stale_weekly_evidence = True
117
+ continue
118
+ boundary = (history.identity.window_minutes, baseline.resets_at)
119
+ boundaries.setdefault(boundary, set()).add(history.identity.source_root_key)
120
+ if len(boundaries) != 1:
121
+ if not boundaries:
122
+ raise CodexCycleUnavailable("stale" if stale_weekly_evidence else "missing")
123
+ raise CodexCycleUnavailable("conflicting")
124
+ (window_minutes, resets_at), root_keys = next(iter(boundaries.items()))
125
+ return CodexCycleBoundary(
126
+ window_minutes=window_minutes,
127
+ start_at=resets_at - dt.timedelta(minutes=window_minutes),
128
+ resets_at=resets_at,
129
+ source_root_keys=tuple(sorted(root_keys)),
130
+ )
131
+
132
+
133
+ def _codex_weekly_periods(
134
+ stats_conn: sqlite3.Connection,
135
+ *,
136
+ source_root_keys: Iterable[str],
137
+ active_cycle: CodexCycleBoundary | None,
138
+ ) -> tuple[CodexWeeklyPeriod, ...]:
139
+ """Read durable 10,080-minute boundaries and clip early re-anchors.
140
+
141
+ A provider-granted reset changes the native window's nominal start before
142
+ the prior seven-day deadline. Sorting those nominal starts and ending the
143
+ prior segment at the next start preserves the actual quota-cycle boundary
144
+ without double-counting the overlapping nominal windows.
145
+ """
146
+ roots = tuple(sorted({
147
+ root for root in source_root_keys if isinstance(root, str) and root
148
+ }))
149
+ if not roots:
150
+ return ()
151
+ placeholders = ",".join("?" for _ in roots)
152
+ try:
153
+ rows = stats_conn.execute(
154
+ "SELECT source_root_key, resets_at_utc, nominal_start_at_utc "
155
+ "FROM quota_window_blocks "
156
+ "WHERE source='codex' AND window_minutes=10080 "
157
+ f"AND source_root_key IN ({placeholders}) AND orphaned_at IS NULL "
158
+ "ORDER BY nominal_start_at_utc DESC, resets_at_utc DESC, source_root_key "
159
+ "LIMIT ?",
160
+ (*roots, SOURCE_HISTORY_LIMIT),
161
+ ).fetchall()
162
+ except sqlite3.Error:
163
+ rows = ()
164
+
165
+ raw_boundaries: list[tuple[dt.datetime, dt.datetime, set[str]]] = []
166
+
167
+ for root_key, resets_at_raw, start_at_raw in rows:
168
+ try:
169
+ start_at = dt.datetime.fromisoformat(str(start_at_raw).replace("Z", "+00:00"))
170
+ resets_at = dt.datetime.fromisoformat(str(resets_at_raw).replace("Z", "+00:00"))
171
+ except (TypeError, ValueError):
172
+ continue
173
+ if start_at.tzinfo is None or resets_at.tzinfo is None:
174
+ continue
175
+ start_at = start_at.astimezone(UTC)
176
+ resets_at = resets_at.astimezone(UTC)
177
+ if resets_at <= start_at:
178
+ continue
179
+ raw_boundaries.append((start_at, resets_at, {str(root_key)}))
180
+
181
+ if active_cycle is not None:
182
+ raw_boundaries.append((
183
+ active_cycle.start_at.astimezone(UTC),
184
+ active_cycle.resets_at.astimezone(UTC),
185
+ set(active_cycle.source_root_keys),
186
+ ))
187
+
188
+ ordered: list[tuple[dt.datetime, dt.datetime, set[str]]] = []
189
+ for start_at, resets_at, period_roots in sorted(
190
+ raw_boundaries, key=lambda item: (item[0], item[1]),
191
+ ):
192
+ if (
193
+ ordered
194
+ and (start_at - ordered[-1][0]).total_seconds()
195
+ < _FIVE_HOUR_JITTER_FLOOR_SECONDS
196
+ ):
197
+ first_start, latest_reset, existing_roots = ordered[-1]
198
+ existing_roots.update(period_roots)
199
+ ordered[-1] = (first_start, max(latest_reset, resets_at), existing_roots)
200
+ else:
201
+ ordered.append((start_at, resets_at, set(period_roots)))
202
+ periods: list[CodexWeeklyPeriod] = []
203
+ for index, (start_at, resets_at, period_roots) in enumerate(ordered):
204
+ next_start = ordered[index + 1][0] if index + 1 < len(ordered) else None
205
+ end_at = min(resets_at, next_start) if next_start is not None else resets_at
206
+ if end_at <= start_at:
207
+ continue
208
+ periods.append(CodexWeeklyPeriod(
209
+ start_at=start_at,
210
+ end_at=end_at,
211
+ source_root_keys=tuple(sorted(period_roots)),
212
+ ))
213
+ return tuple(periods)
214
+
215
+
216
+ def _native_limit_label(limit_name: object, window_minutes: object) -> str:
217
+ """Prefer provider label text, deriving duration copy only when absent."""
218
+ if isinstance(limit_name, str) and limit_name.strip():
219
+ return limit_name.strip()
220
+ if window_minutes == 300:
221
+ return "5-hour limit"
222
+ if window_minutes == 10_080:
223
+ return "7-day limit"
224
+ if not isinstance(window_minutes, int) or isinstance(window_minutes, bool) or window_minutes <= 0:
225
+ return "Codex quota"
226
+ if window_minutes % 1_440 == 0:
227
+ return f"{window_minutes // 1_440}-day limit"
228
+ if window_minutes % 60 == 0:
229
+ return f"{window_minutes // 60}-hour limit"
230
+ return f"{window_minutes}-minute limit"
231
+
232
+
69
233
  @dataclass(frozen=True)
70
234
  class DashboardSourceSemantics:
71
235
  """One canonical CLI configuration resolution for a dashboard read.
@@ -298,6 +462,7 @@ def _bucket_wire(bucket: Any) -> dict[str, object]:
298
462
  "reasoning_output_tokens": bucket.reasoning_output_tokens,
299
463
  "total_tokens": bucket.total_tokens,
300
464
  "models": tuple(bucket.models),
465
+ "model_breakdowns": tuple(dict(row) for row in bucket.model_breakdowns),
301
466
  }
302
467
 
303
468
 
@@ -310,20 +475,179 @@ def _period_wire(view: Any) -> dict[str, object]:
310
475
  }
311
476
 
312
477
 
313
- def _session_wire(view: Any) -> dict[str, object]:
478
+ def _codex_conversation_metadata(
479
+ cache_conn: sqlite3.Connection,
480
+ ) -> dict[tuple[str, str], dict[str, object]]:
481
+ """Read task short names and cached project metadata by rooted rollout.
482
+
483
+ ``state_5.sqlite.threads.title`` is Codex's persisted user-facing task name.
484
+ Conversation rollup titles are derived from prompt text and therefore must
485
+ never be substituted for that name on the dashboard. Accounting remains
486
+ authoritative for totals; conversation rollups only decorate projects.
487
+ """
488
+ metadata: dict[tuple[str, str], dict[str, object]] = {}
489
+ try:
490
+ rows = tuple(cache_conn.execute(
491
+ "SELECT t.source_root_key, t.source_path, t.native_thread_id, "
492
+ "(SELECT e.session_id FROM codex_session_entries AS e "
493
+ " WHERE e.source_root_key=t.source_root_key AND e.source_path=t.source_path "
494
+ " ORDER BY e.id LIMIT 1) AS accounting_session_id, "
495
+ "r.project_key, r.project_label, r.started_utc "
496
+ "FROM codex_conversation_threads AS t "
497
+ "LEFT JOIN codex_conversation_rollups AS r "
498
+ "ON r.conversation_key=t.conversation_key "
499
+ "ORDER BY r.last_activity_utc DESC, t.conversation_key DESC"
500
+ ))
501
+ file_aliases = tuple(cache_conn.execute(
502
+ "SELECT source_root_key, path, last_native_thread_id, last_session_id "
503
+ "FROM codex_session_files "
504
+ "WHERE last_native_thread_id IS NOT NULL AND last_native_thread_id != '' "
505
+ "ORDER BY last_ingested_at DESC, path DESC"
506
+ ))
507
+ native_ids = tuple(sorted({
508
+ str(native_thread_id) for _, _, native_thread_id, *_ in rows
509
+ if isinstance(native_thread_id, str) and native_thread_id
510
+ } | {
511
+ str(native_thread_id) for _, _, native_thread_id, _ in file_aliases
512
+ if isinstance(native_thread_id, str) and native_thread_id
513
+ }))
514
+ provider_roots = {
515
+ str(root_key): pathlib.Path(root_path)
516
+ for root_key, root_path in cache_conn.execute(
517
+ "SELECT source_root_key, canonical_root_path FROM codex_source_roots "
518
+ "ORDER BY source_root_key"
519
+ )
520
+ if isinstance(root_key, str) and root_key
521
+ and isinstance(root_path, str) and root_path
522
+ }
523
+ short_names: dict[str, str] = {}
524
+ for provider_root in provider_roots.values():
525
+ state_path = provider_root / "state_5.sqlite"
526
+ if not state_path.is_file():
527
+ continue
528
+ state_conn: sqlite3.Connection | None = None
529
+ try:
530
+ state_conn = sqlite3.connect(
531
+ f"{state_path.resolve().as_uri()}?mode=ro",
532
+ uri=True,
533
+ timeout=0.05,
534
+ )
535
+ for offset in range(0, len(native_ids), 500):
536
+ batch = native_ids[offset:offset + 500]
537
+ if not batch:
538
+ continue
539
+ placeholders = ",".join("?" for _ in batch)
540
+ for thread_id, title in state_conn.execute(
541
+ f"SELECT id, title FROM threads WHERE id IN ({placeholders})",
542
+ batch,
543
+ ):
544
+ clean_title = " ".join(str(title or "").split())
545
+ if clean_title:
546
+ short_names[str(thread_id)] = clean_title
547
+ except (OSError, sqlite3.Error):
548
+ continue
549
+ finally:
550
+ if state_conn is not None:
551
+ state_conn.close()
552
+
553
+ metadata_by_native: dict[tuple[str, str], dict[str, object]] = {}
554
+ for (
555
+ root_key, source_path, native_thread_id, accounting_session_id,
556
+ project_key, project_label, started_at,
557
+ ) in rows:
558
+ identity = (str(root_key or ""), str(source_path or ""))
559
+ if not all(identity) or identity in metadata:
560
+ continue
561
+ item = {
562
+ "title": short_names.get(str(native_thread_id or "")),
563
+ "native_thread_id": native_thread_id,
564
+ "accounting_session_id": accounting_session_id,
565
+ "root_path": str(provider_roots.get(identity[0]) or ""),
566
+ "project_key": project_key,
567
+ "project_label": project_label,
568
+ "started_at": started_at,
569
+ }
570
+ metadata[identity] = item
571
+ native_identity = (identity[0], str(native_thread_id or ""))
572
+ existing = metadata_by_native.get(native_identity)
573
+ if existing is None or (not existing.get("project_key") and project_key):
574
+ metadata_by_native[native_identity] = item
575
+
576
+ # A child rollout can be accounting-complete while its own historical
577
+ # conversation-thread row is absent (for example, a file first cached
578
+ # before conversation normalization was introduced). The cursor still
579
+ # persists the rooted native thread id. Inherit only presentation
580
+ # metadata from that rooted task; the child's accounting path and
581
+ # session id remain its own identity and totals are never merged.
582
+ for root_key, source_path, native_thread_id, accounting_session_id in file_aliases:
583
+ identity = (str(root_key or ""), str(source_path or ""))
584
+ if not all(identity) or identity in metadata:
585
+ continue
586
+ native_identity = (identity[0], str(native_thread_id or ""))
587
+ inherited = metadata_by_native.get(native_identity)
588
+ metadata[identity] = {
589
+ "title": short_names.get(native_identity[1]) or (inherited or {}).get("title"),
590
+ "native_thread_id": native_thread_id,
591
+ "accounting_session_id": accounting_session_id,
592
+ "root_path": str(provider_roots.get(identity[0]) or ""),
593
+ "project_key": (inherited or {}).get("project_key"),
594
+ "project_label": (inherited or {}).get("project_label"),
595
+ "started_at": (inherited or {}).get("started_at"),
596
+ }
597
+ except sqlite3.Error:
598
+ return {}
599
+ return metadata
600
+
601
+
602
+ def _session_wire(
603
+ view: Any,
604
+ *,
605
+ metadata: Mapping[tuple[str, str], Mapping[str, object]] | None = None,
606
+ ) -> dict[str, object]:
314
607
  rows = []
315
- for ordinal, row in enumerate(view.rows, start=1):
608
+ for row in view.rows:
316
609
  # The Codex session aggregator intentionally splits equal relative
317
610
  # session paths from distinct $CODEX_HOME roots. The opaque detail
318
611
  # key must use that same grouping identity or two visible rows route
319
612
  # to one another's detail payload.
320
613
  root_identity = row.codex_root or "single-root"
614
+ row_metadata = (metadata or {}).get((str(row.codex_root or ""), str(row.session_id_path)))
615
+ if row_metadata is None and metadata is not None:
616
+ row_metadata = next((
617
+ value for (root_key, source_path), value in metadata.items()
618
+ if (
619
+ str(value.get("native_thread_id") or "") == str(row.session_id or "")
620
+ or str(value.get("accounting_session_id") or "") == str(row.session_id or "")
621
+ or source_path == str(row.session_id_path)
622
+ )
623
+ and (
624
+ not row.codex_root
625
+ or str(row.codex_root) in (
626
+ root_key,
627
+ str(value.get("root_path") or ""),
628
+ )
629
+ )
630
+ ), None)
631
+ title = str(row_metadata.get("title") or "").strip() if row_metadata else ""
632
+ project = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
633
+ started_at = row_metadata.get("started_at") if row_metadata else None
634
+ duration_min = None
635
+ if isinstance(started_at, str):
636
+ try:
637
+ started_dt = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
638
+ duration_min = max(0, round((row.last_activity.astimezone(UTC) - started_dt.astimezone(UTC)).total_seconds() / 60))
639
+ except (TypeError, ValueError):
640
+ started_at = None
321
641
  rows.append({
322
642
  "key": dashboard_resource_key(
323
643
  "session", "codex", root_identity, row.session_id_path,
324
644
  ),
325
645
  "source": "codex",
326
- "label": f"Session {ordinal}",
646
+ "label": title or None,
647
+ "project": project or None,
648
+ "project_key": row_metadata.get("project_key") if row_metadata else None,
649
+ "started_at": started_at,
650
+ "duration_min": duration_min,
327
651
  "last_activity": row.last_activity.astimezone(UTC).isoformat(),
328
652
  "cost_usd": row.cost_usd,
329
653
  "input_tokens": row.input_tokens,
@@ -341,31 +665,111 @@ def _session_wire(view: Any) -> dict[str, object]:
341
665
  }
342
666
 
343
667
 
344
- def _quota_wire(stats_conn: sqlite3.Connection) -> tuple[dict[str, object], ...]:
668
+ def _quota_wire(
669
+ stats_conn: sqlite3.Connection,
670
+ *,
671
+ accounting_entries: Iterable[object] = (),
672
+ cycle: CodexCycleBoundary | None = None,
673
+ now_utc: dt.datetime | None = None,
674
+ display_tz_name: str | None = None,
675
+ ) -> tuple[dict[str, object], ...]:
676
+ """Build current-cycle Codex 5-hour activity rows from durable windows.
677
+
678
+ The durable projection supplies the truthful native block boundaries. Cost,
679
+ tokens, and model splits come from root-qualified accounting inside each
680
+ half-open 300-minute interval. Weekly quota summaries are deliberately not
681
+ activity blocks and never enter this wire.
682
+ """
683
+ if cycle is None or now_utc is None:
684
+ return ()
345
685
  try:
346
686
  rows = stats_conn.execute(
347
687
  "SELECT source_root_key, logical_limit_key, observed_slot, window_minutes, "
348
- "limit_name, resets_at_utc, current_percent, orphaned_at "
349
- "FROM quota_window_blocks WHERE source='codex' "
688
+ "limit_name, resets_at_utc, nominal_start_at_utc, current_percent, orphaned_at "
689
+ "FROM quota_window_blocks WHERE source='codex' AND window_minutes=300 "
350
690
  "ORDER BY resets_at_utc DESC, source_root_key, logical_limit_key, observed_slot "
351
691
  "LIMIT ?",
352
692
  (SOURCE_HISTORY_LIMIT,),
353
693
  ).fetchall()
354
694
  except sqlite3.Error:
355
695
  return ()
356
- return tuple({
357
- "key": dashboard_resource_key(
358
- "block", "codex", root_key, logical_limit_key, observed_slot, window_minutes, resets_at,
359
- ),
360
- "source": "codex",
361
- "label": limit_name or "Codex quota",
362
- "resets_at": resets_at,
363
- "current_percent": current_percent,
364
- "orphaned": orphaned_at is not None,
365
- } for (
696
+ entries = tuple(accounting_entries)
697
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
698
+ c = sys.modules["cctally"]
699
+ wired: list[dict[str, object]] = []
700
+ seen_windows: set[tuple[str, dt.datetime, dt.datetime]] = set()
701
+ for (
366
702
  root_key, logical_limit_key, observed_slot, window_minutes,
367
- limit_name, resets_at, current_percent, orphaned_at,
368
- ) in rows)
703
+ _limit_name, resets_at_raw, nominal_start_raw, current_percent, orphaned_at,
704
+ ) in rows:
705
+ if orphaned_at is not None or str(root_key) not in cycle.source_root_keys:
706
+ continue
707
+ try:
708
+ start_at = dt.datetime.fromisoformat(str(nominal_start_raw).replace("Z", "+00:00"))
709
+ resets_at = dt.datetime.fromisoformat(str(resets_at_raw).replace("Z", "+00:00"))
710
+ except (TypeError, ValueError):
711
+ continue
712
+ if start_at.tzinfo is None or resets_at.tzinfo is None:
713
+ continue
714
+ start_at = start_at.astimezone(UTC)
715
+ resets_at = resets_at.astimezone(UTC)
716
+ if resets_at <= cycle.start_at or start_at >= cycle.resets_at:
717
+ continue
718
+ physical_key = (str(root_key), start_at, resets_at)
719
+ if physical_key in seen_windows:
720
+ continue
721
+ seen_windows.add(physical_key)
722
+ block_entries = tuple(
723
+ entry for entry in entries
724
+ if str(getattr(entry, "source_root_key", "")) == str(root_key)
725
+ and start_at <= getattr(entry, "timestamp").astimezone(UTC) < resets_at
726
+ )
727
+ if not block_entries:
728
+ continue
729
+ by_model: dict[str, dict[str, object]] = {}
730
+ for entry in block_entries:
731
+ model = str(getattr(entry, "model", "") or "unknown")
732
+ aggregate = by_model.setdefault(model, {
733
+ "modelName": model,
734
+ "inputTokens": 0,
735
+ "cachedInputTokens": 0,
736
+ "outputTokens": 0,
737
+ "reasoningOutputTokens": 0,
738
+ "totalTokens": 0,
739
+ "costParts": [],
740
+ })
741
+ aggregate["inputTokens"] += int(getattr(entry, "input_tokens", 0))
742
+ aggregate["cachedInputTokens"] += int(getattr(entry, "cached_input_tokens", 0))
743
+ aggregate["outputTokens"] += int(getattr(entry, "output_tokens", 0))
744
+ aggregate["reasoningOutputTokens"] += int(getattr(entry, "reasoning_output_tokens", 0))
745
+ aggregate["totalTokens"] += int(getattr(entry, "total_tokens", 0))
746
+ aggregate["costParts"].append(float(getattr(entry, "cost_usd", 0.0)))
747
+ breakdowns: list[dict[str, object]] = []
748
+ for aggregate in by_model.values():
749
+ cost = stable_sum(aggregate.pop("costParts"))
750
+ breakdowns.append({**aggregate, "cost": cost})
751
+ breakdowns.sort(key=lambda row: (-float(row["cost"]), str(row["modelName"])))
752
+ cost_usd = stable_sum(float(row["cost"]) for row in breakdowns)
753
+ wired.append({
754
+ "key": dashboard_resource_key(
755
+ "block", "codex", root_key, logical_limit_key,
756
+ observed_slot, window_minutes, resets_at_raw,
757
+ ),
758
+ "source": "codex",
759
+ "label": c.format_display_dt(
760
+ start_at, display_tz, fmt="%H:%M %b %d", suffix=True,
761
+ ),
762
+ "window_minutes": window_minutes,
763
+ "start_at": start_at.isoformat(),
764
+ "end_at": resets_at.isoformat(),
765
+ "resets_at": resets_at_raw,
766
+ "current_percent": current_percent,
767
+ "orphaned": False,
768
+ "is_active": start_at <= now_utc < resets_at,
769
+ "cost_usd": cost_usd,
770
+ "model_breakdowns": tuple(breakdowns),
771
+ })
772
+ return tuple(wired)
369
773
 
370
774
 
371
775
  def _budget_wire(stats_conn: sqlite3.Connection) -> tuple[dict[str, object], ...]:
@@ -511,7 +915,7 @@ def _quota_read_model(
511
915
  history_rows.append({
512
916
  "key": dashboard_resource_key("quota", "codex", *key_parts),
513
917
  "source": "codex",
514
- "label": identity.limit_name or "Codex quota",
918
+ "label": _native_limit_label(identity.limit_name, identity.window_minutes),
515
919
  "observed_slot": identity.observed_slot,
516
920
  "window_minutes": identity.window_minutes,
517
921
  "current_percent": baseline.used_percent if baseline is not None else None,
@@ -607,6 +1011,41 @@ def _clock_freshness(
607
1011
  return "stale" if age_seconds > stale_after else "fresh"
608
1012
 
609
1013
 
1014
+ def _clock_cycle_validity(
1015
+ histories: Iterable[object],
1016
+ now_utc: dt.datetime,
1017
+ ) -> tuple[bool, str]:
1018
+ """Re-evaluate frozen weekly evidence without touching cache or rollouts."""
1019
+ boundaries: set[dt.datetime] = set()
1020
+ stale_weekly_evidence = False
1021
+ for raw_history in histories:
1022
+ if not isinstance(raw_history, Mapping):
1023
+ continue
1024
+ if raw_history.get("window_minutes") != 10_080:
1025
+ continue
1026
+ current = raw_history.get("current_percent")
1027
+ forecast = raw_history.get("forecast")
1028
+ if current is None or not isinstance(forecast, Mapping):
1029
+ continue
1030
+ try:
1031
+ resets_at = dt.datetime.fromisoformat(
1032
+ str(forecast.get("resets_at")).replace("Z", "+00:00")
1033
+ ).astimezone(UTC)
1034
+ except (TypeError, ValueError):
1035
+ continue
1036
+ if resets_at <= now_utc:
1037
+ continue
1038
+ if raw_history.get("freshness") != "fresh":
1039
+ stale_weekly_evidence = True
1040
+ continue
1041
+ boundaries.add(resets_at)
1042
+ if len(boundaries) == 1:
1043
+ return True, "ok"
1044
+ if not boundaries and stale_weekly_evidence:
1045
+ return False, "stale"
1046
+ return False, "missing" if not boundaries else "conflicting"
1047
+
1048
+
610
1049
  def _refresh_budget_status_clock(
611
1050
  status: Mapping[str, object] | None,
612
1051
  now_utc: dt.datetime,
@@ -676,6 +1115,11 @@ def refresh_codex_source_clock(
676
1115
  data = dict(state.data)
677
1116
  quota = data.get("quota")
678
1117
  quota_changed = False
1118
+ cycle_changed = False
1119
+ capabilities = state.capabilities
1120
+ warnings = state.warnings
1121
+ availability = state.availability
1122
+ freshness = state.freshness
679
1123
  if isinstance(quota, Mapping):
680
1124
  quota = dict(quota)
681
1125
  refreshed_histories: list[dict[str, object]] = []
@@ -757,6 +1201,40 @@ def refresh_codex_source_clock(
757
1201
  quota["summary"] = summary
758
1202
  data["quota"] = quota
759
1203
  quota_changed = bool(refreshed_histories)
1204
+ hero = data.get("hero")
1205
+ hero_capability = state.capabilities.get("hero")
1206
+ if (
1207
+ isinstance(hero, Mapping)
1208
+ and isinstance(hero.get("cycle"), Mapping)
1209
+ and hero_capability is not None
1210
+ and hero_capability.status == "supported"
1211
+ ):
1212
+ cycle_valid, cycle_reason = _clock_cycle_validity(refreshed_histories, now_utc)
1213
+ if not cycle_valid:
1214
+ hero = dict(hero)
1215
+ for field in (
1216
+ "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
1217
+ "reasoning_output_tokens", "total_tokens", "cycle",
1218
+ ):
1219
+ hero[field] = None
1220
+ data["hero"] = hero
1221
+ refreshed_capabilities = dict(state.capabilities)
1222
+ refreshed_capabilities["hero"] = CapabilityRecord(
1223
+ "unavailable", "missing-or-conflicting-native-cycle",
1224
+ )
1225
+ capabilities = refreshed_capabilities
1226
+ warnings = tuple(
1227
+ warning for warning in state.warnings
1228
+ if warning.code != "codex_cycle_unavailable"
1229
+ ) + (SourceDashboardWarning(
1230
+ "codex_cycle_unavailable",
1231
+ "Codex native reset cycle is unavailable.",
1232
+ "hero",
1233
+ ),)
1234
+ availability = "partial"
1235
+ if cycle_reason == "stale":
1236
+ freshness = "stale"
1237
+ cycle_changed = True
760
1238
  budget_domain = data.get("budget")
761
1239
  budget_changed = False
762
1240
  if isinstance(budget_domain, Mapping):
@@ -778,16 +1256,16 @@ def refresh_codex_source_clock(
778
1256
  hero["budget"] = refreshed_budget
779
1257
  data["hero"] = hero
780
1258
  budget_changed = True
781
- if not (quota_changed or budget_changed):
1259
+ if not (quota_changed or budget_changed or cycle_changed):
782
1260
  return state
783
1261
  refreshed_state = SourceDashboardState(
784
1262
  source=state.source,
785
- availability=state.availability,
786
- freshness=state.freshness,
787
- warnings=state.warnings,
1263
+ availability=availability,
1264
+ freshness=freshness,
1265
+ warnings=warnings,
788
1266
  data_version=state.data_version,
789
1267
  last_success_at=state.last_success_at,
790
- capabilities=state.capabilities,
1268
+ capabilities=capabilities,
791
1269
  data=data,
792
1270
  clock_data=state.clock_data,
793
1271
  )
@@ -888,14 +1366,83 @@ def _projects_wire(
888
1366
  }
889
1367
 
890
1368
 
891
- def _codex_entries_from_qualified(entries: Iterable[object]) -> list[CodexEntry]:
892
- """Adapt the one root-qualified accounting read for shipped view kernels."""
1369
+ def _partial_projects_wire(
1370
+ entries: Iterable[object],
1371
+ metadata: Mapping[tuple[str, str], Mapping[str, object]],
1372
+ ) -> dict[str, object]:
1373
+ """Aggregate the qualified subset when older accounting metadata is mixed.
1374
+
1375
+ Rows without a cached conversation/project identity are omitted and remain
1376
+ covered by the Projects-domain warning. Valid projects stay visible; their
1377
+ totals never include an unqualified accounting row.
1378
+ """
1379
+ groups: dict[tuple[str, str], dict[str, object]] = {}
1380
+ for entry in entries:
1381
+ identity = (
1382
+ str(getattr(entry, "source_root_key", "") or ""),
1383
+ str(getattr(entry, "source_path", "") or ""),
1384
+ )
1385
+ row_metadata = metadata.get(identity)
1386
+ project_key = str(row_metadata.get("project_key") or "").strip() if row_metadata else ""
1387
+ project_label = str(row_metadata.get("project_label") or "").strip() if row_metadata else ""
1388
+ if not project_key or not project_label:
1389
+ continue
1390
+ group_key = (identity[0], project_key)
1391
+ group = groups.setdefault(group_key, {
1392
+ "project_key": project_key,
1393
+ "label": project_label,
1394
+ "sessions": set(),
1395
+ "first_seen": getattr(entry, "timestamp"),
1396
+ "last_seen": getattr(entry, "timestamp"),
1397
+ "cost_usd": 0.0,
1398
+ "input_tokens": 0,
1399
+ "cached_input_tokens": 0,
1400
+ "output_tokens": 0,
1401
+ "reasoning_output_tokens": 0,
1402
+ "total_tokens": 0,
1403
+ })
1404
+ timestamp = getattr(entry, "timestamp")
1405
+ group["first_seen"] = min(group["first_seen"], timestamp)
1406
+ group["last_seen"] = max(group["last_seen"], timestamp)
1407
+ group["sessions"].add(identity)
1408
+ for field in (
1409
+ "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
1410
+ "reasoning_output_tokens", "total_tokens",
1411
+ ):
1412
+ group[field] += getattr(entry, field)
1413
+
1414
+ rows = []
1415
+ for (root_key, _project_key), group in groups.items():
1416
+ rows.append({
1417
+ "key": dashboard_resource_key("project", "codex", root_key, group["project_key"]),
1418
+ "source": "codex",
1419
+ "label": group["label"],
1420
+ "session_count": len(group["sessions"]),
1421
+ "first_seen": group["first_seen"].astimezone(UTC).isoformat(),
1422
+ "last_seen": group["last_seen"].astimezone(UTC).isoformat(),
1423
+ "cost_usd": group["cost_usd"],
1424
+ "input_tokens": group["input_tokens"],
1425
+ "cached_input_tokens": group["cached_input_tokens"],
1426
+ "output_tokens": group["output_tokens"],
1427
+ "reasoning_output_tokens": group["reasoning_output_tokens"],
1428
+ "total_tokens": group["total_tokens"],
1429
+ })
1430
+ rows.sort(key=lambda row: (-float(row["cost_usd"]), str(row["label"]), str(row["key"])))
1431
+ return {
1432
+ "rows": tuple(rows),
1433
+ "total_cost_usd": stable_sum(float(row["cost_usd"]) for row in rows),
1434
+ "total_tokens": sum(int(row["total_tokens"]) for row in rows),
1435
+ }
1436
+
1437
+
1438
+ def _codex_entries_from_accounting(entries: Iterable[object]) -> list[CodexEntry]:
1439
+ """Adapt coordinated accounting rows for the shipped non-project kernels."""
893
1440
  converted: list[CodexEntry] = []
894
1441
  for entry in entries:
895
1442
  source_path = str(getattr(entry, "source_path", "") or "")
896
1443
  session_id = str(getattr(entry, "session_id", "") or "")
897
1444
  if not source_path or not session_id:
898
- raise SourceCapabilityUnavailable("qualified accounting lacks session identity")
1445
+ raise SourceCapabilityUnavailable("Codex accounting lacks session identity")
899
1446
  converted.append(CodexEntry(
900
1447
  timestamp=getattr(entry, "timestamp"),
901
1448
  session_id=session_id,
@@ -910,6 +1457,67 @@ def _codex_entries_from_qualified(entries: Iterable[object]) -> list[CodexEntry]
910
1457
  return converted
911
1458
 
912
1459
 
1460
+ def _codex_entries_from_qualified(entries: Iterable[object]) -> list[CodexEntry]:
1461
+ """Compatibility name retained for the source-detail reader."""
1462
+ return _codex_entries_from_accounting(entries)
1463
+
1464
+
1465
+ def _build_codex_native_weekly_view(
1466
+ stats_conn: sqlite3.Connection,
1467
+ entries: Iterable[object],
1468
+ *,
1469
+ source_root_keys: Iterable[str],
1470
+ active_cycle: CodexCycleBoundary | None,
1471
+ now_utc: dt.datetime,
1472
+ display_tz_name: str | None,
1473
+ speed: str,
1474
+ ) -> CodexWeeklyView:
1475
+ """Aggregate Codex cost into observed native quota-cycle segments."""
1476
+ periods = _codex_weekly_periods(
1477
+ stats_conn,
1478
+ source_root_keys=source_root_keys,
1479
+ active_cycle=active_cycle,
1480
+ )
1481
+ converted: list[CodexEntry] = []
1482
+ bucket_by_entry: dict[int, str] = {}
1483
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
1484
+ labels: dict[str, str] = {}
1485
+ for entry in entries:
1486
+ timestamp = getattr(entry, "timestamp").astimezone(UTC)
1487
+ root_key = str(getattr(entry, "source_root_key", "") or "")
1488
+ period = next((
1489
+ candidate for candidate in periods
1490
+ if root_key in candidate.source_root_keys
1491
+ and candidate.start_at <= timestamp < candidate.end_at
1492
+ ), None)
1493
+ if period is None:
1494
+ continue
1495
+ converted_entry = _codex_entries_from_accounting((entry,))[0]
1496
+ bucket = period.start_at.isoformat()
1497
+ converted.append(converted_entry)
1498
+ bucket_by_entry[id(converted_entry)] = bucket
1499
+ local_start = (
1500
+ period.start_at.astimezone(display_tz)
1501
+ if display_tz is not None else period.start_at.astimezone()
1502
+ )
1503
+ labels[bucket] = local_start.strftime("%m-%d %H:%M")
1504
+
1505
+ rows = _aggregate_codex_buckets(
1506
+ converted,
1507
+ key_fn=lambda entry: bucket_by_entry[id(entry)],
1508
+ speed=speed,
1509
+ )
1510
+ display_rows = tuple(replace(row, bucket=labels[row.bucket]) for row in rows)
1511
+ return CodexWeeklyView(
1512
+ rows=display_rows,
1513
+ total_cost_usd=stable_sum(row.cost_usd for row in display_rows),
1514
+ total_tokens=sum(row.total_tokens for row in display_rows),
1515
+ period_start=(periods[0].start_at if periods else None),
1516
+ period_end=now_utc,
1517
+ display_tz_label=display_tz_name or str(dt.datetime.now().astimezone().tzinfo),
1518
+ )
1519
+
1520
+
913
1521
  def build_codex_source_state(
914
1522
  context: DashboardReadContext,
915
1523
  *,
@@ -938,8 +1546,7 @@ def build_codex_source_state(
938
1546
  coherence = codex_projection_coherence(
939
1547
  context,
940
1548
  )
941
- if not coherence.coherent:
942
- raise CodexProjectionIncoherent(coherence.reason)
1549
+ projection_incoherent = not coherence.coherent
943
1550
  # The cache reader's established report surface treats the ``now`` instant
944
1551
  # as inclusive. The qualified adapter is half-open, so extend only its
945
1552
  # query/result boundary by one microsecond and keep all live budget sums
@@ -949,37 +1556,119 @@ def build_codex_source_state(
949
1556
  if context.codex_budget is not None:
950
1557
  _period, budget_start, _budget_end = _configured_codex_budget_window(context)
951
1558
  accounting_start = min(accounting_start, budget_start)
952
- qualified_entries = load_qualified_codex_entries(
953
- accounting_start,
954
- accounting_end,
955
- speed=context.speed,
956
- sync=False,
1559
+ health = load_codex_project_metadata_health(
957
1560
  cache_conn=context.cache_conn,
1561
+ start=accounting_start,
1562
+ end=accounting_end,
958
1563
  )
959
- budget_entries = _codex_entries_from_qualified(qualified_entries)
960
- visible_qualified_entries = tuple(
961
- entry for entry in qualified_entries
1564
+ metadata_incomplete = health.incomplete_rows > 0
1565
+ metadata_warning_message = (
1566
+ f"{health.incomplete_rows} Codex accounting row(s) lack project metadata; "
1567
+ "run `cctally cache-sync --source codex --rebuild`."
1568
+ if metadata_incomplete
1569
+ else "Codex project metadata could not be read; "
1570
+ "run `cctally cache-sync --source codex --rebuild`."
1571
+ )
1572
+ qualified_entries: tuple[object, ...] = ()
1573
+ if not metadata_incomplete:
1574
+ try:
1575
+ qualified_entries = load_qualified_codex_entries(
1576
+ accounting_start,
1577
+ accounting_end,
1578
+ speed=context.speed,
1579
+ sync=False,
1580
+ cache_conn=context.cache_conn,
1581
+ )
1582
+ accounting_entries: tuple[object, ...] = qualified_entries
1583
+ except QualifiedMetadataUnavailable:
1584
+ # A cached read must be internally coherent, but retain accounting
1585
+ # once if a defensive race or malformed row violates that premise.
1586
+ _lib_log.get_logger("dashboard").warning(
1587
+ "Codex qualified metadata read became unavailable; using cache-only accounting fallback"
1588
+ )
1589
+ metadata_incomplete = True
1590
+ accounting_entries = load_cached_rooted_codex_accounting_entries(
1591
+ accounting_start,
1592
+ accounting_end,
1593
+ speed=context.speed,
1594
+ cache_conn=context.cache_conn,
1595
+ )
1596
+ else:
1597
+ accounting_entries = load_cached_rooted_codex_accounting_entries(
1598
+ accounting_start,
1599
+ accounting_end,
1600
+ speed=context.speed,
1601
+ cache_conn=context.cache_conn,
1602
+ )
1603
+ budget_entries = _codex_entries_from_accounting(accounting_entries)
1604
+ cycle_reason: str | None = None
1605
+ try:
1606
+ cycle = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
1607
+ except CodexCycleUnavailable as exc:
1608
+ cycle = None
1609
+ cycle_reason = exc.reason
1610
+ cycle_failure = cycle is None and has_cached_codex_accounting_entries(
1611
+ cache_conn=context.cache_conn,
1612
+ )
1613
+ hero_failure = projection_incoherent or cycle_failure
1614
+ if cycle is None or hero_failure:
1615
+ cycle_entries: list[CodexEntry] = []
1616
+ cycle_cost_usd: float | None = None if hero_failure else 0.0
1617
+ else:
1618
+ cycle_end = min(accounting_end, cycle.resets_at)
1619
+ cycle_rows = load_cached_rooted_codex_accounting_entries(
1620
+ cycle.start_at,
1621
+ cycle_end,
1622
+ speed=context.speed,
1623
+ cache_conn=context.cache_conn,
1624
+ source_root_keys=cycle.source_root_keys,
1625
+ )
1626
+ cycle_entries = _codex_entries_from_accounting(cycle_rows)
1627
+ cycle_cost_usd = build_codex_daily_view(
1628
+ cycle_entries,
1629
+ now_utc=context.now_utc,
1630
+ tz_name=context.display_tz_name,
1631
+ speed=context.speed,
1632
+ ).total_cost_usd
1633
+ visible_accounting_entries = tuple(
1634
+ entry for entry in accounting_entries
962
1635
  if context.range_start <= getattr(entry, "timestamp").astimezone(UTC) < accounting_end
963
1636
  )
964
- entries = _codex_entries_from_qualified(visible_qualified_entries)
1637
+ entries = _codex_entries_from_accounting(visible_accounting_entries)
965
1638
  daily = build_codex_daily_view(
966
1639
  entries, now_utc=context.now_utc, tz_name=context.display_tz_name, speed=context.speed,
967
1640
  )
968
1641
  monthly = build_codex_monthly_view(
969
1642
  entries, now_utc=context.now_utc, tz_name=context.display_tz_name, speed=context.speed,
970
1643
  )
971
- weekly = build_codex_weekly_view(
972
- entries,
1644
+ weekly = _build_codex_native_weekly_view(
1645
+ context.stats_conn,
1646
+ visible_accounting_entries,
1647
+ source_root_keys=active_roots,
1648
+ active_cycle=cycle,
973
1649
  now_utc=context.now_utc,
974
- tz_name=context.display_tz_name,
975
- week_start_idx=context.week_start_idx,
1650
+ display_tz_name=context.display_tz_name,
976
1651
  speed=context.speed,
977
1652
  )
978
- sessions = build_codex_session_view(
979
- entries, now_utc=context.now_utc, tz_name=context.display_tz_name, speed=context.speed,
1653
+ sessions = (
1654
+ build_rooted_codex_session_view(
1655
+ visible_accounting_entries,
1656
+ now_utc=context.now_utc,
1657
+ tz_name=context.display_tz_name,
1658
+ speed=context.speed,
1659
+ )
1660
+ if metadata_incomplete else build_codex_session_view(
1661
+ entries, now_utc=context.now_utc, tz_name=context.display_tz_name, speed=context.speed,
1662
+ )
980
1663
  )
981
1664
  quota = _quota_read_model(context, quota_observations)
982
- quota_blocks = _quota_wire(context.stats_conn)
1665
+ quota_blocks = _quota_wire(
1666
+ context.stats_conn,
1667
+ accounting_entries=visible_accounting_entries,
1668
+ cycle=cycle,
1669
+ now_utc=context.now_utc,
1670
+ display_tz_name=context.display_tz_name,
1671
+ )
983
1672
  quota = {**quota, "blocks": quota_blocks}
984
1673
  budget_rows = _budget_wire(context.stats_conn)
985
1674
  projected_budget_rows = _projected_budget_wire(context.stats_conn)
@@ -987,45 +1676,94 @@ def build_codex_source_state(
987
1676
  configured_budget = _configured_codex_budget_status(
988
1677
  context, budget_entries, cost_events=budget_cost_events,
989
1678
  )
990
- projects = _projects_wire(
991
- context,
992
- quota_observations,
993
- visible_qualified_entries,
994
- accounting_end=accounting_end,
1679
+ conversation_metadata = _codex_conversation_metadata(context.cache_conn)
1680
+ projects = (
1681
+ _partial_projects_wire(visible_accounting_entries, conversation_metadata)
1682
+ if metadata_incomplete else _projects_wire(
1683
+ context,
1684
+ quota_observations,
1685
+ visible_accounting_entries,
1686
+ accounting_end=accounting_end,
1687
+ )
995
1688
  )
996
1689
  alerts = _alerts_wire(context.stats_conn)
997
- availability = "ok" if (entries or quota_blocks or budget_rows) else "empty"
998
- total_input = sum(entry.input_tokens for entry in entries)
999
- total_cached = sum(entry.cached_input_tokens for entry in entries)
1000
- total_output = sum(entry.output_tokens for entry in entries)
1001
- total_reasoning = sum(entry.reasoning_output_tokens for entry in entries)
1690
+ availability = (
1691
+ "partial" if metadata_incomplete or hero_failure
1692
+ else ("ok" if (entries or quota_blocks or budget_rows) else "empty")
1693
+ )
1694
+ hero_input = None if hero_failure else sum(entry.input_tokens for entry in cycle_entries)
1695
+ hero_cached = None if hero_failure else sum(entry.cached_input_tokens for entry in cycle_entries)
1696
+ hero_output = None if hero_failure else sum(entry.output_tokens for entry in cycle_entries)
1697
+ hero_reasoning = None if hero_failure else sum(entry.reasoning_output_tokens for entry in cycle_entries)
1698
+ hero_total = None if hero_failure else sum(entry.total_tokens for entry in cycle_entries)
1699
+ warnings: list[SourceDashboardWarning] = []
1700
+ if metadata_incomplete:
1701
+ warnings.append(SourceDashboardWarning(
1702
+ "codex_metadata_incomplete",
1703
+ metadata_warning_message,
1704
+ "projects",
1705
+ ))
1706
+ if projection_incoherent:
1707
+ warnings.append(SourceDashboardWarning(
1708
+ "codex_projection_incoherent",
1709
+ "Codex quota projection is unavailable.",
1710
+ "hero",
1711
+ ))
1712
+ if cycle_failure:
1713
+ warnings.append(SourceDashboardWarning(
1714
+ "codex_cycle_unavailable",
1715
+ "Codex native reset cycle is unavailable.",
1716
+ "hero",
1717
+ ))
1002
1718
  return SourceDashboardState(
1003
1719
  source="codex",
1004
1720
  availability=availability,
1005
- freshness="fresh",
1006
- warnings=(),
1721
+ freshness=("stale" if cycle_reason == "stale" else "fresh"),
1722
+ warnings=tuple(warnings),
1007
1723
  data_version=data_version,
1008
1724
  last_success_at=context.now_utc,
1009
1725
  capabilities={
1010
- "hero": CapabilityRecord("supported", "calendar-accounting"),
1726
+ "hero": (
1727
+ CapabilityRecord(
1728
+ "unavailable",
1729
+ (
1730
+ "projection-incoherent" if projection_incoherent
1731
+ else "missing-or-conflicting-native-cycle"
1732
+ ),
1733
+ )
1734
+ if hero_failure
1735
+ else CapabilityRecord("supported", "native-reset-cycle")
1736
+ ),
1011
1737
  "daily": CapabilityRecord("supported", "calendar-day"),
1012
1738
  "monthly": CapabilityRecord("supported", "calendar-month"),
1013
- "weekly": CapabilityRecord("supported", "calendar-week"),
1739
+ "weekly": CapabilityRecord("derived", "native-reset-cycles"),
1014
1740
  "sessions": CapabilityRecord("supported", "inclusive-input-tokens"),
1015
1741
  "forensics": CapabilityRecord("supported", "inclusive-input-token-reuse"),
1016
1742
  "quota": CapabilityRecord("derived", "native-windows"),
1017
1743
  "budget": CapabilityRecord("supported", "calendar-period"),
1018
- "projects": CapabilityRecord("supported", "qualified-attribution"),
1744
+ "projects": (
1745
+ CapabilityRecord("supported", "conversation-metadata-partial")
1746
+ if metadata_incomplete
1747
+ else CapabilityRecord("supported", "qualified-attribution")
1748
+ ),
1019
1749
  "alerts": CapabilityRecord("supported", "provider-native"),
1020
1750
  },
1021
1751
  data={
1022
1752
  "hero": {
1023
- "cost_usd": daily.total_cost_usd,
1024
- "input_tokens": total_input,
1025
- "cached_input_tokens": total_cached,
1026
- "output_tokens": total_output,
1027
- "reasoning_output_tokens": total_reasoning,
1028
- "total_tokens": daily.total_tokens,
1753
+ "cost_usd": cycle_cost_usd,
1754
+ "input_tokens": hero_input,
1755
+ "cached_input_tokens": hero_cached,
1756
+ "output_tokens": hero_output,
1757
+ "reasoning_output_tokens": hero_reasoning,
1758
+ "total_tokens": hero_total,
1759
+ "cycle": (
1760
+ {
1761
+ "window_minutes": cycle.window_minutes,
1762
+ "start_at": cycle.start_at.astimezone(UTC).isoformat(),
1763
+ "resets_at": cycle.resets_at.astimezone(UTC).isoformat(),
1764
+ }
1765
+ if cycle is not None and not hero_failure else None
1766
+ ),
1029
1767
  "quota": quota["summary"],
1030
1768
  "budget": configured_budget,
1031
1769
  "alerts": {"count": len(alerts)},
@@ -1035,7 +1773,7 @@ def build_codex_source_state(
1035
1773
  "monthly": _period_wire(monthly),
1036
1774
  "weekly": _period_wire(weekly),
1037
1775
  },
1038
- "sessions": _session_wire(sessions),
1776
+ "sessions": _session_wire(sessions, metadata=conversation_metadata),
1039
1777
  "quota": quota,
1040
1778
  "budget": {
1041
1779
  "status": configured_budget,