cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
|
@@ -25,13 +25,14 @@ host-local offset while boundaries are canonical UTC).
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
27
|
import datetime as dt
|
|
28
|
-
import json
|
|
29
28
|
import sqlite3
|
|
30
29
|
import sys
|
|
31
30
|
from dataclasses import replace
|
|
32
31
|
|
|
33
32
|
from _cctally_core import make_week_ref, parse_iso_datetime
|
|
34
33
|
from _cctally_quota import codex_quota_breakdown
|
|
34
|
+
from _lib_accounts import UNATTRIBUTED
|
|
35
|
+
from _lib_codex_pools import is_model_scoped_codex_quota
|
|
35
36
|
from _lib_dashboard_sources import dashboard_resource_key
|
|
36
37
|
from _lib_display_tz import _resolve_display_tz_obj, format_display_dt
|
|
37
38
|
from _lib_json_envelope import _iso_z
|
|
@@ -479,22 +480,6 @@ def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None
|
|
|
479
480
|
# per-identity — the spec-faithful realization for the disambiguated index.
|
|
480
481
|
|
|
481
482
|
|
|
482
|
-
def _is_model_scoped_codex_quota(logical_limit_key) -> bool:
|
|
483
|
-
"""Whether an interpreted native identity is a per-model pool (outside the
|
|
484
|
-
account-level standard quota). Mirrors the source builder's check."""
|
|
485
|
-
if not isinstance(logical_limit_key, str):
|
|
486
|
-
return False
|
|
487
|
-
try:
|
|
488
|
-
payload = json.loads(logical_limit_key)
|
|
489
|
-
except (json.JSONDecodeError, TypeError, ValueError):
|
|
490
|
-
return False
|
|
491
|
-
return (
|
|
492
|
-
isinstance(payload, dict)
|
|
493
|
-
and isinstance(payload.get("modelPool"), str)
|
|
494
|
-
and bool(payload["modelPool"].strip())
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
|
|
498
483
|
def _parse_utc(value) -> "dt.datetime | None":
|
|
499
484
|
if not value:
|
|
500
485
|
return None
|
|
@@ -520,7 +505,8 @@ class _CodexCycle:
|
|
|
520
505
|
|
|
521
506
|
__slots__ = (
|
|
522
507
|
"root", "limit", "slot", "window", "limit_id", "limit_name",
|
|
523
|
-
"start", "reset", "end", "current_percent", "members",
|
|
508
|
+
"account_key", "start", "reset", "end", "current_percent", "members",
|
|
509
|
+
"is_current",
|
|
524
510
|
)
|
|
525
511
|
|
|
526
512
|
def __init__(self, row):
|
|
@@ -528,6 +514,11 @@ class _CodexCycle:
|
|
|
528
514
|
self.limit = row["logical_limit_key"]
|
|
529
515
|
self.slot = row["observed_slot"]
|
|
530
516
|
self.window = int(row["window_minutes"])
|
|
517
|
+
# #373 root cause 3: `account_key` participates in QuotaWindowIdentity
|
|
518
|
+
# equality by #341 design, so every identity this class is used to
|
|
519
|
+
# reconstruct — and every query against the account-stamped projection
|
|
520
|
+
# tables reached from it — must carry it.
|
|
521
|
+
self.account_key = row["account_key"]
|
|
531
522
|
self.limit_id = row["limit_id"]
|
|
532
523
|
self.limit_name = row["limit_name"]
|
|
533
524
|
self.start = _parse_utc(row["nominal_start_at_utc"])
|
|
@@ -538,6 +529,7 @@ class _CodexCycle:
|
|
|
538
529
|
None if cp is None or isinstance(cp, bool) else float(cp)
|
|
539
530
|
)
|
|
540
531
|
self.members = None
|
|
532
|
+
self.is_current = False
|
|
541
533
|
|
|
542
534
|
@property
|
|
543
535
|
def cluster_members(self) -> list:
|
|
@@ -608,8 +600,40 @@ def _canonical_identity_cycles(cycles: list) -> list:
|
|
|
608
600
|
]
|
|
609
601
|
|
|
610
602
|
|
|
611
|
-
def
|
|
612
|
-
"""
|
|
603
|
+
def _boundary_has_live_reset(boundary) -> bool:
|
|
604
|
+
"""Whether a boundary object actually names a live cycle.
|
|
605
|
+
|
|
606
|
+
The §7.4 guard needs a REAL live boundary. The former future-reset proxy was
|
|
607
|
+
true for EVERY future-ending cycle, so arming the guard on a stub boundary
|
|
608
|
+
suppressed every clip and made a historic cycle whose nominal reset had not
|
|
609
|
+
yet passed render its full seven-day length. That is how one cycle key came
|
|
610
|
+
back as ``Jul 21–Jul 28`` from the detail while the index said
|
|
611
|
+
``Jul 21–Jul 25`` (#373). With no live boundary the correct behaviour is to
|
|
612
|
+
select no current cycle and clip exactly as before.
|
|
613
|
+
"""
|
|
614
|
+
return getattr(boundary, "resets_at", None) is not None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _select_physical_cycles(cycles: list, *, preferred_identity=None,
|
|
618
|
+
current_boundary=None, now_utc=None) -> list:
|
|
619
|
+
"""Select one full identity per physical reset boundary, then clip globally.
|
|
620
|
+
|
|
621
|
+
#373 §7.4: the cycle the LIVE baseline marks current is never clipped by a
|
|
622
|
+
successor — it ends at its own reset. Every other cycle clips exactly as
|
|
623
|
+
before, and no identity axis enters the clip (§6 Q3: cross-identity
|
|
624
|
+
clipping is what collapses the concurrent secondary/primary weekly windows
|
|
625
|
+
into one non-overlapping timeline).
|
|
626
|
+
|
|
627
|
+
``current_boundary`` is a ``CodexCycleBoundary``. Its reset can sit within
|
|
628
|
+
the jitter floor of more than one DISTINCT identity cluster, so proximity
|
|
629
|
+
is only the candidate filter. ``_select_live_physical_cycle`` resolves one
|
|
630
|
+
physical cycle (preferring the boundary's selected full identity), and that
|
|
631
|
+
object supplies both the no-clip guard and rendered ``is_current`` fact.
|
|
632
|
+
|
|
633
|
+
Suppressing a clip can restore an entire ROW, not merely lengthen one: the
|
|
634
|
+
``end > start`` filter below runs after clipping, so a live cycle whose
|
|
635
|
+
successor starts at the same instant would otherwise be dropped outright.
|
|
636
|
+
"""
|
|
613
637
|
identity_cycles = _canonical_identity_cycles(cycles)
|
|
614
638
|
selected = [
|
|
615
639
|
_choose_physical_cycle(cluster, preferred_identity=preferred_identity)
|
|
@@ -618,14 +642,23 @@ def _select_physical_cycles(cycles: list, *, preferred_identity=None) -> list:
|
|
|
618
642
|
)
|
|
619
643
|
]
|
|
620
644
|
selected.sort(key=lambda c: (c.start, c.reset, c.identity_parts))
|
|
645
|
+
live_boundary = (
|
|
646
|
+
current_boundary if _boundary_has_live_reset(current_boundary) else None
|
|
647
|
+
)
|
|
648
|
+
live_cycle = _select_live_physical_cycle(selected, live_boundary)
|
|
621
649
|
for index, cyc in enumerate(selected):
|
|
650
|
+
cyc.is_current = cyc is live_cycle
|
|
651
|
+
if cyc.is_current:
|
|
652
|
+
cyc.end = cyc.reset
|
|
653
|
+
continue
|
|
622
654
|
next_start = selected[index + 1].start if index + 1 < len(selected) else None
|
|
623
655
|
cyc.end = min(cyc.reset, next_start) if next_start is not None else cyc.reset
|
|
624
656
|
return [c for c in selected if c.end > c.start]
|
|
625
657
|
|
|
626
658
|
|
|
627
659
|
def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
|
|
628
|
-
preferred_identity=None
|
|
660
|
+
preferred_identity=None, current_boundary=None,
|
|
661
|
+
now_utc=None) -> list:
|
|
629
662
|
"""Unbounded reset-defined 7-day cycles, one selected identity per boundary."""
|
|
630
663
|
roots = tuple(sorted({r for r in root_keys if isinstance(r, str) and r}))
|
|
631
664
|
if not roots:
|
|
@@ -634,8 +667,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
|
|
|
634
667
|
orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
|
|
635
668
|
rows = stats_conn.execute(
|
|
636
669
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
637
|
-
" window_minutes, limit_id, limit_name,
|
|
638
|
-
" nominal_start_at_utc, current_percent "
|
|
670
|
+
" window_minutes, limit_id, limit_name, account_key, "
|
|
671
|
+
" resets_at_utc, nominal_start_at_utc, current_percent "
|
|
639
672
|
"FROM quota_window_blocks "
|
|
640
673
|
"WHERE source='codex' AND window_minutes=10080 "
|
|
641
674
|
f"{orphan_clause}"
|
|
@@ -646,14 +679,17 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
|
|
|
646
679
|
|
|
647
680
|
cycles: list = []
|
|
648
681
|
for row in rows:
|
|
649
|
-
if
|
|
682
|
+
if is_model_scoped_codex_quota(row["logical_limit_key"], row["limit_name"]):
|
|
650
683
|
continue
|
|
651
684
|
cyc = _CodexCycle(row)
|
|
652
685
|
if cyc.start is None or cyc.reset is None or cyc.reset <= cyc.start:
|
|
653
686
|
continue
|
|
654
687
|
cycles.append(cyc)
|
|
655
688
|
|
|
656
|
-
selected = _select_physical_cycles(
|
|
689
|
+
selected = _select_physical_cycles(
|
|
690
|
+
cycles, preferred_identity=preferred_identity,
|
|
691
|
+
current_boundary=current_boundary, now_utc=now_utc,
|
|
692
|
+
)
|
|
657
693
|
selected.sort(key=lambda c: c.start, reverse=True)
|
|
658
694
|
return selected
|
|
659
695
|
|
|
@@ -675,24 +711,53 @@ def _canonicalize_codex_cluster(members: list) -> "_CodexCycle":
|
|
|
675
711
|
return rep
|
|
676
712
|
|
|
677
713
|
|
|
678
|
-
def
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
714
|
+
def _select_live_physical_cycle(cycles: list, boundary):
|
|
715
|
+
"""Resolve at most one physical cycle for the live boundary.
|
|
716
|
+
|
|
717
|
+
Jitter clustering is identity-local before the cross-identity physical
|
|
718
|
+
selection. Two distinct clusters may therefore be more than one jitter
|
|
719
|
+
floor apart from each other while each remains within the floor of the
|
|
720
|
+
boundary reset. Prefer the boundary's already-selected full quota
|
|
721
|
+
identity, then the closest member reset, with stable scalar tie-breakers.
|
|
722
|
+
"""
|
|
723
|
+
if boundary is None:
|
|
724
|
+
return None
|
|
725
|
+
try:
|
|
726
|
+
target = boundary.resets_at.astimezone(UTC)
|
|
727
|
+
except (AttributeError, ValueError):
|
|
728
|
+
return None
|
|
729
|
+
candidates = [
|
|
730
|
+
cyc for cyc in cycles
|
|
731
|
+
if any(
|
|
732
|
+
abs((member.reset - target).total_seconds())
|
|
733
|
+
<= _mh.CODEX_CYCLE_JITTER_FLOOR_SECONDS
|
|
734
|
+
for member in cyc.cluster_members
|
|
735
|
+
)
|
|
736
|
+
]
|
|
737
|
+
if not candidates:
|
|
738
|
+
return None
|
|
739
|
+
preferred_identity = getattr(boundary, "quota_identity", None)
|
|
740
|
+
preferred = [
|
|
741
|
+
cyc for cyc in candidates if _identity_matches_cycle(preferred_identity, cyc)
|
|
742
|
+
]
|
|
743
|
+
pool = preferred or candidates
|
|
744
|
+
|
|
745
|
+
def _rank(cyc):
|
|
746
|
+
distance = min(
|
|
747
|
+
abs((member.reset - target).total_seconds())
|
|
748
|
+
for member in cyc.cluster_members
|
|
749
|
+
)
|
|
750
|
+
current_rank = (
|
|
751
|
+
float("inf") if cyc.current_percent is None else -cyc.current_percent
|
|
752
|
+
)
|
|
753
|
+
return (
|
|
754
|
+
distance,
|
|
755
|
+
current_rank,
|
|
756
|
+
cyc.reset,
|
|
757
|
+
cyc.identity_parts,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
return min(pool, key=_rank)
|
|
696
761
|
|
|
697
762
|
|
|
698
763
|
def _codex_milestone_count(stats_conn, cyc) -> tuple[int, str | None]:
|
|
@@ -702,29 +767,47 @@ def _codex_milestone_count(stats_conn, cyc) -> tuple[int, str | None]:
|
|
|
702
767
|
row = stats_conn.execute(
|
|
703
768
|
"SELECT COUNT(DISTINCT percent_threshold), MAX(captured_at_utc) "
|
|
704
769
|
"FROM quota_percent_milestones "
|
|
705
|
-
"WHERE source='codex' AND source_root_key=? AND
|
|
770
|
+
"WHERE source='codex' AND source_root_key=? AND account_key=? "
|
|
771
|
+
" AND logical_limit_key=? "
|
|
706
772
|
" AND observed_slot=? AND window_minutes=? "
|
|
707
773
|
f" AND unixepoch(resets_at_utc) IN ({placeholders}) AND orphaned_at IS NULL",
|
|
708
|
-
(cyc.root, cyc.limit, cyc.slot, cyc.window, *resets),
|
|
774
|
+
(cyc.root, cyc.account_key, cyc.limit, cyc.slot, cyc.window, *resets),
|
|
709
775
|
).fetchone()
|
|
710
776
|
return int(row[0] or 0), row[1]
|
|
711
777
|
|
|
712
778
|
|
|
713
779
|
def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
|
|
714
|
-
"""Every retained 5h block on the selected root intersecting [start, end).
|
|
780
|
+
"""Every retained 5h block on the selected root intersecting [start, end).
|
|
781
|
+
|
|
782
|
+
The account filter admits the cycle's own key OR the ``unattributed``
|
|
783
|
+
sentinel, and deliberately NOT strict equality. Unlike
|
|
784
|
+
``_codex_milestone_count`` — whose block and milestone parameters both come
|
|
785
|
+
from one ``block.identity`` and so cannot disagree — this query spans
|
|
786
|
+
identities, selecting by root plus time range. The 300-minute windows are a
|
|
787
|
+
separate physical-window group from the weekly one and
|
|
788
|
+
``adopt_unidentified_observations`` resolves attribution PER GROUP, so a
|
|
789
|
+
decorated install can legitimately carry the weekly window under a real
|
|
790
|
+
account while its 5h windows are still unattributed. Strict equality would
|
|
791
|
+
then match nothing and the cycle would render 0 blocks (#373).
|
|
792
|
+
|
|
793
|
+
The widening is one-directional on purpose: an unattributed CYCLE never
|
|
794
|
+
picks up another account's identified 5h rows, which would violate #341's
|
|
795
|
+
never-combine rule.
|
|
796
|
+
"""
|
|
715
797
|
orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
|
|
716
798
|
return stats_conn.execute(
|
|
717
799
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
718
|
-
" window_minutes, limit_id, limit_name,
|
|
719
|
-
" nominal_start_at_utc, current_percent "
|
|
800
|
+
" window_minutes, limit_id, limit_name, account_key, "
|
|
801
|
+
" resets_at_utc, nominal_start_at_utc, current_percent "
|
|
720
802
|
"FROM quota_window_blocks "
|
|
721
803
|
"WHERE source='codex' AND window_minutes=300 "
|
|
722
804
|
f"{orphan_clause}"
|
|
723
|
-
"AND source_root_key=? "
|
|
805
|
+
"AND source_root_key=? AND account_key IN (?,?) "
|
|
724
806
|
"AND unixepoch(nominal_start_at_utc) < unixepoch(?) "
|
|
725
807
|
"AND unixepoch(resets_at_utc) > unixepoch(?) "
|
|
726
808
|
"ORDER BY unixepoch(nominal_start_at_utc) ASC",
|
|
727
|
-
(cyc.root, cyc.
|
|
809
|
+
(cyc.root, cyc.account_key, UNATTRIBUTED,
|
|
810
|
+
cyc.end.astimezone(UTC).isoformat(),
|
|
728
811
|
cyc.start.astimezone(UTC).isoformat()),
|
|
729
812
|
).fetchall()
|
|
730
813
|
|
|
@@ -755,7 +838,7 @@ def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False) -> lis
|
|
|
755
838
|
return clusters
|
|
756
839
|
|
|
757
840
|
|
|
758
|
-
def _codex_cycle_entry(stats_conn, cyc,
|
|
841
|
+
def _codex_cycle_entry(stats_conn, cyc, tz) -> dict:
|
|
759
842
|
milestone_count, max_captured = _codex_milestone_count(stats_conn, cyc)
|
|
760
843
|
block_count = len(_codex_five_hour_clusters(stats_conn, cyc))
|
|
761
844
|
key = cyc.key
|
|
@@ -765,7 +848,7 @@ def _codex_cycle_entry(stats_conn, cyc, identity, now_utc, tz) -> dict:
|
|
|
765
848
|
"end_at_utc": _iso_z(cyc.end),
|
|
766
849
|
"resets_at_utc": _iso_z(cyc.reset),
|
|
767
850
|
"label": _codex_cycle_label(cyc, tz),
|
|
768
|
-
"is_current":
|
|
851
|
+
"is_current": bool(cyc.is_current),
|
|
769
852
|
"milestone_count": milestone_count,
|
|
770
853
|
"block_count": block_count,
|
|
771
854
|
"detail_stamp": _mh.compute_detail_stamp(
|
|
@@ -793,8 +876,9 @@ def build_codex_cycle_index(stats_conn, *, identity, now_utc) -> list:
|
|
|
793
876
|
cycles = _load_codex_cycles(
|
|
794
877
|
stats_conn, getattr(identity, "source_root_keys", ()),
|
|
795
878
|
preferred_identity=getattr(identity, "quota_identity", None),
|
|
879
|
+
current_boundary=identity, now_utc=now,
|
|
796
880
|
)
|
|
797
|
-
return [_codex_cycle_entry(stats_conn, c,
|
|
881
|
+
return [_codex_cycle_entry(stats_conn, c, tz) for c in cycles]
|
|
798
882
|
|
|
799
883
|
|
|
800
884
|
def _shape_codex_milestone(row, *, key_parts, block_key) -> dict:
|
|
@@ -844,8 +928,8 @@ def _union_cluster_milestones(cluster, block_key, speed, cache_conn, stats_conn)
|
|
|
844
928
|
ident = QuotaWindowIdentity(
|
|
845
929
|
source="codex", source_root_key=member.root,
|
|
846
930
|
logical_limit_key=member.limit, observed_slot=member.slot,
|
|
847
|
-
window_minutes=member.window,
|
|
848
|
-
limit_name=member.limit_name,
|
|
931
|
+
window_minutes=member.window, account_key=member.account_key,
|
|
932
|
+
limit_id=member.limit_id, limit_name=member.limit_name,
|
|
849
933
|
)
|
|
850
934
|
breakdown = _codex_breakdown_rows(
|
|
851
935
|
ident, member.reset, speed, cache_conn, stats_conn
|
|
@@ -917,6 +1001,7 @@ def build_codex_cycle_detail(
|
|
|
917
1001
|
cycles = _load_codex_cycles(
|
|
918
1002
|
stats_conn, getattr(identity, "source_root_keys", ()),
|
|
919
1003
|
preferred_identity=getattr(identity, "quota_identity", None),
|
|
1004
|
+
current_boundary=identity, now_utc=now,
|
|
920
1005
|
)
|
|
921
1006
|
except sqlite3.Error:
|
|
922
1007
|
return (None, "projection_incoherent")
|
|
@@ -929,6 +1014,7 @@ def build_codex_cycle_detail(
|
|
|
929
1014
|
stats_conn, getattr(identity, "source_root_keys", ()),
|
|
930
1015
|
include_orphaned=True,
|
|
931
1016
|
preferred_identity=getattr(identity, "quota_identity", None),
|
|
1017
|
+
current_boundary=identity, now_utc=now,
|
|
932
1018
|
)
|
|
933
1019
|
except sqlite3.Error:
|
|
934
1020
|
return (None, "projection_incoherent")
|
|
@@ -945,7 +1031,7 @@ def build_codex_cycle_detail(
|
|
|
945
1031
|
if blocks is None:
|
|
946
1032
|
return (None, "projection_incoherent")
|
|
947
1033
|
|
|
948
|
-
entry = _codex_cycle_entry(stats_conn, match,
|
|
1034
|
+
entry = _codex_cycle_entry(stats_conn, match, tz)
|
|
949
1035
|
return {
|
|
950
1036
|
"source": "codex",
|
|
951
1037
|
"key": entry["key"],
|