cctally 1.100.0 → 1.102.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 +73 -0
- package/README.md +8 -2
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
|
@@ -982,6 +982,8 @@ def _source_safe_claude_project_detail(
|
|
|
982
982
|
"key": key,
|
|
983
983
|
"label": label,
|
|
984
984
|
"window_weeks": detail.get("window_weeks"),
|
|
985
|
+
"window_start_at": detail.get("window_start_at"),
|
|
986
|
+
"window_end_at": detail.get("window_end_at"),
|
|
985
987
|
"window_cost_usd": detail.get("window_cost_usd"),
|
|
986
988
|
"window_attributed_pct": detail.get("window_attributed_pct"),
|
|
987
989
|
"models": detail.get("models", []),
|
|
@@ -1330,6 +1332,7 @@ def _build_claude_source_detail(
|
|
|
1330
1332
|
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
1331
1333
|
_load_recorded_five_hour_windows(start_at - BLOCK_DURATION, end_at + BLOCK_DURATION)
|
|
1332
1334
|
)
|
|
1335
|
+
entry_membership: dict[int, list[UsageEntry]] = {}
|
|
1333
1336
|
blocks = _group_entries_into_blocks(
|
|
1334
1337
|
entries,
|
|
1335
1338
|
mode="auto",
|
|
@@ -1337,6 +1340,7 @@ def _build_claude_source_detail(
|
|
|
1337
1340
|
block_start_overrides=block_start_overrides,
|
|
1338
1341
|
canonical_intervals=canonical_intervals,
|
|
1339
1342
|
now=now_utc,
|
|
1343
|
+
_entry_membership=entry_membership,
|
|
1340
1344
|
)
|
|
1341
1345
|
target = next(
|
|
1342
1346
|
(block for block in blocks if not block.is_gap and block.start_time == start_at),
|
|
@@ -1344,9 +1348,7 @@ def _build_claude_source_detail(
|
|
|
1344
1348
|
)
|
|
1345
1349
|
if target is None:
|
|
1346
1350
|
raise SourceResourceNotFound()
|
|
1347
|
-
block_entries = [
|
|
1348
|
-
entry for entry in entries if target.start_time <= entry.timestamp < target.end_time
|
|
1349
|
-
]
|
|
1351
|
+
block_entries = entry_membership[id(target)]
|
|
1350
1352
|
return _source_safe_claude_block_detail(
|
|
1351
1353
|
_build_block_detail(target, block_entries), key=key,
|
|
1352
1354
|
)
|
|
@@ -1662,6 +1664,28 @@ def _make_sync_loop_collaborators(*, ref, hub) -> dict:
|
|
|
1662
1664
|
}
|
|
1663
1665
|
|
|
1664
1666
|
|
|
1667
|
+
@contextlib.contextmanager
|
|
1668
|
+
def _rebuilding_claim(mark_rebuilding, *, prepare=None):
|
|
1669
|
+
"""Bracket one owner-scoped rebuilding claim, including preparation.
|
|
1670
|
+
|
|
1671
|
+
``prepare`` may itself publish/claim (``_SnapshotRef.capture_batch`` does),
|
|
1672
|
+
so it belongs inside the same structural cleanup boundary as the explicit
|
|
1673
|
+
mark. This is the single guard used by the periodic loop and both
|
|
1674
|
+
synchronous HTTP rebuild routes; a future exception between claim and work
|
|
1675
|
+
therefore cannot strand ``rebuilding=true`` for the process lifetime.
|
|
1676
|
+
"""
|
|
1677
|
+
prepared = None
|
|
1678
|
+
try:
|
|
1679
|
+
if prepare is not None:
|
|
1680
|
+
prepared = prepare()
|
|
1681
|
+
if mark_rebuilding is not None:
|
|
1682
|
+
mark_rebuilding(True)
|
|
1683
|
+
yield prepared
|
|
1684
|
+
finally:
|
|
1685
|
+
if mark_rebuilding is not None:
|
|
1686
|
+
mark_rebuilding(False)
|
|
1687
|
+
|
|
1688
|
+
|
|
1665
1689
|
def _dashboard_sync_loop(
|
|
1666
1690
|
*,
|
|
1667
1691
|
stop,
|
|
@@ -1705,43 +1729,32 @@ def _dashboard_sync_loop(
|
|
|
1705
1729
|
settlement counters, which must not advance for a batch that never existed.
|
|
1706
1730
|
"""
|
|
1707
1731
|
while not stop.is_set():
|
|
1708
|
-
|
|
1732
|
+
prepare = None
|
|
1709
1733
|
if (pending_request is not None and capture_batch is not None
|
|
1710
1734
|
and pending_request()):
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
# Failure time is charged to the cooldown exactly like success
|
|
1735
|
-
# time, so a crash loop cannot busy-spin.
|
|
1736
|
-
work = monotonic() - t0
|
|
1737
|
-
if batch is not None and settle is not None:
|
|
1738
|
-
settle(batch[0], status, warnings)
|
|
1739
|
-
if mark_rebuilding is not None:
|
|
1740
|
-
# Every exit path, including an escaped exception: a flag left
|
|
1741
|
-
# set would pin the client's chip at `syncing…` for the life of
|
|
1742
|
-
# the process. `settle` has already cleared it on a requested
|
|
1743
|
-
# tick, so this publishes nothing extra there.
|
|
1744
|
-
mark_rebuilding(False)
|
|
1735
|
+
prepare = capture_batch
|
|
1736
|
+
# Claim publication stays BEFORE t0, preserving #313's measured-work
|
|
1737
|
+
# algebra, but the shared guard begins before capture_batch because that
|
|
1738
|
+
# preparation also claims this thread's owner id.
|
|
1739
|
+
with _rebuilding_claim(mark_rebuilding, prepare=prepare) as batch:
|
|
1740
|
+
t0 = monotonic()
|
|
1741
|
+
status, warnings = "ok", ()
|
|
1742
|
+
try:
|
|
1743
|
+
result = (run_iteration(batch=batch) if batch is not None
|
|
1744
|
+
else run_iteration())
|
|
1745
|
+
if isinstance(result, dict):
|
|
1746
|
+
warnings = tuple(result.get("warnings") or ())
|
|
1747
|
+
except Exception: # noqa: BLE001 — see below
|
|
1748
|
+
# An escaped exception must not kill the only drainer: an
|
|
1749
|
+
# accepted 202 would then never reach a terminal state.
|
|
1750
|
+
status = "failed"
|
|
1751
|
+
_log_sync_iteration_failure()
|
|
1752
|
+
finally:
|
|
1753
|
+
# Failure time is charged to the cooldown exactly like success
|
|
1754
|
+
# time, so a crash loop cannot busy-spin.
|
|
1755
|
+
work = monotonic() - t0
|
|
1756
|
+
if batch is not None and settle is not None:
|
|
1757
|
+
settle(batch[0], status, warnings)
|
|
1745
1758
|
|
|
1746
1759
|
deadline = _next_deadline(t0, interval, work)
|
|
1747
1760
|
floor = t0 + 2.0 * work
|
|
@@ -2017,6 +2030,10 @@ def _build_alert_payload_weekly(*args, **kwargs):
|
|
|
2017
2030
|
return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
|
|
2018
2031
|
|
|
2019
2032
|
|
|
2033
|
+
def synthetic_preview_week_start(*args, **kwargs):
|
|
2034
|
+
return sys.modules["cctally"].synthetic_preview_week_start(*args, **kwargs)
|
|
2035
|
+
|
|
2036
|
+
|
|
2020
2037
|
def _build_alert_payload_five_hour(*args, **kwargs):
|
|
2021
2038
|
return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
|
|
2022
2039
|
|
|
@@ -2385,42 +2402,10 @@ class _SnapshotRef:
|
|
|
2385
2402
|
# therefore remove only the clearing thread's own claim; `rebuilding`
|
|
2386
2403
|
# is then true exactly while at least one rebuilder holds one.
|
|
2387
2404
|
#
|
|
2388
|
-
# Owner scoping
|
|
2389
|
-
#
|
|
2390
|
-
# `
|
|
2391
|
-
#
|
|
2392
|
-
# `threading.get_ident()`, so a claim left behind by a thread that has
|
|
2393
|
-
# exited can be discarded by NO other thread, and `rebuilding` would stay
|
|
2394
|
-
# true — pinning every client's chip at `syncing…` for the life of the
|
|
2395
|
-
# process.
|
|
2396
|
-
#
|
|
2397
|
-
# No leak is reachable today, but the argument splits by CALLER, not by
|
|
2398
|
-
# add site. `mark_rebuilding` below is reached from both routes — the
|
|
2399
|
-
# sync loop through `_make_sync_loop_collaborators` and an HTTP handler
|
|
2400
|
-
# thread through `DashboardHTTPHandler.mark_rebuilding` — so reading one
|
|
2401
|
-
# add site answers for neither. Four callers add a claim.
|
|
2402
|
-
#
|
|
2403
|
-
# Two of the four are bracketed: `_handle_post_sync` and
|
|
2404
|
-
# `_handle_post_settings` each mark inside a `try` whose `finally`
|
|
2405
|
-
# clears, so nothing between the two can leak the claim.
|
|
2406
|
-
#
|
|
2407
|
-
# The other two are the sync loop's `capture_batch()` and its
|
|
2408
|
-
# `mark_rebuilding(True)`, and they are NOT bracketed. Both run before
|
|
2409
|
-
# `t0` and therefore before the `try:` whose `finally` clears them; the
|
|
2410
|
-
# loop's own comment at `mark_rebuilding(True)` gives the #313
|
|
2411
|
-
# duty-algebra reason for that one's placement. `_dashboard_sync_loop`'s
|
|
2412
|
-
# `while` has no outer handler, so a raise in that gap would kill the
|
|
2413
|
-
# drainer and leak the claim together. The gap is safe because nothing
|
|
2414
|
-
# in it raises: the `_restamp_locked()` each add performs is a
|
|
2415
|
-
# `dataclasses.replace` over `DataSnapshot`, a plain dataclass with no
|
|
2416
|
-
# `__post_init__`, no `init=False` field and no `InitVar`;
|
|
2417
|
-
# `SSEHub.publish` holds its own lock and swallows
|
|
2418
|
-
# `queue.Full`/`queue.Empty`; `ref.get()` is a lock-and-return; and what
|
|
2419
|
-
# remains is a clock read and two local assignments.
|
|
2420
|
-
#
|
|
2421
|
-
# A new `add` must therefore satisfy one of the two: a same-thread
|
|
2422
|
-
# `finally` that clears it, or a proven non-raising path to one. There
|
|
2423
|
-
# is no self-healing path behind either.
|
|
2405
|
+
# Owner scoping makes a leaked claim permanent: another thread cannot
|
|
2406
|
+
# discard this thread's id. All three rebuilding routes therefore use
|
|
2407
|
+
# `_rebuilding_claim`, whose boundary begins before any preparation
|
|
2408
|
+
# that can claim and ends after the terminal publish.
|
|
2424
2409
|
self._rebuilding_owners: set[int] = set()
|
|
2425
2410
|
self._snap = self._stamped_locked(initial)
|
|
2426
2411
|
|
|
@@ -3770,7 +3755,22 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3770
3755
|
recorded-windows-widening trick (loads reset windows from
|
|
3771
3756
|
``[start - BLOCK_DURATION, end + BLOCK_DURATION]`` so a recorded
|
|
3772
3757
|
reset just outside the visible window can still anchor blocks
|
|
3773
|
-
inside it) and the
|
|
3758
|
+
inside it) and the post-group block-overlap filter.
|
|
3759
|
+
|
|
3760
|
+
#620 S1 D8: the entry set is grouped into native blocks FIRST and the
|
|
3761
|
+
week filter is then applied to whole blocks, retaining every block whose
|
|
3762
|
+
interval overlaps ``[week_start_at, week_end_at)`` with its full native
|
|
3763
|
+
totals. It used to filter ENTRIES to the week before grouping, so a
|
|
3764
|
+
block straddling a week boundary was folded from only the part of itself
|
|
3765
|
+
that fell inside the week — while ``/api/block/<iso>`` fetches the
|
|
3766
|
+
block's own native window and applies no week clip, so the panel and its
|
|
3767
|
+
own drilldown reported different totals for the same ``start_at`` and
|
|
3768
|
+
the panel's was permanently short. Selecting a block that overlaps the
|
|
3769
|
+
week is the deliberate part of the contract and stays; clipping a
|
|
3770
|
+
selected block's contents was the defect.
|
|
3771
|
+
|
|
3772
|
+
The fetch already read one block duration on each side, so this needs no
|
|
3773
|
+
additional query.
|
|
3774
3774
|
|
|
3775
3775
|
Returning the full ``BlocksView`` (rows + totals) lets the sync
|
|
3776
3776
|
thread populate ``DataSnapshot.blocks_total_cost_usd`` /
|
|
@@ -3781,13 +3781,12 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3781
3781
|
fetch_start = week_start_at - BLOCK_DURATION
|
|
3782
3782
|
fetch_end = week_end_at + BLOCK_DURATION
|
|
3783
3783
|
entries = get_entries(fetch_start, fetch_end, skip_sync=skip_sync)
|
|
3784
|
-
entries = [e for e in entries if week_start_at <= e.timestamp < week_end_at]
|
|
3785
3784
|
|
|
3786
3785
|
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
3787
3786
|
_load_recorded_five_hour_windows(fetch_start, fetch_end)
|
|
3788
3787
|
)
|
|
3789
3788
|
c = _cctally()
|
|
3790
|
-
|
|
3789
|
+
view = c.build_blocks_view(
|
|
3791
3790
|
entries,
|
|
3792
3791
|
now_utc=now_utc,
|
|
3793
3792
|
recorded_windows=recorded_windows,
|
|
@@ -3798,6 +3797,60 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3798
3797
|
display_tz=display_tz,
|
|
3799
3798
|
mode="auto",
|
|
3800
3799
|
)
|
|
3800
|
+
return _blocks_view_overlapping_week(
|
|
3801
|
+
view, week_start_at=week_start_at, week_end_at=week_end_at,
|
|
3802
|
+
)
|
|
3803
|
+
|
|
3804
|
+
|
|
3805
|
+
def _blocks_view_overlapping_week(view, *, week_start_at, week_end_at):
|
|
3806
|
+
"""Retain only the blocks whose interval overlaps
|
|
3807
|
+
``[week_start_at, week_end_at)``, keeping each retained block's FULL
|
|
3808
|
+
native totals (#620 S1 D8).
|
|
3809
|
+
|
|
3810
|
+
Overlap is the standard half-open test ``start < week_end and end >
|
|
3811
|
+
week_start``: a block that merely touches a bound (its end exactly at
|
|
3812
|
+
``week_start_at``, or its start exactly at ``week_end_at``) shares no
|
|
3813
|
+
instant with the week and is not retained.
|
|
3814
|
+
|
|
3815
|
+
Totals are re-derived from the retained non-gap blocks so the React
|
|
3816
|
+
panel's ``footer total == sum(visible rows)`` invariant still holds, and
|
|
3817
|
+
``aggregated`` is filtered in lockstep so no consumer can read a block
|
|
3818
|
+
set that disagrees with ``rows``.
|
|
3819
|
+
"""
|
|
3820
|
+
kept_blocks = []
|
|
3821
|
+
total_cost = 0.0
|
|
3822
|
+
total_tokens = 0
|
|
3823
|
+
kept_starts = set()
|
|
3824
|
+
for b in view.aggregated:
|
|
3825
|
+
start = getattr(b, "start_time", None)
|
|
3826
|
+
end = getattr(b, "end_time", None)
|
|
3827
|
+
if start is None or end is None:
|
|
3828
|
+
# API-anchored views carry dicts, not Blocks. This adapter only
|
|
3829
|
+
# ever sees the heuristic path, but degrade by retaining rather
|
|
3830
|
+
# than silently dropping a shape we cannot classify.
|
|
3831
|
+
kept_blocks.append(b)
|
|
3832
|
+
continue
|
|
3833
|
+
if not (start < week_end_at and end > week_start_at):
|
|
3834
|
+
continue
|
|
3835
|
+
kept_blocks.append(b)
|
|
3836
|
+
if getattr(b, "is_gap", False):
|
|
3837
|
+
continue
|
|
3838
|
+
# Plain `+=` rather than `stable_sum`, deliberately: this mirrors
|
|
3839
|
+
# `build_blocks_view`'s own accumulation (`bin/_lib_view_models.py`),
|
|
3840
|
+
# and this function exists to publish the SAME totals that view
|
|
3841
|
+
# publishes. A different fold here could disagree with it in the last
|
|
3842
|
+
# ULP, which is the divergence the function was written to remove.
|
|
3843
|
+
total_cost += b.cost_usd
|
|
3844
|
+
total_tokens += b.total_tokens
|
|
3845
|
+
kept_starts.add(start.astimezone(dt.timezone.utc).isoformat())
|
|
3846
|
+
rows = tuple(r for r in view.rows if r.start_at in kept_starts)
|
|
3847
|
+
return dataclasses.replace(
|
|
3848
|
+
view,
|
|
3849
|
+
rows=rows,
|
|
3850
|
+
aggregated=tuple(kept_blocks),
|
|
3851
|
+
total_cost_usd=total_cost,
|
|
3852
|
+
total_tokens=total_tokens,
|
|
3853
|
+
)
|
|
3801
3854
|
|
|
3802
3855
|
|
|
3803
3856
|
def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
|
|
@@ -4152,6 +4205,158 @@ def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
|
|
|
4152
4205
|
)
|
|
4153
4206
|
|
|
4154
4207
|
|
|
4208
|
+
class _ProjectsWeekGrid:
|
|
4209
|
+
"""The ordered half-open subscription intervals the Projects panel
|
|
4210
|
+
attributes cost into (#620 S1 D1).
|
|
4211
|
+
|
|
4212
|
+
``_projects_week_start_monday_utc`` above is the fallback used when no
|
|
4213
|
+
snapshot anchor is available; this is what replaces it when one IS
|
|
4214
|
+
available. Intervals come from ``_compute_subscription_weeks``, the same
|
|
4215
|
+
kernel ``cmd_project`` buckets by, so the panel and the CLI describe one
|
|
4216
|
+
set of weeks rather than two.
|
|
4217
|
+
|
|
4218
|
+
Intervals are half-open ``[start, end)`` and are NOT assumed to be seven
|
|
4219
|
+
days long: Anthropic's reset day drifts, and a drifted cycle produces a
|
|
4220
|
+
genuinely short week. ``end_for`` therefore returns the interval's own
|
|
4221
|
+
end, never ``start + 7d``.
|
|
4222
|
+
"""
|
|
4223
|
+
|
|
4224
|
+
__slots__ = ("starts", "ends", "_end_by_start", "_start_by_date")
|
|
4225
|
+
|
|
4226
|
+
def __init__(self, bounds: "list[tuple[dt.datetime, dt.datetime]]"):
|
|
4227
|
+
ordered = sorted(bounds, key=lambda b: b[0])
|
|
4228
|
+
self.starts = [b[0] for b in ordered]
|
|
4229
|
+
self.ends = [b[1] for b in ordered]
|
|
4230
|
+
self._end_by_start = {s: e for s, e in ordered}
|
|
4231
|
+
# `weekly_usage_snapshots.week_start_date` is the date-only lookup
|
|
4232
|
+
# key a legacy row carries when it has no `week_start_at`. Later
|
|
4233
|
+
# intervals win a collision, matching the "last capture per week
|
|
4234
|
+
# wins" rule the percentage read already applies.
|
|
4235
|
+
self._start_by_date = {s.date(): s for s in self.starts}
|
|
4236
|
+
|
|
4237
|
+
def __bool__(self) -> bool:
|
|
4238
|
+
return bool(self.starts)
|
|
4239
|
+
|
|
4240
|
+
def week_for(self, ts: "dt.datetime") -> "dt.datetime | None":
|
|
4241
|
+
"""The start of the interval containing ``ts``, or None when ``ts``
|
|
4242
|
+
falls outside every interval.
|
|
4243
|
+
|
|
4244
|
+
First-match-wins on the reset-day-drift overlap the clamp can leave
|
|
4245
|
+
behind — the same walk-back `cmd_project._week_start_for` performs,
|
|
4246
|
+
so an entry near a drifted boundary lands in the same week on both
|
|
4247
|
+
surfaces.
|
|
4248
|
+
"""
|
|
4249
|
+
ts_utc = ts.astimezone(dt.timezone.utc)
|
|
4250
|
+
idx = bisect.bisect_right(self.starts, ts_utc) - 1
|
|
4251
|
+
if idx < 0:
|
|
4252
|
+
return None
|
|
4253
|
+
while idx > 0 and self.starts[idx - 1] <= ts_utc < self.ends[idx - 1]:
|
|
4254
|
+
idx -= 1
|
|
4255
|
+
if self.starts[idx] <= ts_utc < self.ends[idx]:
|
|
4256
|
+
return self.starts[idx]
|
|
4257
|
+
return None
|
|
4258
|
+
|
|
4259
|
+
def start_for_date(self, day: "dt.date") -> "dt.datetime | None":
|
|
4260
|
+
"""The interval start whose own date is ``day``, for a legacy
|
|
4261
|
+
snapshot row that carries ``week_start_date`` but no
|
|
4262
|
+
``week_start_at``."""
|
|
4263
|
+
return self._start_by_date.get(day)
|
|
4264
|
+
|
|
4265
|
+
def end_for(self, start: "dt.datetime") -> "dt.datetime":
|
|
4266
|
+
"""The interval's real end. Falls back to ``start + 7d`` only for a
|
|
4267
|
+
start this grid does not know, which the padding below produces."""
|
|
4268
|
+
return self._end_by_start.get(start, start + dt.timedelta(days=7))
|
|
4269
|
+
|
|
4270
|
+
def window_ending_at(
|
|
4271
|
+
self, cw_start: "dt.datetime", weeks_back: int,
|
|
4272
|
+
) -> "list[tuple[dt.datetime, dt.datetime]]":
|
|
4273
|
+
"""The last ``weeks_back`` intervals up to and including the one
|
|
4274
|
+
starting at ``cw_start``, oldest first.
|
|
4275
|
+
|
|
4276
|
+
When the grid holds fewer than ``weeks_back`` intervals at or before
|
|
4277
|
+
``cw_start``, the head is padded backwards in seven-day steps. The
|
|
4278
|
+
padding is a genuine no-anchor tail — history older than any snapshot
|
|
4279
|
+
— so the seven-day assumption is the right one there.
|
|
4280
|
+
|
|
4281
|
+
The walk itself lives in ``_lib_subscription_weeks`` because
|
|
4282
|
+
``cmd_project`` performs the same one; a copy here is how the two
|
|
4283
|
+
surfaces drifted apart in the first place.
|
|
4284
|
+
"""
|
|
4285
|
+
return _cctally().subscription_window_ending_at(
|
|
4286
|
+
list(zip(self.starts, self.ends)), cw_start, weeks_back,
|
|
4287
|
+
)
|
|
4288
|
+
|
|
4289
|
+
|
|
4290
|
+
def _projects_week_grid(
|
|
4291
|
+
conn: "sqlite3.Connection",
|
|
4292
|
+
*,
|
|
4293
|
+
anchor_utc: "dt.datetime",
|
|
4294
|
+
weeks_back: int,
|
|
4295
|
+
account_key: "str | None" = None,
|
|
4296
|
+
) -> "_ProjectsWeekGrid | None":
|
|
4297
|
+
"""Build the panel's subscription-week grid, or None when no snapshot
|
|
4298
|
+
row carries an anchor.
|
|
4299
|
+
|
|
4300
|
+
Returning None is the deliberate no-anchor path: the caller then keeps
|
|
4301
|
+
``_projects_week_start_monday_utc`` throughout, which is what that
|
|
4302
|
+
function was written for and what every anchorless fixture already
|
|
4303
|
+
exercises byte-identically.
|
|
4304
|
+
|
|
4305
|
+
Cost: one grouped read of ``weekly_usage_snapshots`` plus the reset-event
|
|
4306
|
+
join `_compute_subscription_weeks` already performs. It adds no walk over
|
|
4307
|
+
``session_entries`` and nothing per entry, so this does not re-open the
|
|
4308
|
+
per-tick rescan #583 owns.
|
|
4309
|
+
"""
|
|
4310
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
4311
|
+
acct_params: tuple = () if account_key is None else (account_key,)
|
|
4312
|
+
try:
|
|
4313
|
+
row = conn.execute(
|
|
4314
|
+
"SELECT COUNT(*) FROM weekly_usage_snapshots "
|
|
4315
|
+
"WHERE week_start_at IS NOT NULL "
|
|
4316
|
+
" AND week_end_at IS NOT NULL "
|
|
4317
|
+
" AND week_start_date IS NOT NULL"
|
|
4318
|
+
f"{acct_pred}",
|
|
4319
|
+
acct_params,
|
|
4320
|
+
).fetchone()
|
|
4321
|
+
except sqlite3.OperationalError:
|
|
4322
|
+
return None
|
|
4323
|
+
if not row or not row[0]:
|
|
4324
|
+
return None
|
|
4325
|
+
|
|
4326
|
+
# A generous provisional range, defined once in `_lib_subscription_weeks`
|
|
4327
|
+
# because `cmd_project` needs the identical range: the extrapolation
|
|
4328
|
+
# anchor `_compute_subscription_weeks` picks is relative to `range_start`,
|
|
4329
|
+
# so two callers asking the same question over different ranges can be
|
|
4330
|
+
# handed differently-phased intervals for the same history.
|
|
4331
|
+
range_start, range_end = _cctally().subscription_window_probe_range(
|
|
4332
|
+
anchor_utc, weeks_back,
|
|
4333
|
+
)
|
|
4334
|
+
try:
|
|
4335
|
+
subweeks = _cctally()._compute_subscription_weeks(
|
|
4336
|
+
conn, range_start, range_end, account_key=account_key,
|
|
4337
|
+
)
|
|
4338
|
+
except Exception:
|
|
4339
|
+
# A malformed anchor must not take the panel down; the Monday
|
|
4340
|
+
# fallback still renders a coherent (if approximate) window.
|
|
4341
|
+
return None
|
|
4342
|
+
bounds: "list[tuple[dt.datetime, dt.datetime]]" = []
|
|
4343
|
+
for sw in subweeks:
|
|
4344
|
+
try:
|
|
4345
|
+
s = parse_iso_datetime(
|
|
4346
|
+
sw.start_ts, "projects week.start_ts",
|
|
4347
|
+
).astimezone(dt.timezone.utc)
|
|
4348
|
+
e = parse_iso_datetime(
|
|
4349
|
+
sw.end_ts, "projects week.end_ts",
|
|
4350
|
+
).astimezone(dt.timezone.utc)
|
|
4351
|
+
except (TypeError, ValueError):
|
|
4352
|
+
continue
|
|
4353
|
+
if e > s:
|
|
4354
|
+
bounds.append((s, e))
|
|
4355
|
+
if not bounds:
|
|
4356
|
+
return None
|
|
4357
|
+
return _ProjectsWeekGrid(bounds)
|
|
4358
|
+
|
|
4359
|
+
|
|
4155
4360
|
def _projects_week_label(week_start: "dt.datetime") -> str:
|
|
4156
4361
|
"""Render a `wk Mon DD` label for the trend chart x-axis.
|
|
4157
4362
|
|
|
@@ -4200,9 +4405,25 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
|
|
|
4200
4405
|
downstream. An ``EXPLAIN QUERY PLAN`` regression asserts the mutation_seq
|
|
4201
4406
|
index seek (``tests/test_projects_envelope.py``).
|
|
4202
4407
|
"""
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4408
|
+
# The SQL bounds are an outward-widened CANDIDATE filter; the real
|
|
4409
|
+
# membership test is the Python one every caller applies
|
|
4410
|
+
# (`_fold_projects_entry`'s interval gate, `_week_for`, or
|
|
4411
|
+
# `_fetch_delta_rows`' own pre-filter). #620 S1: the lower bound is
|
|
4412
|
+
# widened by one second because ingestion stores
|
|
4413
|
+
# `timestamp.astimezone(utc).isoformat()`, which keeps a `+00:00`
|
|
4414
|
+
# offset, while this predicate spells its bound `Z` — and SQLite
|
|
4415
|
+
# compares the column lexically, where `+` (0x2B) sorts BELOW `Z`
|
|
4416
|
+
# (0x5A). An entry stored at exactly `since`, or in the first second
|
|
4417
|
+
# after it, therefore sorts below the bound and is dropped before any
|
|
4418
|
+
# Python gate runs. That was unreachable while a week always started at
|
|
4419
|
+
# Monday 00:00 UTC, which carries no entries; a real subscription week
|
|
4420
|
+
# starts at the reset instant, and an entry lands on it routinely. The
|
|
4421
|
+
# widening admits at most one extra second of candidates, which the
|
|
4422
|
+
# Python gate then rejects, so no caller's result changes except the one
|
|
4423
|
+
# that was silently losing the boundary entry.
|
|
4424
|
+
since_iso = (
|
|
4425
|
+
since.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
|
|
4426
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4206
4427
|
until_iso = until.astimezone(dt.timezone.utc).strftime(
|
|
4207
4428
|
"%Y-%m-%dT%H:%M:%SZ"
|
|
4208
4429
|
)
|
|
@@ -4393,6 +4614,25 @@ def _shared_range_row_to_usage_entry(row):
|
|
|
4393
4614
|
)
|
|
4394
4615
|
|
|
4395
4616
|
|
|
4617
|
+
def _shared_range_row_to_priced_usage_entry(row):
|
|
4618
|
+
"""Prepare one shared row's effective cost once for both range folds."""
|
|
4619
|
+
entry = _shared_range_row_to_usage_entry(row)
|
|
4620
|
+
if entry.model == "<synthetic>":
|
|
4621
|
+
return entry
|
|
4622
|
+
return _cctally().UsageEntry(
|
|
4623
|
+
timestamp=entry.timestamp,
|
|
4624
|
+
model=entry.model,
|
|
4625
|
+
usage=entry.usage,
|
|
4626
|
+
cost_usd=_calculate_entry_cost(
|
|
4627
|
+
entry.model,
|
|
4628
|
+
entry.usage,
|
|
4629
|
+
mode="auto",
|
|
4630
|
+
cost_usd=entry.cost_usd,
|
|
4631
|
+
),
|
|
4632
|
+
source_path=entry.source_path,
|
|
4633
|
+
)
|
|
4634
|
+
|
|
4635
|
+
|
|
4396
4636
|
def _fold_prepared_daily_entries(
|
|
4397
4637
|
accumulators, entries, *, display_tz=None, mode: str = "auto",
|
|
4398
4638
|
):
|
|
@@ -4504,6 +4744,10 @@ class _ProjWeekBucket(NamedTuple):
|
|
|
4504
4744
|
first_order: str
|
|
4505
4745
|
first_id: int
|
|
4506
4746
|
first_key: "Any"
|
|
4747
|
+
# Retained so selected-window totals can deduplicate a session that is
|
|
4748
|
+
# active in more than one subscription bucket (#634). The default keeps
|
|
4749
|
+
# older fixture constructors source-compatible.
|
|
4750
|
+
session_ids: "frozenset[str]" = frozenset()
|
|
4507
4751
|
|
|
4508
4752
|
|
|
4509
4753
|
def _fold_projects_entry(
|
|
@@ -4512,7 +4756,9 @@ def _fold_projects_entry(
|
|
|
4512
4756
|
*,
|
|
4513
4757
|
resolver_cache: dict,
|
|
4514
4758
|
week_start: "dt.datetime | None",
|
|
4759
|
+
week_end: "dt.datetime | None" = None,
|
|
4515
4760
|
prepared_daily_entries: "list | None" = None,
|
|
4761
|
+
priced_entry=None,
|
|
4516
4762
|
) -> "float | None":
|
|
4517
4763
|
"""Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
|
|
4518
4764
|
per-row body, #271 §20 Codex-P1a).
|
|
@@ -4524,6 +4770,15 @@ def _fold_projects_entry(
|
|
|
4524
4770
|
row is filtered out (``<synthetic>`` model, or its Monday-anchored week ≠
|
|
4525
4771
|
``week_start``) — the caller then skips ``week_total`` / ``tail`` advance.
|
|
4526
4772
|
|
|
4773
|
+
The membership gate is the half-open interval ``[week_start, week_end)``
|
|
4774
|
+
(#620 S1 D1). It was a ``_projects_week_start_monday_utc(ts) ==
|
|
4775
|
+
week_start`` equality, which is the SAME predicate whenever ``week_start``
|
|
4776
|
+
is a Monday 00:00 UTC and ``week_end`` is ``week_start + 7d`` — so every
|
|
4777
|
+
Monday-anchored caller is byte-unchanged — but the interval form also
|
|
4778
|
+
admits a real subscription week that neither starts on a Monday nor runs
|
|
4779
|
+
a full seven days. ``week_end`` defaults to ``week_start + 7d`` for a
|
|
4780
|
+
caller that has not been threaded through yet.
|
|
4781
|
+
|
|
4527
4782
|
``mut[bp]`` is the running mutable dict ``{"cost_usd": float,
|
|
4528
4783
|
"sessions": set, "first_seen": dt, "last_seen": dt, "first_order": ts_iso,
|
|
4529
4784
|
"first_id": int, "first_key": ProjectKey}``. The first row seen for a
|
|
@@ -4546,32 +4801,43 @@ def _fold_projects_entry(
|
|
|
4546
4801
|
if model == "<synthetic>":
|
|
4547
4802
|
return None
|
|
4548
4803
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
4549
|
-
if week_start is not None
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4804
|
+
if week_start is not None:
|
|
4805
|
+
w_end = (
|
|
4806
|
+
week_end if week_end is not None
|
|
4807
|
+
else week_start + dt.timedelta(days=7)
|
|
4808
|
+
)
|
|
4809
|
+
if not (week_start <= ts < w_end):
|
|
4810
|
+
return None
|
|
4811
|
+
if priced_entry is None:
|
|
4812
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
4813
|
+
input_tokens=input_tok,
|
|
4814
|
+
output_tokens=output_tok,
|
|
4815
|
+
cache_creation_tokens=cache_create,
|
|
4816
|
+
cache_read_tokens=cache_read,
|
|
4817
|
+
cache_1h_tokens=cache_1h,
|
|
4818
|
+
speed=speed,
|
|
4819
|
+
)
|
|
4820
|
+
entry_cost = _calculate_entry_cost(
|
|
4821
|
+
model,
|
|
4822
|
+
usage,
|
|
4823
|
+
mode="auto",
|
|
4824
|
+
cost_usd=cost_raw,
|
|
4825
|
+
)
|
|
4826
|
+
else:
|
|
4827
|
+
usage = priced_entry.usage
|
|
4828
|
+
entry_cost = priced_entry.cost_usd
|
|
4565
4829
|
if prepared_daily_entries is not None:
|
|
4566
4830
|
# #567: preserve the canonical daily entry and aggregator while
|
|
4567
4831
|
# handing off the effective cost this pass already computed.
|
|
4568
|
-
prepared_daily_entries.append(
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4832
|
+
prepared_daily_entries.append(
|
|
4833
|
+
priced_entry if priced_entry is not None else c.UsageEntry(
|
|
4834
|
+
timestamp=dt.datetime.fromisoformat(ts_iso),
|
|
4835
|
+
model=model,
|
|
4836
|
+
usage=usage,
|
|
4837
|
+
cost_usd=entry_cost,
|
|
4838
|
+
source_path=source_path,
|
|
4839
|
+
)
|
|
4840
|
+
)
|
|
4575
4841
|
pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
|
|
4576
4842
|
bp = pkey.bucket_path
|
|
4577
4843
|
a = mut.get(bp)
|
|
@@ -4600,6 +4866,7 @@ def _fold_projects_entry(
|
|
|
4600
4866
|
|
|
4601
4867
|
def fold_projects_over_range(
|
|
4602
4868
|
rows, *, resolver_cache=None, prepared_daily_entries=None,
|
|
4869
|
+
priced_entries=None,
|
|
4603
4870
|
) -> "dict[str, dict]":
|
|
4604
4871
|
"""Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
|
|
4605
4872
|
|
|
@@ -4622,13 +4889,17 @@ def fold_projects_over_range(
|
|
|
4622
4889
|
"""
|
|
4623
4890
|
mut: "dict[str, dict]" = {}
|
|
4624
4891
|
cache = {} if resolver_cache is None else resolver_cache
|
|
4625
|
-
|
|
4892
|
+
if priced_entries is not None and len(priced_entries) != len(rows):
|
|
4893
|
+
raise ValueError("priced shared-range entries do not match source rows")
|
|
4894
|
+
for index, row in enumerate(rows):
|
|
4626
4895
|
_fold_projects_entry(
|
|
4627
4896
|
mut,
|
|
4628
4897
|
row,
|
|
4629
4898
|
resolver_cache=cache,
|
|
4630
4899
|
week_start=None,
|
|
4631
4900
|
prepared_daily_entries=prepared_daily_entries,
|
|
4901
|
+
priced_entry=(
|
|
4902
|
+
priced_entries[index] if priced_entries is not None else None),
|
|
4632
4903
|
)
|
|
4633
4904
|
return mut
|
|
4634
4905
|
|
|
@@ -4900,25 +5171,35 @@ def _shared_range_cache_payload(
|
|
|
4900
5171
|
}
|
|
4901
5172
|
|
|
4902
5173
|
|
|
4903
|
-
|
|
5174
|
+
@dataclass(frozen=True)
|
|
5175
|
+
class ClaudeRangeAggregateCapture:
|
|
5176
|
+
"""Cache-owned inputs for one post-transaction Claude range fold."""
|
|
5177
|
+
|
|
5178
|
+
base: tuple
|
|
5179
|
+
max_entry_id: int
|
|
5180
|
+
entry_mutation_seq: int
|
|
5181
|
+
shared_end_exclusive: object
|
|
5182
|
+
prior: object
|
|
5183
|
+
delta_rows: tuple
|
|
5184
|
+
full_rows: tuple | None
|
|
5185
|
+
|
|
5186
|
+
|
|
5187
|
+
def capture_cached_claude_range_aggregates(
|
|
4904
5188
|
conn,
|
|
4905
5189
|
*,
|
|
4906
5190
|
shared_start,
|
|
4907
5191
|
shared_end_exclusive,
|
|
4908
|
-
now_utc,
|
|
4909
5192
|
display_tz,
|
|
4910
|
-
legacy_labels,
|
|
4911
5193
|
max_entry_id: "int | None" = None,
|
|
4912
5194
|
entry_mutation_seq: "int | None" = None,
|
|
4913
5195
|
generation: int = 0,
|
|
4914
5196
|
):
|
|
4915
|
-
"""
|
|
5197
|
+
"""Capture only cache-backed inputs for the #567 append accumulator.
|
|
4916
5198
|
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
the current legacy population is reapplied on every publication.
|
|
5199
|
+
This half may run under `_tui_build_source_bundle`'s pinned transaction. It
|
|
5200
|
+
performs no deepcopy, pricing, project fold, daily fold, payload assembly,
|
|
5201
|
+
or memo mutation. The returned rows are ordinary immutable SQLite tuples,
|
|
5202
|
+
so the caller can end the read transaction before consuming them.
|
|
4922
5203
|
"""
|
|
4923
5204
|
if max_entry_id is None or entry_mutation_seq is None:
|
|
4924
5205
|
observed_id, observed_seq = _shared_range_entry_signature(conn)
|
|
@@ -4935,7 +5216,8 @@ def build_cached_claude_range_aggregates(
|
|
|
4935
5216
|
generation=generation,
|
|
4936
5217
|
)
|
|
4937
5218
|
prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
|
|
4938
|
-
|
|
5219
|
+
delta_rows: tuple = ()
|
|
5220
|
+
full_rows: tuple | None = None
|
|
4939
5221
|
if isinstance(prior, dict) and prior.get("base") == base:
|
|
4940
5222
|
monotone = (
|
|
4941
5223
|
max_entry_id >= prior["max_entry_id"]
|
|
@@ -4951,9 +5233,6 @@ def build_cached_claude_range_aggregates(
|
|
|
4951
5233
|
)
|
|
4952
5234
|
)
|
|
4953
5235
|
if monotone and not old_row_changed:
|
|
4954
|
-
project_mut = copy.deepcopy(prior["project_mut"])
|
|
4955
|
-
daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
|
|
4956
|
-
resolver_cache = dict(prior["resolver_cache"])
|
|
4957
5236
|
delta_by_id = {}
|
|
4958
5237
|
for row in _shared_range_entries_after_id(
|
|
4959
5238
|
conn, prior["max_entry_id"],
|
|
@@ -4971,85 +5250,198 @@ def build_cached_claude_range_aggregates(
|
|
|
4971
5250
|
):
|
|
4972
5251
|
if row[0] <= prior["max_entry_id"]:
|
|
4973
5252
|
delta_by_id[row[0]] = row
|
|
4974
|
-
delta_rows = sorted(
|
|
5253
|
+
delta_rows = tuple(sorted(
|
|
4975
5254
|
delta_by_id.values(),
|
|
4976
5255
|
key=lambda row: (row[1], row[0]),
|
|
4977
|
-
)
|
|
5256
|
+
))
|
|
4978
5257
|
prior_tail = prior["tail"]
|
|
4979
|
-
if prior_tail is None or all(
|
|
5258
|
+
if not (prior_tail is None or all(
|
|
4980
5259
|
(row[1], row[0]) > prior_tail
|
|
4981
5260
|
for row in delta_rows
|
|
4982
5261
|
if row[2] != "<synthetic>"
|
|
4983
|
-
):
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
row for row in delta_rows if row[2] != "<synthetic>"
|
|
5001
|
-
]
|
|
5002
|
-
if real_delta:
|
|
5003
|
-
last = real_delta[-1]
|
|
5004
|
-
tail = (last[1], last[0])
|
|
5005
|
-
state = {
|
|
5006
|
-
"base": base,
|
|
5007
|
-
"max_entry_id": max_entry_id,
|
|
5008
|
-
"entry_mutation_seq": entry_mutation_seq,
|
|
5009
|
-
"end_exclusive": shared_end_exclusive,
|
|
5010
|
-
"tail": tail,
|
|
5011
|
-
"project_mut": project_mut,
|
|
5012
|
-
"daily_accumulators": daily_accumulators,
|
|
5013
|
-
"resolver_cache": resolver_cache,
|
|
5014
|
-
}
|
|
5015
|
-
if state is None:
|
|
5016
|
-
rows = tuple(iter_shared_range_entries(
|
|
5262
|
+
)):
|
|
5263
|
+
# An out-of-order append cannot be folded onto the accumulator.
|
|
5264
|
+
# Capture the cold carrier while the same snapshot is pinned;
|
|
5265
|
+
# discovering this after rollback would require a second read
|
|
5266
|
+
# generation.
|
|
5267
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5268
|
+
conn, start=shared_start,
|
|
5269
|
+
end_exclusive=shared_end_exclusive,
|
|
5270
|
+
))
|
|
5271
|
+
delta_rows = ()
|
|
5272
|
+
else:
|
|
5273
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5274
|
+
conn, start=shared_start,
|
|
5275
|
+
end_exclusive=shared_end_exclusive,
|
|
5276
|
+
))
|
|
5277
|
+
else:
|
|
5278
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5017
5279
|
conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
5018
5280
|
))
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5281
|
+
return ClaudeRangeAggregateCapture(
|
|
5282
|
+
base=base,
|
|
5283
|
+
max_entry_id=max_entry_id,
|
|
5284
|
+
entry_mutation_seq=entry_mutation_seq,
|
|
5285
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
5286
|
+
prior=prior,
|
|
5287
|
+
delta_rows=delta_rows,
|
|
5288
|
+
full_rows=full_rows,
|
|
5289
|
+
)
|
|
5290
|
+
|
|
5291
|
+
|
|
5292
|
+
def build_cached_claude_range_aggregates_from_capture(
|
|
5293
|
+
capture: ClaudeRangeAggregateCapture,
|
|
5294
|
+
*,
|
|
5295
|
+
now_utc,
|
|
5296
|
+
display_tz,
|
|
5297
|
+
legacy_labels,
|
|
5298
|
+
tolerate_leg_failures: bool = False,
|
|
5299
|
+
):
|
|
5300
|
+
"""Fold and publish one captured Claude range accumulator outside the pin.
|
|
5301
|
+
|
|
5302
|
+
``tolerate_leg_failures`` is the source-bundle path's typed degradation
|
|
5303
|
+
seam. Project identity resolution and the daily calendar are independent
|
|
5304
|
+
folds over the same captured rows, so a project-only fault must not discard
|
|
5305
|
+
a valid daily result. The one-shot compatibility wrapper keeps the legacy
|
|
5306
|
+
raise-on-any-fault contract by leaving it false.
|
|
5307
|
+
"""
|
|
5308
|
+
prior = capture.prior
|
|
5309
|
+
incremental = capture.full_rows is None and isinstance(prior, dict)
|
|
5310
|
+
rows = capture.delta_rows if incremental else (capture.full_rows or ())
|
|
5311
|
+
payload: dict[str, object] = {}
|
|
5312
|
+
outcomes = {
|
|
5313
|
+
"projects": {"state": "ok"},
|
|
5314
|
+
"daily": {"state": "ok"},
|
|
5315
|
+
}
|
|
5316
|
+
failures: dict[str, Exception] = {}
|
|
5317
|
+
project_mut = None
|
|
5318
|
+
resolver_cache = None
|
|
5319
|
+
daily_accumulators = None
|
|
5320
|
+
|
|
5321
|
+
try:
|
|
5322
|
+
prepared = tuple(
|
|
5323
|
+
_shared_range_row_to_priced_usage_entry(row) for row in rows)
|
|
5324
|
+
except Exception as exc:
|
|
5325
|
+
prepared = ()
|
|
5326
|
+
failures["projects"] = exc
|
|
5327
|
+
failures["daily"] = exc
|
|
5328
|
+
outcomes["projects"] = {
|
|
5329
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5330
|
+
outcomes["daily"] = {
|
|
5331
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5332
|
+
|
|
5333
|
+
try:
|
|
5334
|
+
if "projects" in failures:
|
|
5335
|
+
raise failures["projects"]
|
|
5336
|
+
if incremental:
|
|
5337
|
+
project_mut = copy.deepcopy(prior["project_mut"])
|
|
5338
|
+
resolver_cache = dict(prior["resolver_cache"])
|
|
5339
|
+
for row, priced_entry in zip(rows, prepared):
|
|
5340
|
+
_fold_projects_entry(
|
|
5341
|
+
project_mut,
|
|
5342
|
+
row,
|
|
5343
|
+
resolver_cache=resolver_cache,
|
|
5344
|
+
week_start=None,
|
|
5345
|
+
priced_entry=priced_entry,
|
|
5346
|
+
)
|
|
5347
|
+
else:
|
|
5348
|
+
resolver_cache = {}
|
|
5349
|
+
project_mut = fold_projects_over_range(
|
|
5350
|
+
rows,
|
|
5351
|
+
resolver_cache=resolver_cache,
|
|
5352
|
+
priced_entries=prepared,
|
|
5353
|
+
)
|
|
5354
|
+
payload["projects"] = _project_aggregate_rows_from_folded(
|
|
5355
|
+
project_mut, legacy_labels)
|
|
5356
|
+
except Exception as exc:
|
|
5357
|
+
failures["projects"] = exc
|
|
5358
|
+
outcomes["projects"] = {
|
|
5359
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5360
|
+
|
|
5361
|
+
try:
|
|
5362
|
+
if "daily" in failures:
|
|
5363
|
+
raise failures["daily"]
|
|
5364
|
+
daily_accumulators = (
|
|
5365
|
+
copy.deepcopy(prior["daily_accumulators"])
|
|
5366
|
+
if incremental else {}
|
|
5025
5367
|
)
|
|
5026
|
-
daily_accumulators = {}
|
|
5027
5368
|
_fold_prepared_daily_entries(
|
|
5028
|
-
daily_accumulators, prepared, display_tz=display_tz
|
|
5029
|
-
)
|
|
5369
|
+
daily_accumulators, prepared, display_tz=display_tz)
|
|
5370
|
+
daily_buckets = _finalize_daily_accumulators(daily_accumulators)
|
|
5371
|
+
daily_rows = _build_daily_aggregate_rows_from_buckets(
|
|
5372
|
+
daily_buckets, now_utc=now_utc, display_tz=display_tz)
|
|
5373
|
+
c = _cctally()
|
|
5374
|
+
payload["daily"] = [
|
|
5375
|
+
c.daily_panel_row_to_wire(row) for row in daily_rows]
|
|
5376
|
+
except Exception as exc:
|
|
5377
|
+
failures["daily"] = exc
|
|
5378
|
+
outcomes["daily"] = {
|
|
5379
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5380
|
+
|
|
5381
|
+
if incremental:
|
|
5382
|
+
tail = prior["tail"]
|
|
5383
|
+
real_delta = [
|
|
5384
|
+
row for row in capture.delta_rows if row[2] != "<synthetic>"
|
|
5385
|
+
]
|
|
5386
|
+
if real_delta:
|
|
5387
|
+
last = real_delta[-1]
|
|
5388
|
+
tail = (last[1], last[0])
|
|
5389
|
+
else:
|
|
5030
5390
|
real_rows = [row for row in rows if row[2] != "<synthetic>"]
|
|
5031
5391
|
tail = None
|
|
5032
5392
|
if real_rows:
|
|
5033
5393
|
last = real_rows[-1]
|
|
5034
5394
|
tail = (last[1], last[0])
|
|
5395
|
+
|
|
5396
|
+
if not failures:
|
|
5035
5397
|
state = {
|
|
5036
|
-
"base": base,
|
|
5037
|
-
"max_entry_id": max_entry_id,
|
|
5038
|
-
"entry_mutation_seq": entry_mutation_seq,
|
|
5039
|
-
"end_exclusive": shared_end_exclusive,
|
|
5398
|
+
"base": capture.base,
|
|
5399
|
+
"max_entry_id": capture.max_entry_id,
|
|
5400
|
+
"entry_mutation_seq": capture.entry_mutation_seq,
|
|
5401
|
+
"end_exclusive": capture.shared_end_exclusive,
|
|
5040
5402
|
"tail": tail,
|
|
5041
5403
|
"project_mut": project_mut,
|
|
5042
5404
|
"daily_accumulators": daily_accumulators,
|
|
5043
5405
|
"resolver_cache": resolver_cache,
|
|
5044
5406
|
}
|
|
5045
|
-
|
|
5046
|
-
state
|
|
5047
|
-
|
|
5407
|
+
_cctally()._load_sibling("_lib_snapshot_cache")._assert_owner()
|
|
5408
|
+
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
|
|
5409
|
+
|
|
5410
|
+
if failures and not tolerate_leg_failures:
|
|
5411
|
+
raise next(iter(failures.values()))
|
|
5412
|
+
if tolerate_leg_failures:
|
|
5413
|
+
return payload, outcomes
|
|
5414
|
+
return payload
|
|
5415
|
+
|
|
5416
|
+
|
|
5417
|
+
def build_cached_claude_range_aggregates(
|
|
5418
|
+
conn,
|
|
5419
|
+
*,
|
|
5420
|
+
shared_start,
|
|
5421
|
+
shared_end_exclusive,
|
|
5422
|
+
now_utc,
|
|
5423
|
+
display_tz,
|
|
5424
|
+
legacy_labels,
|
|
5425
|
+
max_entry_id: "int | None" = None,
|
|
5426
|
+
entry_mutation_seq: "int | None" = None,
|
|
5427
|
+
generation: int = 0,
|
|
5428
|
+
):
|
|
5429
|
+
"""One-shot compatibility wrapper for non-pinned focused callers."""
|
|
5430
|
+
capture = capture_cached_claude_range_aggregates(
|
|
5431
|
+
conn,
|
|
5432
|
+
shared_start=shared_start,
|
|
5433
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
5434
|
+
display_tz=display_tz,
|
|
5435
|
+
max_entry_id=max_entry_id,
|
|
5436
|
+
entry_mutation_seq=entry_mutation_seq,
|
|
5437
|
+
generation=generation,
|
|
5438
|
+
)
|
|
5439
|
+
return build_cached_claude_range_aggregates_from_capture(
|
|
5440
|
+
capture,
|
|
5048
5441
|
now_utc=now_utc,
|
|
5049
5442
|
display_tz=display_tz,
|
|
5443
|
+
legacy_labels=legacy_labels,
|
|
5050
5444
|
)
|
|
5051
|
-
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
|
|
5052
|
-
return payload
|
|
5053
5445
|
|
|
5054
5446
|
|
|
5055
5447
|
def _aggregate_projects_week_raw(
|
|
@@ -5080,7 +5472,8 @@ def _aggregate_projects_week_raw(
|
|
|
5080
5472
|
conn, since=week_start, until=week_end,
|
|
5081
5473
|
):
|
|
5082
5474
|
entry_cost = _fold_projects_entry(
|
|
5083
|
-
mut, row, resolver_cache=resolver_cache,
|
|
5475
|
+
mut, row, resolver_cache=resolver_cache,
|
|
5476
|
+
week_start=week_start, week_end=week_end,
|
|
5084
5477
|
)
|
|
5085
5478
|
if entry_cost is None:
|
|
5086
5479
|
continue
|
|
@@ -5104,6 +5497,7 @@ def _finalize_projects_mut(mut: dict) -> "dict[str, _ProjWeekBucket]":
|
|
|
5104
5497
|
first_order=a["first_order"],
|
|
5105
5498
|
first_id=a["first_id"],
|
|
5106
5499
|
first_key=a["first_key"],
|
|
5500
|
+
session_ids=frozenset(a["sessions"]),
|
|
5107
5501
|
)
|
|
5108
5502
|
for bp, a in mut.items()
|
|
5109
5503
|
}
|
|
@@ -5143,7 +5537,7 @@ def _aggregate_projects_week(
|
|
|
5143
5537
|
def _assemble_projects_via_cache(
|
|
5144
5538
|
conn: "sqlite3.Connection",
|
|
5145
5539
|
*,
|
|
5146
|
-
|
|
5540
|
+
week_bounds: "list[tuple[dt.datetime, dt.datetime]]",
|
|
5147
5541
|
cw_start: "dt.datetime",
|
|
5148
5542
|
cw_end: "dt.datetime",
|
|
5149
5543
|
cur_max_id: int,
|
|
@@ -5186,9 +5580,16 @@ def _assemble_projects_via_cache(
|
|
|
5186
5580
|
for bp, wb in week_buckets.items():
|
|
5187
5581
|
buckets[(bp, w)] = {
|
|
5188
5582
|
"cost_usd": wb.cost_usd,
|
|
5189
|
-
#
|
|
5190
|
-
#
|
|
5191
|
-
|
|
5583
|
+
# Window totals union these identities so a resumed session
|
|
5584
|
+
# crossing a reset is counted once. The range fallback serves
|
|
5585
|
+
# test/legacy constructors that predate ``session_ids``.
|
|
5586
|
+
"sessions": (
|
|
5587
|
+
wb.session_ids
|
|
5588
|
+
if wb.session_ids
|
|
5589
|
+
else frozenset(
|
|
5590
|
+
(w, index) for index in range(wb.sessions_count)
|
|
5591
|
+
)
|
|
5592
|
+
),
|
|
5192
5593
|
"first_seen": wb.first_seen,
|
|
5193
5594
|
"last_seen": wb.last_seen,
|
|
5194
5595
|
}
|
|
@@ -5218,33 +5619,47 @@ def _assemble_projects_via_cache(
|
|
|
5218
5619
|
if r[2] == "<synthetic>": # r[2] = model
|
|
5219
5620
|
continue
|
|
5220
5621
|
ts = parse_iso_datetime(r[1], "session_entries.timestamp_utc")
|
|
5221
|
-
if
|
|
5622
|
+
if not (cw_start <= ts < cw_end):
|
|
5222
5623
|
continue
|
|
5223
5624
|
out.append(r)
|
|
5224
5625
|
out.sort(key=lambda r: (r[1], r[0])) # (ts_iso, id)
|
|
5225
5626
|
return out
|
|
5226
5627
|
|
|
5227
|
-
|
|
5628
|
+
# The cache identity below is `(start, end)`, while the spec named
|
|
5629
|
+
# "account, exact start, exact end". The account axis is omitted
|
|
5630
|
+
# deliberately, not by oversight: this panel always folds merged
|
|
5631
|
+
# (`_projects_week_grid` is called here with `account_key=None`), so
|
|
5632
|
+
# every entry in this cache was produced by the one merged read and two
|
|
5633
|
+
# scopes cannot collide in it. Adding a constant third component would
|
|
5634
|
+
# be a key that never varies. If the panel ever gains an account scope,
|
|
5635
|
+
# the axis has to be added at the same time — an account-scoped fold
|
|
5636
|
+
# served from a merged slot is a wrong answer, not a stale one.
|
|
5637
|
+
for w, w_end in week_bounds:
|
|
5228
5638
|
if w == cw_start:
|
|
5229
5639
|
week_buckets, week_total = sc.accumulate_projects_current_week(
|
|
5230
|
-
|
|
5640
|
+
# #620 S1: the accumulator's identity is the INTERVAL. An
|
|
5641
|
+
# early reset that moves the current week's bounds must
|
|
5642
|
+
# cold-refold the slot rather than keep appending to a
|
|
5643
|
+
# running aggregate folded over the old window.
|
|
5644
|
+
week_key=sc.projects_env_week_key(cw_start, cw_end),
|
|
5231
5645
|
cur_max_id=cur_max_id,
|
|
5232
5646
|
cur_max_seq=cur_max_seq,
|
|
5233
5647
|
fetch_all_raw=_fetch_all_raw,
|
|
5234
5648
|
fetch_delta_rows=_fetch_delta_rows,
|
|
5235
5649
|
finalize=_finalize_projects_mut,
|
|
5236
5650
|
fold=lambda mut, row: _fold_projects_entry(
|
|
5237
|
-
mut, row, resolver_cache=resolver_cache,
|
|
5651
|
+
mut, row, resolver_cache=resolver_cache,
|
|
5652
|
+
week_start=cw_start, week_end=cw_end,
|
|
5238
5653
|
),
|
|
5239
5654
|
)
|
|
5240
5655
|
else:
|
|
5241
|
-
week_iso = sc.projects_env_week_key(w)
|
|
5656
|
+
week_iso = sc.projects_env_week_key(w, w_end)
|
|
5242
5657
|
hit = sc.projects_env_week_get(week_iso)
|
|
5243
5658
|
if hit is not None:
|
|
5244
5659
|
week_buckets, week_total = hit
|
|
5245
5660
|
else:
|
|
5246
5661
|
week_buckets, week_total = _aggregate_projects_week(
|
|
5247
|
-
conn, week_start=w, week_end=
|
|
5662
|
+
conn, week_start=w, week_end=w_end,
|
|
5248
5663
|
resolver_cache=resolver_cache,
|
|
5249
5664
|
)
|
|
5250
5665
|
sc.projects_env_week_put(week_iso, week_buckets, week_total)
|
|
@@ -5268,14 +5683,21 @@ def _build_projects_envelope(
|
|
|
5268
5683
|
shape from spec §5.2 (no per-model breakdowns, no first/last seen
|
|
5269
5684
|
per session, no per-row $/1%; just cost / attributed_pct / sessions).
|
|
5270
5685
|
|
|
5271
|
-
Week boundaries
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5686
|
+
Week boundaries are the real subscription intervals (#620 S1 D1):
|
|
5687
|
+
``_projects_week_grid`` derives them from ``_compute_subscription_weeks``,
|
|
5688
|
+
the same kernel ``cmd_project`` buckets by, so the two surfaces attribute
|
|
5689
|
+
the same projects over the same weeks. A ``weekly_usage_snapshots`` row
|
|
5690
|
+
is matched onto an interval by its ``week_start_at`` anchor, falling back
|
|
5691
|
+
to ``week_start_date`` for a legacy row that carries no anchor; a row
|
|
5692
|
+
that matches no interval contributes nothing, leaving ``attributed_pct``
|
|
5693
|
+
None rather than attributing over a mismatched population. Only a store
|
|
5694
|
+
with no anchored snapshot at all falls back to
|
|
5695
|
+
``_projects_week_start_monday_utc``, which is what that function was
|
|
5696
|
+
written for.
|
|
5697
|
+
|
|
5698
|
+
``current_week`` is passed through opaquely — if non-None and carrying a
|
|
5699
|
+
``.week_start_at`` UTC datetime, that instant selects which interval is
|
|
5700
|
+
the current week. None (the default) uses ``now_utc``.
|
|
5279
5701
|
|
|
5280
5702
|
Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
|
|
5281
5703
|
(R-PROJ5 invariant). Per-tick memoized on
|
|
@@ -5330,33 +5752,51 @@ def _build_projects_envelope(
|
|
|
5330
5752
|
return cached
|
|
5331
5753
|
|
|
5332
5754
|
# ---- Week-start anchor (current subscription week) ------------------
|
|
5333
|
-
#
|
|
5334
|
-
#
|
|
5335
|
-
#
|
|
5336
|
-
#
|
|
5337
|
-
#
|
|
5338
|
-
#
|
|
5339
|
-
#
|
|
5340
|
-
#
|
|
5341
|
-
#
|
|
5342
|
-
#
|
|
5343
|
-
#
|
|
5755
|
+
# #620 S1 D1. The panel buckets cost into the REAL subscription
|
|
5756
|
+
# intervals — the ones `_compute_subscription_weeks` derives from the
|
|
5757
|
+
# retained reset anchors, and the ones `cmd_project` already buckets by.
|
|
5758
|
+
# `_projects_week_start_monday_utc` remains what its own docstring says
|
|
5759
|
+
# it is: the fallback for when no anchor is available. It used to be
|
|
5760
|
+
# applied to the anchor itself, which discarded the very thing it was
|
|
5761
|
+
# written to defer to, so the cost window and the quota window described
|
|
5762
|
+
# different intervals for every account whose reset is not exactly
|
|
5763
|
+
# Monday midnight UTC — in practice almost all of them.
|
|
5764
|
+
#
|
|
5765
|
+
# ``TuiCurrentWeek.week_start_at`` after ``_apply_midweek_reset_override``
|
|
5766
|
+
# is the in-week reset instant rather than the week's start. It is used
|
|
5767
|
+
# here only to locate the containing interval, never as a bucket key, so
|
|
5768
|
+
# a shifted value resolves to the same week the entry walk uses and no
|
|
5769
|
+
# activity is stranded. Regression:
|
|
5770
|
+
# ``tests/fixtures/dashboard/reset-week/`` +
|
|
5344
5771
|
# ``test_current_week_rows_populated_after_midweek_reset``.
|
|
5345
|
-
if cw_key is not None
|
|
5346
|
-
|
|
5772
|
+
anchor_instant = cw_key if cw_key is not None else now_utc
|
|
5773
|
+
grid = _projects_week_grid(
|
|
5774
|
+
conn, anchor_utc=anchor_instant, weeks_back=weeks_back,
|
|
5775
|
+
)
|
|
5776
|
+
cw_start = grid.week_for(anchor_instant) if grid is not None else None
|
|
5777
|
+
if cw_start is None:
|
|
5778
|
+
# No anchor covers `now` — the genuine fallback path, byte-identical
|
|
5779
|
+
# to the pre-#620 behaviour for a store with no anchored snapshots.
|
|
5780
|
+
grid = None
|
|
5781
|
+
cw_start = _projects_week_start_monday_utc(anchor_instant)
|
|
5782
|
+
cw_end = cw_start + dt.timedelta(days=7)
|
|
5783
|
+
week_bounds = [
|
|
5784
|
+
(
|
|
5785
|
+
cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i)),
|
|
5786
|
+
cw_start - dt.timedelta(days=7 * (weeks_back - 2 - i)),
|
|
5787
|
+
)
|
|
5788
|
+
for i in range(weeks_back)
|
|
5789
|
+
]
|
|
5347
5790
|
else:
|
|
5348
|
-
|
|
5791
|
+
cw_end = grid.end_for(cw_start)
|
|
5792
|
+
week_bounds = grid.window_ending_at(cw_start, weeks_back)
|
|
5349
5793
|
|
|
5350
|
-
#
|
|
5351
|
-
# cw_start, oldest → newest, of length ``weeks_back``. Clamping to
|
|
5794
|
+
# Week starts, oldest → newest, of length ``weeks_back``. Clamping to
|
|
5352
5795
|
# actual history happens after the entry walk reveals what weeks
|
|
5353
5796
|
# have any activity.
|
|
5354
|
-
weeks_full = [
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
]
|
|
5358
|
-
cw_end = cw_start + dt.timedelta(days=7)
|
|
5359
|
-
since_dt = weeks_full[0]
|
|
5797
|
+
weeks_full = [b[0] for b in week_bounds]
|
|
5798
|
+
end_by_week = {s: e for s, e in week_bounds}
|
|
5799
|
+
since_dt = week_bounds[0][0]
|
|
5360
5800
|
until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
|
|
5361
5801
|
|
|
5362
5802
|
# ---- Bucket entries per (ProjectKey, week_start) --------------------
|
|
@@ -5371,7 +5811,7 @@ def _build_projects_envelope(
|
|
|
5371
5811
|
# HTTP-drill): the original single full-window walk, byte-unchanged.
|
|
5372
5812
|
if use_projects_env_cache:
|
|
5373
5813
|
buckets, total_cost_by_week, key_by_bucket = _assemble_projects_via_cache(
|
|
5374
|
-
conn,
|
|
5814
|
+
conn, week_bounds=week_bounds, cw_start=cw_start, cw_end=cw_end,
|
|
5375
5815
|
cur_max_id=max_id, cur_max_seq=entry_mutation_seq,
|
|
5376
5816
|
)
|
|
5377
5817
|
else:
|
|
@@ -5387,8 +5827,13 @@ def _build_projects_envelope(
|
|
|
5387
5827
|
key_by_bucket = {}
|
|
5388
5828
|
|
|
5389
5829
|
def _week_for(ts: dt.datetime) -> "dt.datetime | None":
|
|
5390
|
-
|
|
5391
|
-
|
|
5830
|
+
if grid is not None:
|
|
5831
|
+
wstart = grid.week_for(ts)
|
|
5832
|
+
if wstart is None:
|
|
5833
|
+
return None
|
|
5834
|
+
else:
|
|
5835
|
+
wstart = _projects_week_start_monday_utc(ts)
|
|
5836
|
+
if wstart not in end_by_week:
|
|
5392
5837
|
return None
|
|
5393
5838
|
return wstart
|
|
5394
5839
|
|
|
@@ -5478,34 +5923,78 @@ def _build_projects_envelope(
|
|
|
5478
5923
|
# (bin/cctally:1162-1168) and the doctor credited-week check
|
|
5479
5924
|
# (bin/cctally:8706-8714).
|
|
5480
5925
|
#
|
|
5481
|
-
#
|
|
5482
|
-
#
|
|
5483
|
-
#
|
|
5926
|
+
# Reduce to one latest NON-NULL row per candidate date in SQLite, then let
|
|
5927
|
+
# Python resolve those bounded rows onto the anchored interval grid. NULL
|
|
5928
|
+
# rows never erased the prior known value in the former ascending fold, so
|
|
5929
|
+
# they are excluded before ranking. The outer capture order preserves the
|
|
5930
|
+
# same final-overwrite behaviour if two legacy date keys resolve to one
|
|
5931
|
+
# interval. The date bounds keep historical status-line ticks out of the
|
|
5932
|
+
# scan, while the per-date ranking makes the rows crossing into Python
|
|
5933
|
+
# proportional to candidate boundary dates rather than tick count. Do not
|
|
5934
|
+
# LIMIT before Python resolves legacy date keys: an unresolvable key must
|
|
5935
|
+
# not evict a valid rendered-week row (#620 S1 A3).
|
|
5484
5936
|
weekly_pct_by_week: dict[dt.datetime, float] = {}
|
|
5485
5937
|
try:
|
|
5486
5938
|
cur = conn.execute(
|
|
5487
|
-
"
|
|
5488
|
-
"
|
|
5489
|
-
"
|
|
5939
|
+
"WITH ranked AS ("
|
|
5940
|
+
" SELECT week_start_date, week_start_at, weekly_percent,"
|
|
5941
|
+
" captured_at_utc, id,"
|
|
5942
|
+
" ROW_NUMBER() OVER ("
|
|
5943
|
+
" PARTITION BY week_start_date"
|
|
5944
|
+
" ORDER BY captured_at_utc DESC, id DESC"
|
|
5945
|
+
" ) AS latest_rank"
|
|
5946
|
+
" FROM weekly_usage_snapshots"
|
|
5947
|
+
" WHERE week_start_date >= ? AND week_start_date < ?"
|
|
5948
|
+
" AND date(week_start_date) IS NOT NULL"
|
|
5949
|
+
" AND weekly_percent IS NOT NULL"
|
|
5950
|
+
")"
|
|
5951
|
+
" SELECT week_start_date, week_start_at, weekly_percent"
|
|
5952
|
+
" FROM ranked WHERE latest_rank = 1"
|
|
5953
|
+
" ORDER BY captured_at_utc ASC, id ASC",
|
|
5954
|
+
(
|
|
5955
|
+
since_dt.date().isoformat(),
|
|
5956
|
+
cw_end.date().isoformat(),
|
|
5957
|
+
),
|
|
5490
5958
|
)
|
|
5491
5959
|
rows = cur.fetchall()
|
|
5492
5960
|
except sqlite3.OperationalError:
|
|
5493
5961
|
# No weekly_usage_snapshots table — leaves attributed_pct = None
|
|
5494
5962
|
# throughout (acceptable per spec §2.7).
|
|
5495
5963
|
rows = []
|
|
5496
|
-
for week_date_str, weekly_pct in rows:
|
|
5964
|
+
for week_date_str, week_start_at, weekly_pct in rows:
|
|
5497
5965
|
try:
|
|
5498
5966
|
wd = dt.date.fromisoformat(week_date_str)
|
|
5499
5967
|
except (TypeError, ValueError):
|
|
5500
5968
|
continue
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5969
|
+
wstart: "dt.datetime | None" = None
|
|
5970
|
+
if grid is not None:
|
|
5971
|
+
# #620 S1 D1. Resolve the row onto the SAME interval the entry
|
|
5972
|
+
# walk buckets into, so the numerator, the denominator and this
|
|
5973
|
+
# percentage are all taken from one half-open window before the
|
|
5974
|
+
# multiplication below.
|
|
5975
|
+
if week_start_at:
|
|
5976
|
+
try:
|
|
5977
|
+
anchor = parse_iso_datetime(
|
|
5978
|
+
week_start_at, "weekly_usage_snapshots.week_start_at",
|
|
5979
|
+
).astimezone(dt.timezone.utc)
|
|
5980
|
+
except (TypeError, ValueError):
|
|
5981
|
+
anchor = None
|
|
5982
|
+
if anchor is not None:
|
|
5983
|
+
wstart = grid.week_for(anchor)
|
|
5984
|
+
if wstart is None:
|
|
5985
|
+
# A legacy row carrying only the date-only boundary. One
|
|
5986
|
+
# shared interval still serves both the cost and the
|
|
5987
|
+
# percentage; taking them from different intervals is never
|
|
5988
|
+
# acceptable, so an unresolvable row contributes nothing and
|
|
5989
|
+
# `attributed_pct` stays None (#620 S1 A3).
|
|
5990
|
+
wstart = grid.start_for_date(wd)
|
|
5991
|
+
if wstart is None:
|
|
5992
|
+
continue
|
|
5993
|
+
else:
|
|
5994
|
+
# No anchor anywhere in the store: the genuine Monday fallback.
|
|
5995
|
+
wstart = _projects_week_start_monday_utc(dt.datetime.combine(
|
|
5996
|
+
wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
|
|
5997
|
+
))
|
|
5509
5998
|
if weekly_pct is not None:
|
|
5510
5999
|
weekly_pct_by_week[wstart] = float(weekly_pct)
|
|
5511
6000
|
|
|
@@ -5532,12 +6021,11 @@ def _build_projects_envelope(
|
|
|
5532
6021
|
if weeks_with_activity:
|
|
5533
6022
|
# Window = inclusive [oldest_active_week, cw_start]. Always emits
|
|
5534
6023
|
# cw_start (panel + trend share the same current_week column).
|
|
6024
|
+
# Keyed by the SAME rule as the current week (#620 S1 D1) — the
|
|
6025
|
+
# subscription intervals themselves, not a seven-day walk, because
|
|
6026
|
+
# a mixed keying inside one panel is the defect restated.
|
|
5535
6027
|
oldest = min(weeks_with_activity[0], cw_start)
|
|
5536
|
-
trend_weeks = []
|
|
5537
|
-
w = oldest
|
|
5538
|
-
while w <= cw_start:
|
|
5539
|
-
trend_weeks.append(w)
|
|
5540
|
-
w += dt.timedelta(days=7)
|
|
6028
|
+
trend_weeks = [w for w in weeks_full if oldest <= w <= cw_start]
|
|
5541
6029
|
else:
|
|
5542
6030
|
trend_weeks = [cw_start]
|
|
5543
6031
|
|
|
@@ -5613,6 +6101,19 @@ def _build_projects_envelope(
|
|
|
5613
6101
|
sessions_per_week.append(len(b["sessions"]))
|
|
5614
6102
|
first_seen_per_week.append(_iso_z(b["first_seen"]))
|
|
5615
6103
|
last_seen_per_week.append(_iso_z(b["last_seen"]))
|
|
6104
|
+
session_counts_by_window: dict[str, int] = {}
|
|
6105
|
+
for window_weeks in PROJECT_WINDOW_WEEKS_CHOICES:
|
|
6106
|
+
selected_weeks = trend_weeks[-window_weeks:]
|
|
6107
|
+
# Cached real buckets contribute string identities; the
|
|
6108
|
+
# source-compatible fallback contributes opaque tuple identities.
|
|
6109
|
+
window_sessions: set[object] = set()
|
|
6110
|
+
for selected_week in selected_weeks:
|
|
6111
|
+
selected_bucket = buckets.get((bp, selected_week))
|
|
6112
|
+
if selected_bucket is not None:
|
|
6113
|
+
window_sessions.update(selected_bucket["sessions"])
|
|
6114
|
+
session_counts_by_window[str(window_weeks)] = len(
|
|
6115
|
+
window_sessions,
|
|
6116
|
+
)
|
|
5616
6117
|
# Skip projects with zero total cost across the entire window
|
|
5617
6118
|
# (the bucket-loop only enters projects that have at least one
|
|
5618
6119
|
# entry, so this is mainly a safety check).
|
|
@@ -5624,6 +6125,7 @@ def _build_projects_envelope(
|
|
|
5624
6125
|
"weekly_cost": weekly_cost,
|
|
5625
6126
|
"weekly_pct": weekly_pct_arr,
|
|
5626
6127
|
"sessions_per_week": sessions_per_week,
|
|
6128
|
+
"session_counts_by_window": session_counts_by_window,
|
|
5627
6129
|
"first_seen_per_week": first_seen_per_week,
|
|
5628
6130
|
"last_seen_per_week": last_seen_per_week,
|
|
5629
6131
|
})
|
|
@@ -5729,14 +6231,87 @@ def _project_detail_for_window(
|
|
|
5729
6231
|
if bucket_path is None:
|
|
5730
6232
|
return None
|
|
5731
6233
|
|
|
5732
|
-
# ---- Window bounds
|
|
6234
|
+
# ---- Window bounds, from the envelope's own current-week anchor ----
|
|
6235
|
+
# That anchor is the account's real subscription week start; the
|
|
6236
|
+
# Monday-midnight snap is only the no-anchor fallback (#620).
|
|
5733
6237
|
cw_start = parse_iso_datetime(
|
|
5734
6238
|
env["current_week"]["week_start_at"],
|
|
5735
6239
|
"projects.current_week.week_start_at",
|
|
6240
|
+
).astimezone(dt.timezone.utc)
|
|
6241
|
+
|
|
6242
|
+
# The drill resolves the SAME interval union its panel resolved, by
|
|
6243
|
+
# rebuilding the panel's grid rather than stepping back in seven-day
|
|
6244
|
+
# multiples. Subscription reset shifts can leave gaps between buckets;
|
|
6245
|
+
# the outer start/end pair is only a candidate-query bound, while
|
|
6246
|
+
# ``window_intervals`` is the authoritative membership contract.
|
|
6247
|
+
# `_ProjectsWeekGrid` exists because a drifted reset day produces a
|
|
6248
|
+
# genuinely short week: on `non-monday-anchor` at `weeks_back=4` the grid
|
|
6249
|
+
# starts the window at 2026-03-27T09:00Z while a seven-day walk yields
|
|
6250
|
+
# 2026-03-26T09:00Z, and an early reset that shortens the current week
|
|
6251
|
+
# would likewise leave a `cw_start + 7d` end counting cost past the
|
|
6252
|
+
# week's real end. `window_start_at` / `window_end_at` publish these
|
|
6253
|
+
# bounds to the client as authoritative, so a divergence here renders a
|
|
6254
|
+
# window the panel never computed.
|
|
6255
|
+
#
|
|
6256
|
+
# The grid is rebuilt from the PANEL'S OWN anchor, not from `cw_start`.
|
|
6257
|
+
# `_projects_week_grid` derives its provisional range from an ISO-Monday
|
|
6258
|
+
# snap of whatever anchor it is handed, and `cw_start` is the interval
|
|
6259
|
+
# START while the panel anchors on `current_week.week_start_at` — which
|
|
6260
|
+
# after `_apply_midweek_reset_override` is the in-week reset instant and
|
|
6261
|
+
# can sit up to a week later. The two therefore snap to different Mondays
|
|
6262
|
+
# and `_compute_subscription_weeks` can pick a different extrapolation
|
|
6263
|
+
# anchor for each, so window equality would rest on a coincidence rather
|
|
6264
|
+
# than on the two surfaces asking the same question. `cw_start` remains
|
|
6265
|
+
# the anchor `window_ending_at` walks back from, because that walk needs
|
|
6266
|
+
# an interval start.
|
|
6267
|
+
panel_anchor = getattr(current_week, "week_start_at", None)
|
|
6268
|
+
if not isinstance(panel_anchor, dt.datetime):
|
|
6269
|
+
panel_anchor = now_utc
|
|
6270
|
+
detail_grid = _projects_week_grid(
|
|
6271
|
+
conn, anchor_utc=panel_anchor, weeks_back=weeks_back,
|
|
5736
6272
|
)
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
6273
|
+
detail_bounds = (
|
|
6274
|
+
detail_grid.window_ending_at(cw_start, weeks_back)
|
|
6275
|
+
if detail_grid is not None else []
|
|
6276
|
+
)
|
|
6277
|
+
if detail_bounds:
|
|
6278
|
+
since_dt = detail_bounds[0][0]
|
|
6279
|
+
until_dt = detail_bounds[-1][1]
|
|
6280
|
+
else:
|
|
6281
|
+
# No anchor covers this start — the same no-anchor tail the panel
|
|
6282
|
+
# falls back to, where the seven-day assumption is the right one.
|
|
6283
|
+
since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
|
|
6284
|
+
until_dt = cw_start + dt.timedelta(days=7)
|
|
6285
|
+
detail_bounds = [(since_dt, until_dt)]
|
|
6286
|
+
detail_bounds_utc = [
|
|
6287
|
+
(
|
|
6288
|
+
start.astimezone(dt.timezone.utc),
|
|
6289
|
+
end.astimezone(dt.timezone.utc),
|
|
6290
|
+
)
|
|
6291
|
+
for start, end in detail_bounds
|
|
6292
|
+
]
|
|
6293
|
+
window_intervals = [
|
|
6294
|
+
{"start_at": _iso_z(start), "end_at": _iso_z(end)}
|
|
6295
|
+
for start, end in detail_bounds_utc
|
|
6296
|
+
]
|
|
6297
|
+
since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
|
|
6298
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
6299
|
+
)
|
|
6300
|
+
until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
|
|
6301
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
6302
|
+
)
|
|
6303
|
+
# SQL candidate bound, NOT the published one. Ingestion stores
|
|
6304
|
+
# `timestamp_utc` as `…+00:00` while these bounds are spelled `…Z`, and
|
|
6305
|
+
# SQLite compares that column lexically with `+` (0x2B) below `Z` (0x5A).
|
|
6306
|
+
# So the lower bound drops an entry sitting exactly on it and the upper
|
|
6307
|
+
# bound admits one sitting exactly on it — an asymmetry in both
|
|
6308
|
+
# directions. Widening the lower bound by a second makes SQL an outward
|
|
6309
|
+
# candidate filter at both ends; the half-open membership test is then
|
|
6310
|
+
# enforced on the PARSED datetime in the entry loop, which is the only
|
|
6311
|
+
# place it can be stated honestly.
|
|
6312
|
+
since_sql_iso = (
|
|
6313
|
+
since_dt.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
|
|
6314
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
5740
6315
|
# ---- Build bucket → source_paths map for SQL-side scoping ----------
|
|
5741
6316
|
# Walk session_files (~8k rows) once instead of session_entries
|
|
5742
6317
|
# (~150k+ rows). _resolve_project_key gets called at most ~distinct-
|
|
@@ -5787,6 +6362,9 @@ def _project_detail_for_window(
|
|
|
5787
6362
|
"key": project_key,
|
|
5788
6363
|
"bucket_path": bucket_path,
|
|
5789
6364
|
"window_weeks": weeks_back,
|
|
6365
|
+
"window_start_at": since_iso,
|
|
6366
|
+
"window_end_at": until_iso,
|
|
6367
|
+
"window_intervals": window_intervals,
|
|
5790
6368
|
"window_cost_usd": 0.0,
|
|
5791
6369
|
"window_attributed_pct": None,
|
|
5792
6370
|
"models": [],
|
|
@@ -5810,13 +6388,6 @@ def _project_detail_for_window(
|
|
|
5810
6388
|
[(p,) for p in bucket_source_paths],
|
|
5811
6389
|
)
|
|
5812
6390
|
|
|
5813
|
-
since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
|
|
5814
|
-
"%Y-%m-%dT%H:%M:%SZ"
|
|
5815
|
-
)
|
|
5816
|
-
until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
|
|
5817
|
-
"%Y-%m-%dT%H:%M:%SZ"
|
|
5818
|
-
)
|
|
5819
|
-
|
|
5820
6391
|
# ---- Walk session_entries (project-scoped) once -------------------
|
|
5821
6392
|
# INNER JOIN to _drill_paths drops every row whose source_path
|
|
5822
6393
|
# doesn't belong to this bucket. The Python-side filter that
|
|
@@ -5832,7 +6403,7 @@ def _project_detail_for_window(
|
|
|
5832
6403
|
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
5833
6404
|
"WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
|
|
5834
6405
|
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
5835
|
-
(
|
|
6406
|
+
(since_sql_iso, until_iso),
|
|
5836
6407
|
)
|
|
5837
6408
|
|
|
5838
6409
|
# Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
|
|
@@ -5855,6 +6426,17 @@ def _project_detail_for_window(
|
|
|
5855
6426
|
# on _drill_paths already restricted the result set to entries
|
|
5856
6427
|
# whose source_path belongs to this bucket.
|
|
5857
6428
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
6429
|
+
# The half-open membership test. The SQL bounds above are a widened
|
|
6430
|
+
# candidate filter that admits a second on each side, because the
|
|
6431
|
+
# column's stored offset spelling and the bound's spelling do not
|
|
6432
|
+
# compare the way the interval means; this is where the interval is
|
|
6433
|
+
# actually decided.
|
|
6434
|
+
ts_utc = ts.astimezone(dt.timezone.utc)
|
|
6435
|
+
if not any(
|
|
6436
|
+
interval_start <= ts_utc < interval_end
|
|
6437
|
+
for interval_start, interval_end in detail_bounds_utc
|
|
6438
|
+
):
|
|
6439
|
+
continue
|
|
5858
6440
|
entry_cost = _calculate_entry_cost(
|
|
5859
6441
|
model,
|
|
5860
6442
|
claude_usage_dict( # #195 chokepoint
|
|
@@ -5977,6 +6559,9 @@ def _project_detail_for_window(
|
|
|
5977
6559
|
"key": project_key,
|
|
5978
6560
|
"bucket_path": bucket_path,
|
|
5979
6561
|
"window_weeks": weeks_back,
|
|
6562
|
+
"window_start_at": since_iso,
|
|
6563
|
+
"window_end_at": until_iso,
|
|
6564
|
+
"window_intervals": window_intervals,
|
|
5980
6565
|
"window_cost_usd": window_cost,
|
|
5981
6566
|
"window_attributed_pct": win_pct,
|
|
5982
6567
|
"models": models_out,
|
|
@@ -6191,6 +6776,36 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
|
6191
6776
|
return vals[0] if vals else default
|
|
6192
6777
|
|
|
6193
6778
|
|
|
6779
|
+
def _qs_flag(q: dict, key: str) -> bool:
|
|
6780
|
+
"""Parse a single query-string boolean.
|
|
6781
|
+
|
|
6782
|
+
A bare `?flag` and `?flag=1` are both true; `0`, `false` and `no` are
|
|
6783
|
+
false. Written out rather than spelled `bool(_qs_str(...))`, because that
|
|
6784
|
+
reads `?reveal_projects=0` as a REQUEST to reveal — the string "0" is
|
|
6785
|
+
truthy — which is the wrong direction for a privacy switch.
|
|
6786
|
+
"""
|
|
6787
|
+
raw = _qs_str(q, key, None)
|
|
6788
|
+
if raw is None:
|
|
6789
|
+
return False
|
|
6790
|
+
return raw.strip().lower() not in ("0", "false", "no", "off")
|
|
6791
|
+
|
|
6792
|
+
|
|
6793
|
+
class _DiagnosisSelectorError(Exception):
|
|
6794
|
+
"""A `/api/diagnosis` request whose selectors are wrong (#620 S2).
|
|
6795
|
+
|
|
6796
|
+
Distinct from `EstablishmentFailure`, whose codes are the closed
|
|
6797
|
+
report-establishment enum. A selector this route rejects — an unknown
|
|
6798
|
+
source, an account combined with `source=all` — never reached the point of
|
|
6799
|
+
establishing a report, so it carries its own code rather than borrowing one
|
|
6800
|
+
from that enum and claiming a stage it never got to.
|
|
6801
|
+
"""
|
|
6802
|
+
|
|
6803
|
+
def __init__(self, code: str, message: str) -> None:
|
|
6804
|
+
super().__init__(message)
|
|
6805
|
+
self.code = code
|
|
6806
|
+
self.message = message
|
|
6807
|
+
|
|
6808
|
+
|
|
6194
6809
|
# ── /api/debug/backend on-demand cache-state helpers (issue #276, Session A) ──
|
|
6195
6810
|
# All read-only, cheap, and privacy-safe: they leak ONLY row counts, signature
|
|
6196
6811
|
# legs (ints/tuples), pending-flag names, and the tool version — never prompt /
|
|
@@ -6471,6 +7086,11 @@ _GET_ROUTES = (
|
|
|
6471
7086
|
("exact", "/api/share/presets", "_handle_share_presets_get", None, False),
|
|
6472
7087
|
("exact", "/api/share/history", "_handle_share_history_get", None, False),
|
|
6473
7088
|
("exact", "/api/doctor", "_handle_get_doctor", None, False),
|
|
7089
|
+
# #620 S2 — the on-demand diagnosis. Exact, so it can neither shadow nor
|
|
7090
|
+
# be shadowed by the parameterized conversation routes below (dispatch
|
|
7091
|
+
# compares exact paths first, so the placement here is a convention).
|
|
7092
|
+
("exact", "/api/diagnosis", "_handle_get_diagnosis",
|
|
7093
|
+
("scope", "endpoint.diagnosis"), False),
|
|
6474
7094
|
("exact", "/api/debug/backend", "_handle_get_debug_backend", None, False),
|
|
6475
7095
|
("exact", "/api/conversations/facets", "_handle_get_conversations_facets",
|
|
6476
7096
|
("scope", "endpoint.conversations_facets"), False),
|
|
@@ -6948,32 +7568,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6948
7568
|
})
|
|
6949
7569
|
return
|
|
6950
7570
|
try:
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
self.log_error("/api/sync rebuild failed: %r", exc)
|
|
6967
|
-
self.send_error(500, "sync failed")
|
|
6968
|
-
return
|
|
7571
|
+
with _rebuilding_claim(cls.mark_rebuilding):
|
|
7572
|
+
warnings: list = []
|
|
7573
|
+
if do_refresh:
|
|
7574
|
+
if cls.no_sync:
|
|
7575
|
+
warnings.append({"code": "refresh_skipped_no_sync"})
|
|
7576
|
+
else:
|
|
7577
|
+
result = _refresh_usage_inproc()
|
|
7578
|
+
if result.status != "ok":
|
|
7579
|
+
warnings.append({"code": result.status})
|
|
7580
|
+
try:
|
|
7581
|
+
cls.run_sync_now_locked()
|
|
7582
|
+
except Exception as exc:
|
|
7583
|
+
self.log_error("/api/sync rebuild failed: %r", exc)
|
|
7584
|
+
self.send_error(500, "sync failed")
|
|
7585
|
+
return
|
|
6969
7586
|
finally:
|
|
6970
|
-
# Drops THIS thread's claim only, so the ordering against the lock
|
|
6971
|
-
# release is not what makes it safe: a concurrent rebuilder's claim
|
|
6972
|
-
# is a different set member and this call cannot touch it, whichever
|
|
6973
|
-
# side of the release it runs on. On the success path the rebuild's
|
|
6974
|
-
# terminal publish has already dropped this thread's claim and this
|
|
6975
|
-
# adds no frame; the exception path is what needs it.
|
|
6976
|
-
cls.mark_rebuilding(False)
|
|
6977
7587
|
sync_lock.release()
|
|
6978
7588
|
|
|
6979
7589
|
if warnings:
|
|
@@ -7992,12 +8602,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7992
8602
|
# outside the acquire because `run_sync_now` takes `sync_lock` itself;
|
|
7993
8603
|
# a wait for a rebuild already in flight is honestly in-flight too.
|
|
7994
8604
|
try:
|
|
7995
|
-
type(self).mark_rebuilding
|
|
7996
|
-
|
|
8605
|
+
with _rebuilding_claim(type(self).mark_rebuilding):
|
|
8606
|
+
type(self).run_sync_now()
|
|
7997
8607
|
except Exception as exc:
|
|
7998
8608
|
eprint(f"warning: settings broadcast failed: {exc!r}")
|
|
7999
|
-
finally:
|
|
8000
|
-
type(self).mark_rebuilding(False)
|
|
8001
8609
|
|
|
8002
8610
|
self._respond_json(200, out)
|
|
8003
8611
|
|
|
@@ -8098,10 +8706,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8098
8706
|
return
|
|
8099
8707
|
|
|
8100
8708
|
if axis == "weekly":
|
|
8709
|
+
# Mirrors the CLI `alerts test --axis weekly` branch: the preview
|
|
8710
|
+
# carries the reset INSTANT a real crossing carries, so it renders
|
|
8711
|
+
# the instant form rather than the day-granularity fallback.
|
|
8712
|
+
preview_week_start = synthetic_preview_week_start()
|
|
8101
8713
|
payload = _build_alert_payload_weekly(
|
|
8102
8714
|
threshold=threshold,
|
|
8103
8715
|
crossed_at_utc=now_utc_iso(),
|
|
8104
|
-
week_start_date=
|
|
8716
|
+
week_start_date=preview_week_start.date().isoformat(),
|
|
8717
|
+
week_start_at=preview_week_start.isoformat().replace(
|
|
8718
|
+
"+00:00", "Z"
|
|
8719
|
+
),
|
|
8105
8720
|
cumulative_cost_usd=1.23,
|
|
8106
8721
|
dollars_per_percent=0.01,
|
|
8107
8722
|
)
|
|
@@ -8409,6 +9024,250 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8409
9024
|
self.log_error("/api/doctor failed after commit: %r", exc)
|
|
8410
9025
|
self.close_connection = True
|
|
8411
9026
|
|
|
9027
|
+
# ── GET /api/diagnosis (#620 S2, spec §5) ───────────────────────────────
|
|
9028
|
+
#
|
|
9029
|
+
# The on-demand diagnosis. NOT an envelope key: an envelope key would pay
|
|
9030
|
+
# #607's client per-frame cost on every tick for a surface most ticks never
|
|
9031
|
+
# display, and would force a deliberate re-take of the byte-identity
|
|
9032
|
+
# baseline at bench/baselines/envelope-oracle.json.
|
|
9033
|
+
|
|
9034
|
+
def _send_diagnosis_json(self, status: int, body: dict) -> None:
|
|
9035
|
+
encoded = encode_dashboard_json_bytes(body, ensure_ascii=False)
|
|
9036
|
+
self.send_response(status)
|
|
9037
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
9038
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
9039
|
+
self.send_header("Cache-Control", "no-cache")
|
|
9040
|
+
self.end_headers()
|
|
9041
|
+
self.wfile.write(encoded)
|
|
9042
|
+
|
|
9043
|
+
def _diagnosis_selectors(self, query):
|
|
9044
|
+
"""Resolve one request's selectors into a `DiagnosisScope`.
|
|
9045
|
+
|
|
9046
|
+
Raises `_DiagnosisSelectorError` for anything the caller got wrong and
|
|
9047
|
+
`EstablishmentFailure` for anything that could not be established. Both
|
|
9048
|
+
are 400 here; only `store_unavailable` and `generation_incoherent` are
|
|
9049
|
+
503, because those two say the machine could not answer rather than
|
|
9050
|
+
that the request was wrong.
|
|
9051
|
+
"""
|
|
9052
|
+
c = _cctally()
|
|
9053
|
+
diagnosis = c._load_sibling("_cctally_diagnosis")
|
|
9054
|
+
sources = c._load_sibling("_cctally_diagnosis_sources")
|
|
9055
|
+
kernel = c._load_sibling("_lib_diagnosis")
|
|
9056
|
+
|
|
9057
|
+
source = _qs_str(query, "source", "claude") or "claude"
|
|
9058
|
+
if source not in ("claude", "codex", "all"):
|
|
9059
|
+
raise _DiagnosisSelectorError(
|
|
9060
|
+
"invalid_selector",
|
|
9061
|
+
f"source must be claude, codex or all, not {source!r}")
|
|
9062
|
+
account = _qs_str(query, "account", None)
|
|
9063
|
+
if account is not None and source == "all":
|
|
9064
|
+
# Account keys are provider-scoped, so one selector cannot address
|
|
9065
|
+
# both providers. The CLI exits 2 on the same condition.
|
|
9066
|
+
raise _DiagnosisSelectorError(
|
|
9067
|
+
"invalid_selector",
|
|
9068
|
+
"account cannot be combined with source=all "
|
|
9069
|
+
"(account keys are provider-scoped)")
|
|
9070
|
+
speed = _qs_str(query, "speed", None)
|
|
9071
|
+
if speed in ("auto", ""):
|
|
9072
|
+
speed = None
|
|
9073
|
+
if speed is not None and speed not in ("standard", "fast"):
|
|
9074
|
+
raise _DiagnosisSelectorError(
|
|
9075
|
+
"invalid_selector",
|
|
9076
|
+
f"speed must be auto, standard or fast, not {speed!r}")
|
|
9077
|
+
|
|
9078
|
+
raw_tz = _qs_str(query, "tz", None)
|
|
9079
|
+
config = _apply_display_tz_override(
|
|
9080
|
+
load_config(), type(self).display_tz_pref_override,
|
|
9081
|
+
)
|
|
9082
|
+
try:
|
|
9083
|
+
tz_name = diagnosis.resolve_tz_name(
|
|
9084
|
+
argparse.Namespace(tz=raw_tz), config,
|
|
9085
|
+
)
|
|
9086
|
+
except ValueError as exc:
|
|
9087
|
+
raise _DiagnosisSelectorError("invalid_selector", str(exc)) from exc
|
|
9088
|
+
|
|
9089
|
+
now_utc = _command_as_of()
|
|
9090
|
+
start_raw = _qs_str(query, "start_at", None)
|
|
9091
|
+
end_raw = _qs_str(query, "end_at", None)
|
|
9092
|
+
if start_raw or end_raw:
|
|
9093
|
+
# The alert-follow path carries INSTANTS, not calendar days: a
|
|
9094
|
+
# five-hour block start is not a date, so the window grammar's
|
|
9095
|
+
# date-only form cannot reproduce the window a warning fired
|
|
9096
|
+
# against. Explicit bounds are how that window reaches the route.
|
|
9097
|
+
if not (start_raw and end_raw):
|
|
9098
|
+
raise kernel.EstablishmentFailure(
|
|
9099
|
+
kernel.EstablishmentError.RANGE_UNRESOLVED.value,
|
|
9100
|
+
"start_at and end_at must be given together")
|
|
9101
|
+
start_at = parse_iso_datetime(start_raw, "start_at")
|
|
9102
|
+
end_at = parse_iso_datetime(end_raw, "end_at")
|
|
9103
|
+
start_at = (start_at.replace(tzinfo=dt.timezone.utc)
|
|
9104
|
+
if start_at.tzinfo is None
|
|
9105
|
+
else start_at.astimezone(dt.timezone.utc))
|
|
9106
|
+
end_at = (end_at.replace(tzinfo=dt.timezone.utc)
|
|
9107
|
+
if end_at.tzinfo is None
|
|
9108
|
+
else end_at.astimezone(dt.timezone.utc))
|
|
9109
|
+
label = ""
|
|
9110
|
+
else:
|
|
9111
|
+
token = _qs_str(query, "window", "this-week") or "this-week"
|
|
9112
|
+
diff_kernel = c._load_sibling("_lib_diff_kernel")
|
|
9113
|
+
try:
|
|
9114
|
+
parsed = diagnosis._resolve_window(
|
|
9115
|
+
argparse.Namespace(window=token), now_utc, tz_name,
|
|
9116
|
+
)
|
|
9117
|
+
except diff_kernel.NoAnchorError as exc:
|
|
9118
|
+
# A week token this machine holds no anchor for is an
|
|
9119
|
+
# unresolved RANGE. It is a `RuntimeError`, so without this it
|
|
9120
|
+
# reached the outer handler and was served as 500 — an
|
|
9121
|
+
# unexplained internal error for a request the server could
|
|
9122
|
+
# perfectly well describe.
|
|
9123
|
+
raise kernel.EstablishmentFailure(
|
|
9124
|
+
kernel.EstablishmentError.RANGE_UNRESOLVED.value,
|
|
9125
|
+
str(exc)) from exc
|
|
9126
|
+
start_at, end_at, label = (parsed.start_utc, parsed.end_utc,
|
|
9127
|
+
parsed.label)
|
|
9128
|
+
|
|
9129
|
+
account_key = None
|
|
9130
|
+
if account is not None:
|
|
9131
|
+
account_key = self._resolve_diagnosis_account(
|
|
9132
|
+
sources, account,
|
|
9133
|
+
source if source in ("claude", "codex") else "claude",
|
|
9134
|
+
)
|
|
9135
|
+
# DiagnosisScope's own __post_init__ raises `range_unresolved` for a
|
|
9136
|
+
# naive or inverted window, so an out-of-order pair is rejected by the
|
|
9137
|
+
# same rule the CLI applies rather than by a second one here.
|
|
9138
|
+
return sources.DiagnosisScope(
|
|
9139
|
+
source=source,
|
|
9140
|
+
account_key=account_key,
|
|
9141
|
+
window_start=start_at,
|
|
9142
|
+
window_end=end_at,
|
|
9143
|
+
effective_speed=speed,
|
|
9144
|
+
display_tz=tz_name,
|
|
9145
|
+
label=label,
|
|
9146
|
+
), now_utc, _qs_flag(query, "reveal_projects")
|
|
9147
|
+
|
|
9148
|
+
@staticmethod
|
|
9149
|
+
def _resolve_diagnosis_account(sources, ref: str, provider: str):
|
|
9150
|
+
"""Resolve `?account=` over the diagnosis's own read-only open path.
|
|
9151
|
+
|
|
9152
|
+
`resolve_account_filter` reaches stats.db through the ordinary opener,
|
|
9153
|
+
which migrates, repairs, imports and replays. This route reads and
|
|
9154
|
+
never writes, so it resolves the ref over `mode=ro`, exactly as
|
|
9155
|
+
`cmd_explain` does — and a ref that could not be resolved for ANY
|
|
9156
|
+
reason, including a machine with no registry at all, is an unresolved
|
|
9157
|
+
account rather than an unavailable store.
|
|
9158
|
+
"""
|
|
9159
|
+
import _lib_accounts as accounts
|
|
9160
|
+
kernel = _cctally()._load_sibling("_lib_diagnosis")
|
|
9161
|
+
try:
|
|
9162
|
+
conn = sources.open_read_only("stats")
|
|
9163
|
+
except kernel.EstablishmentFailure as exc:
|
|
9164
|
+
raise kernel.EstablishmentFailure(
|
|
9165
|
+
kernel.EstablishmentError.ACCOUNT_UNRESOLVED.value,
|
|
9166
|
+
f"account {ref!r} is ambiguous or unknown "
|
|
9167
|
+
f"(this machine holds no account registry)") from exc
|
|
9168
|
+
try:
|
|
9169
|
+
return accounts.resolve_account_ref(conn, ref, provider)
|
|
9170
|
+
except accounts.AccountRefError as exc:
|
|
9171
|
+
raise kernel.EstablishmentFailure(
|
|
9172
|
+
kernel.EstablishmentError.ACCOUNT_UNRESOLVED.value,
|
|
9173
|
+
f"account {ref!r} is ambiguous or unknown") from exc
|
|
9174
|
+
finally:
|
|
9175
|
+
conn.close()
|
|
9176
|
+
|
|
9177
|
+
def _handle_get_diagnosis(self) -> None:
|
|
9178
|
+
"""`GET /api/diagnosis` — the on-demand diagnosis (#620 S2, spec §5).
|
|
9179
|
+
|
|
9180
|
+
Read-only and mutating nothing: the whole read goes through
|
|
9181
|
+
`_cctally_diagnosis_sources`, whose only opener is a `mode=ro` connect
|
|
9182
|
+
that performs no schema work, no migration, no legacy import and no
|
|
9183
|
+
contract repair. It runs on the request thread, outside the snapshot
|
|
9184
|
+
build and outside its pinned cache transaction.
|
|
9185
|
+
|
|
9186
|
+
`_require_api_auth` applies automatically, before dispatch. No CSRF
|
|
9187
|
+
check: `_check_origin_csrf` is opt-in for the routes that mutate, and
|
|
9188
|
+
this one does not.
|
|
9189
|
+
|
|
9190
|
+
Status codes: 200 for any valid coherently-generated report, including
|
|
9191
|
+
a healthy one, an empty one and one whose fields are withheld; 400 for
|
|
9192
|
+
a malformed selector and for an unresolved range or account; 503 for
|
|
9193
|
+
`generation_incoherent`, for `store_unavailable`, and for a report the
|
|
9194
|
+
kernel's `unreadable_store_is_terminal` holds over — that last one
|
|
9195
|
+
publishes the withheld REPORT as the body, not an empty error, for the
|
|
9196
|
+
same reason the CLI still prints it while exiting 3: a person needs to
|
|
9197
|
+
read the typed cause. 500 for an unexpected invariant failure, which is
|
|
9198
|
+
never converted into a healthy 200.
|
|
9199
|
+
"""
|
|
9200
|
+
import urllib.parse as _urlparse
|
|
9201
|
+
|
|
9202
|
+
c = _cctally()
|
|
9203
|
+
kernel = c._load_sibling("_lib_diagnosis")
|
|
9204
|
+
diagnosis = c._load_sibling("_cctally_diagnosis")
|
|
9205
|
+
sources = c._load_sibling("_cctally_diagnosis_sources")
|
|
9206
|
+
query = _urlparse.parse_qs(_urlparse.urlparse(self.path).query)
|
|
9207
|
+
|
|
9208
|
+
# Preparation and commit are separate, as on every other JSON route:
|
|
9209
|
+
# before headers a failure can still become a JSON 500; after them the
|
|
9210
|
+
# only valid recovery is log + close.
|
|
9211
|
+
try:
|
|
9212
|
+
try:
|
|
9213
|
+
scope, now_utc, reveal = self._diagnosis_selectors(query)
|
|
9214
|
+
# Evaluated ONCE, before the plan is resolved, and threaded
|
|
9215
|
+
# into plan stage 1. A class denied there is settled: the
|
|
9216
|
+
# store it would have needed is never opened, probed or
|
|
9217
|
+
# digested, and the identifier this response publishes
|
|
9218
|
+
# describes what actually ran rather than what was intended.
|
|
9219
|
+
# The predicate itself is untouched (D-E) — this composes it,
|
|
9220
|
+
# it does not change it.
|
|
9221
|
+
report = sources.build_diagnosis(
|
|
9222
|
+
scope, measured_at=now_utc,
|
|
9223
|
+
transcripts_visible=self._transcripts_visible_to_request(),
|
|
9224
|
+
)
|
|
9225
|
+
except _DiagnosisSelectorError as exc:
|
|
9226
|
+
self._send_diagnosis_json(
|
|
9227
|
+
400, {"error": exc.message, "code": exc.code})
|
|
9228
|
+
return
|
|
9229
|
+
except ValueError as exc:
|
|
9230
|
+
# A malformed instant from `parse_iso_datetime`.
|
|
9231
|
+
self._send_diagnosis_json(400, {
|
|
9232
|
+
"error": str(exc),
|
|
9233
|
+
"code": kernel.EstablishmentError.RANGE_UNRESOLVED.value,
|
|
9234
|
+
})
|
|
9235
|
+
return
|
|
9236
|
+
except kernel.EstablishmentFailure as exc:
|
|
9237
|
+
# `store_unavailable` reaches this arm only from SELECTOR
|
|
9238
|
+
# resolution — the week anchor, which reads stats.db through
|
|
9239
|
+
# the ordinary opener. It can never arrive from
|
|
9240
|
+
# `build_diagnosis`, which converts every store failure it
|
|
9241
|
+
# meets into `_unavailable_provider_result` so the withheld
|
|
9242
|
+
# report is published rather than lost; nor from
|
|
9243
|
+
# `_resolve_diagnosis_account`, which re-codes an unopenable
|
|
9244
|
+
# stats.db as `account_unresolved`. Both conversions are
|
|
9245
|
+
# deliberate, so do not read the mapping as dead code.
|
|
9246
|
+
status = 503 if exc.code in (
|
|
9247
|
+
kernel.EstablishmentError.STORE_UNAVAILABLE.value,
|
|
9248
|
+
kernel.EstablishmentError.GENERATION_INCOHERENT.value,
|
|
9249
|
+
) else 400
|
|
9250
|
+
self._send_diagnosis_json(
|
|
9251
|
+
status, {"error": exc.message, "code": exc.code})
|
|
9252
|
+
return
|
|
9253
|
+
|
|
9254
|
+
scopes = {result.source: diagnosis._scope_for(sources, scope,
|
|
9255
|
+
result.source)
|
|
9256
|
+
for result in report.results}
|
|
9257
|
+
body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
|
|
9258
|
+
reveal_projects=reveal)
|
|
9259
|
+
status = 503 if kernel.unreadable_store_is_terminal(report) else 200
|
|
9260
|
+
except Exception as exc: # noqa: BLE001
|
|
9261
|
+
self.log_error("/api/diagnosis failed before commit: %r", exc)
|
|
9262
|
+
self._send_diagnosis_json(500, {"error": "internal error"})
|
|
9263
|
+
return
|
|
9264
|
+
|
|
9265
|
+
try:
|
|
9266
|
+
self._send_diagnosis_json(status, body)
|
|
9267
|
+
except Exception as exc: # noqa: BLE001
|
|
9268
|
+
self.log_error("/api/diagnosis failed after commit: %r", exc)
|
|
9269
|
+
self.close_connection = True
|
|
9270
|
+
|
|
8412
9271
|
def _handle_get_session_detail(self, path: str) -> None:
|
|
8413
9272
|
"""Return TuiSessionDetail JSON for the given session id (spec §3.2).
|
|
8414
9273
|
|
|
@@ -8727,12 +9586,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8727
9586
|
entries_in_window = list(get_entries(
|
|
8728
9587
|
start_at, end_at, skip_sync=self.no_sync,
|
|
8729
9588
|
))
|
|
9589
|
+
entry_membership: dict[int, list[UsageEntry]] = {}
|
|
8730
9590
|
blocks = _group_entries_into_blocks(
|
|
8731
9591
|
entries_in_window, mode="auto",
|
|
8732
9592
|
recorded_windows=recorded_windows,
|
|
8733
9593
|
block_start_overrides=block_start_overrides,
|
|
8734
9594
|
canonical_intervals=canonical_intervals,
|
|
8735
9595
|
now=now_utc,
|
|
9596
|
+
_entry_membership=entry_membership,
|
|
8736
9597
|
)
|
|
8737
9598
|
target = next(
|
|
8738
9599
|
(b for b in blocks
|
|
@@ -8743,10 +9604,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8743
9604
|
status = 404
|
|
8744
9605
|
body = encode_dashboard_json_bytes({"error": "block not found"})
|
|
8745
9606
|
else:
|
|
8746
|
-
block_entries = [
|
|
8747
|
-
e for e in entries_in_window
|
|
8748
|
-
if target.start_time <= e.timestamp < target.end_time
|
|
8749
|
-
]
|
|
9607
|
+
block_entries = entry_membership[id(target)]
|
|
8750
9608
|
# Resolve display tz once per request so the block detail's
|
|
8751
9609
|
# `label` matches the snapshot envelope's blocks panel.
|
|
8752
9610
|
# Shared resolver -- same warn-once semantics as
|