cctally 1.98.0 → 1.99.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 +23 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +375 -19
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1501 -118
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +508 -47
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +26 -1
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +236 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +12 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +0 -1
- package/dashboard/static/assets/index-CChXFhs_.js +0 -97
package/bin/_cctally_core.py
CHANGED
|
@@ -888,6 +888,58 @@ def note_stats_maintenance_released() -> None:
|
|
|
888
888
|
_STATS_MAINTENANCE_HELD.set(max(0, _STATS_MAINTENANCE_HELD.get() - 1))
|
|
889
889
|
|
|
890
890
|
|
|
891
|
+
# === #500 §8.1 ordered-partial-release apply lock set =====================
|
|
892
|
+
#
|
|
893
|
+
# `_cctally_rederive.codex_attribution_apply_locks` takes the two cache writer
|
|
894
|
+
# flocks INSIDE the stats maintenance and ingest locks, and the repository
|
|
895
|
+
# lock-order law requires every cache write to be committed and unlocked before
|
|
896
|
+
# the stats transaction opens. The ordered partial release is what satisfies
|
|
897
|
+
# that law; this counter is what MAKES it a law rather than a convention, by
|
|
898
|
+
# letting `_run_stats_ingest_once(locks_held=True)` refuse while they are still
|
|
899
|
+
# held instead of quietly running the stats transaction underneath them.
|
|
900
|
+
#
|
|
901
|
+
# A ContextVar like the two above, but NOT for their reason, and the difference
|
|
902
|
+
# is worth stating because copying their rationale here inverts it. Those two
|
|
903
|
+
# are AUTHORIZATION counters: a global would let one sanctioned context
|
|
904
|
+
# authorize an unsanctioned one, so per-context isolation is what fails safe.
|
|
905
|
+
# This one is a REFUSAL guard, and for a refusal per-context isolation fails
|
|
906
|
+
# OPEN — a context that cannot see the hold does not refuse.
|
|
907
|
+
#
|
|
908
|
+
# What the ContextVar buys is therefore narrower and still worth having: the
|
|
909
|
+
# guard reports the state of the context that is about to open the stats
|
|
910
|
+
# transaction, so it can never be silenced by an unrelated dashboard thread that
|
|
911
|
+
# happens to hold the cache flocks for its own read, and it can never be left
|
|
912
|
+
# armed by one. It is a guard against the apply sequence's own steps running out
|
|
913
|
+
# of order, not a process-wide interlock.
|
|
914
|
+
#
|
|
915
|
+
# The cost of that choice: the guard is ADVISORY across a context boundary. A
|
|
916
|
+
# bare `os.fork()` inherits the value by copy and a thread spawned inside the
|
|
917
|
+
# `with` block starts from a copy of the spawning context, so neither child sees
|
|
918
|
+
# a later release, and a thread created BEFORE the acquisition never sees the
|
|
919
|
+
# hold at all. `codex_attribution_apply_locks` therefore forbids both inside its
|
|
920
|
+
# block; the flocks themselves remain the real mutual exclusion.
|
|
921
|
+
|
|
922
|
+
_ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD = contextvars.ContextVar(
|
|
923
|
+
"cctally_attribution_apply_cache_flocks_held", default=0
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def holds_attribution_apply_cache_flocks() -> bool:
|
|
928
|
+
"""True while this context still holds the #500 apply set's cache flocks."""
|
|
929
|
+
return _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() > 0
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def note_attribution_apply_cache_flocks_acquired() -> None:
|
|
933
|
+
_ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.set(
|
|
934
|
+
_ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() + 1)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def note_attribution_apply_cache_flocks_released() -> None:
|
|
938
|
+
"""Clamped at zero, for the reason the maintenance twin above states."""
|
|
939
|
+
_ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.set(
|
|
940
|
+
max(0, _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() - 1))
|
|
941
|
+
|
|
942
|
+
|
|
891
943
|
# === Alerts validation cluster ======================================
|
|
892
944
|
|
|
893
945
|
|
|
@@ -262,6 +262,7 @@ from __future__ import annotations
|
|
|
262
262
|
|
|
263
263
|
import argparse
|
|
264
264
|
import bisect
|
|
265
|
+
import copy
|
|
265
266
|
import contextlib
|
|
266
267
|
import dataclasses
|
|
267
268
|
import datetime as dt
|
|
@@ -383,7 +384,14 @@ from _lib_display_tz import (
|
|
|
383
384
|
normalize_display_tz_value,
|
|
384
385
|
_compute_display_block,
|
|
385
386
|
)
|
|
386
|
-
from _lib_aggregators import
|
|
387
|
+
from _lib_aggregators import (
|
|
388
|
+
_aggregate_daily,
|
|
389
|
+
_aggregate_monthly,
|
|
390
|
+
_aggregate_weekly,
|
|
391
|
+
_new_bucket_acc,
|
|
392
|
+
_fold_entry,
|
|
393
|
+
_finalize_bucket,
|
|
394
|
+
)
|
|
387
395
|
from _lib_fmt import stable_sum
|
|
388
396
|
from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
|
|
389
397
|
_short_model_name, claude_usage_dict)
|
|
@@ -3554,18 +3562,43 @@ def _shared_range_row_to_usage_entry(row):
|
|
|
3554
3562
|
)
|
|
3555
3563
|
|
|
3556
3564
|
|
|
3557
|
-
def
|
|
3565
|
+
def _fold_prepared_daily_entries(
|
|
3566
|
+
accumulators, entries, *, display_tz=None, mode: str = "auto",
|
|
3567
|
+
):
|
|
3568
|
+
"""Append prepared entries through the canonical daily fold primitive."""
|
|
3569
|
+
for entry in entries:
|
|
3570
|
+
if entry.model == "<synthetic>":
|
|
3571
|
+
continue
|
|
3572
|
+
key = entry.timestamp.astimezone(display_tz).strftime("%Y-%m-%d")
|
|
3573
|
+
accumulator = accumulators.get(key)
|
|
3574
|
+
if accumulator is None:
|
|
3575
|
+
accumulator = _new_bucket_acc()
|
|
3576
|
+
accumulators[key] = accumulator
|
|
3577
|
+
_fold_entry(accumulator, entry, mode)
|
|
3578
|
+
|
|
3579
|
+
|
|
3580
|
+
def _finalize_daily_accumulators(accumulators):
|
|
3581
|
+
return [
|
|
3582
|
+
_finalize_bucket(key, accumulators[key])
|
|
3583
|
+
for key in sorted(accumulators)
|
|
3584
|
+
]
|
|
3585
|
+
|
|
3586
|
+
|
|
3587
|
+
def fold_daily_over_range(
|
|
3588
|
+
rows, *, display_tz=None, mode: str = "auto", prepared_entries=None,
|
|
3589
|
+
):
|
|
3558
3590
|
"""Fold the shared candidate stream into per-day ``BucketUsage``.
|
|
3559
3591
|
|
|
3560
3592
|
Consumes the SAME already-materialised sequence the projects fold reads
|
|
3561
3593
|
(spec §3.4 — one candidate read, two folds). ``_aggregate_daily`` skips
|
|
3562
3594
|
``<synthetic>`` rows itself, so both folds share that policy.
|
|
3563
3595
|
"""
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
mode
|
|
3567
|
-
|
|
3596
|
+
entries = (
|
|
3597
|
+
prepared_entries
|
|
3598
|
+
if prepared_entries is not None and mode == "auto"
|
|
3599
|
+
else [_shared_range_row_to_usage_entry(row) for row in rows]
|
|
3568
3600
|
)
|
|
3601
|
+
return _aggregate_daily(entries, mode=mode, tz=display_tz)
|
|
3569
3602
|
|
|
3570
3603
|
|
|
3571
3604
|
def build_daily_aggregate_rows(
|
|
@@ -3575,6 +3608,7 @@ def build_daily_aggregate_rows(
|
|
|
3575
3608
|
display_tz=None,
|
|
3576
3609
|
n: int = 30,
|
|
3577
3610
|
mode: str = "auto",
|
|
3611
|
+
prepared_entries=None,
|
|
3578
3612
|
) -> "list[DailyPanelRow]":
|
|
3579
3613
|
"""The complete canonical thirty-day shape for the All Daily aggregate.
|
|
3580
3614
|
|
|
@@ -3583,7 +3617,29 @@ def build_daily_aggregate_rows(
|
|
|
3583
3617
|
the legacy panel uses, then materializes the contiguous calendar — so an
|
|
3584
3618
|
empty Claude provider still publishes a full zero-cost shape (§6.3a).
|
|
3585
3619
|
"""
|
|
3586
|
-
buckets = fold_daily_over_range(
|
|
3620
|
+
buckets = fold_daily_over_range(
|
|
3621
|
+
rows,
|
|
3622
|
+
display_tz=display_tz,
|
|
3623
|
+
mode=mode,
|
|
3624
|
+
prepared_entries=prepared_entries,
|
|
3625
|
+
)
|
|
3626
|
+
return _build_daily_aggregate_rows_from_buckets(
|
|
3627
|
+
buckets,
|
|
3628
|
+
now_utc=now_utc,
|
|
3629
|
+
display_tz=display_tz,
|
|
3630
|
+
n=n,
|
|
3631
|
+
mode=mode,
|
|
3632
|
+
)
|
|
3633
|
+
|
|
3634
|
+
|
|
3635
|
+
def _build_daily_aggregate_rows_from_buckets(
|
|
3636
|
+
buckets,
|
|
3637
|
+
*,
|
|
3638
|
+
now_utc,
|
|
3639
|
+
display_tz=None,
|
|
3640
|
+
n: int = 30,
|
|
3641
|
+
mode: str = "auto",
|
|
3642
|
+
):
|
|
3587
3643
|
view = _cctally().build_daily_view(
|
|
3588
3644
|
(), now_utc=now_utc, display_tz=display_tz, mode=mode,
|
|
3589
3645
|
aggregated_override=buckets,
|
|
@@ -3625,6 +3681,7 @@ def _fold_projects_entry(
|
|
|
3625
3681
|
*,
|
|
3626
3682
|
resolver_cache: dict,
|
|
3627
3683
|
week_start: "dt.datetime | None",
|
|
3684
|
+
prepared_daily_entries: "list | None" = None,
|
|
3628
3685
|
) -> "float | None":
|
|
3629
3686
|
"""Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
|
|
3630
3687
|
per-row body, #271 §20 Codex-P1a).
|
|
@@ -3660,19 +3717,30 @@ def _fold_projects_entry(
|
|
|
3660
3717
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
3661
3718
|
if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
|
|
3662
3719
|
return None
|
|
3720
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
3721
|
+
input_tokens=input_tok,
|
|
3722
|
+
output_tokens=output_tok,
|
|
3723
|
+
cache_creation_tokens=cache_create,
|
|
3724
|
+
cache_read_tokens=cache_read,
|
|
3725
|
+
cache_1h_tokens=cache_1h,
|
|
3726
|
+
speed=speed,
|
|
3727
|
+
)
|
|
3663
3728
|
entry_cost = _calculate_entry_cost(
|
|
3664
3729
|
model,
|
|
3665
|
-
|
|
3666
|
-
input_tokens=input_tok,
|
|
3667
|
-
output_tokens=output_tok,
|
|
3668
|
-
cache_creation_tokens=cache_create,
|
|
3669
|
-
cache_read_tokens=cache_read,
|
|
3670
|
-
cache_1h_tokens=cache_1h,
|
|
3671
|
-
speed=speed,
|
|
3672
|
-
),
|
|
3730
|
+
usage,
|
|
3673
3731
|
mode="auto",
|
|
3674
3732
|
cost_usd=cost_raw,
|
|
3675
3733
|
)
|
|
3734
|
+
if prepared_daily_entries is not None:
|
|
3735
|
+
# #567: preserve the canonical daily entry and aggregator while
|
|
3736
|
+
# handing off the effective cost this pass already computed.
|
|
3737
|
+
prepared_daily_entries.append(c.UsageEntry(
|
|
3738
|
+
timestamp=dt.datetime.fromisoformat(ts_iso),
|
|
3739
|
+
model=model,
|
|
3740
|
+
usage=usage,
|
|
3741
|
+
cost_usd=entry_cost,
|
|
3742
|
+
source_path=source_path,
|
|
3743
|
+
))
|
|
3676
3744
|
pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
|
|
3677
3745
|
bp = pkey.bucket_path
|
|
3678
3746
|
a = mut.get(bp)
|
|
@@ -3699,7 +3767,9 @@ def _fold_projects_entry(
|
|
|
3699
3767
|
return entry_cost
|
|
3700
3768
|
|
|
3701
3769
|
|
|
3702
|
-
def fold_projects_over_range(
|
|
3770
|
+
def fold_projects_over_range(
|
|
3771
|
+
rows, *, resolver_cache=None, prepared_daily_entries=None,
|
|
3772
|
+
) -> "dict[str, dict]":
|
|
3703
3773
|
"""Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
|
|
3704
3774
|
|
|
3705
3775
|
#556 S2 §3.4. Takes rows rather than a connection because one candidate read
|
|
@@ -3722,7 +3792,13 @@ def fold_projects_over_range(rows, *, resolver_cache=None) -> "dict[str, dict]":
|
|
|
3722
3792
|
mut: "dict[str, dict]" = {}
|
|
3723
3793
|
cache = {} if resolver_cache is None else resolver_cache
|
|
3724
3794
|
for row in rows:
|
|
3725
|
-
_fold_projects_entry(
|
|
3795
|
+
_fold_projects_entry(
|
|
3796
|
+
mut,
|
|
3797
|
+
row,
|
|
3798
|
+
resolver_cache=cache,
|
|
3799
|
+
week_start=None,
|
|
3800
|
+
prepared_daily_entries=prepared_daily_entries,
|
|
3801
|
+
)
|
|
3726
3802
|
return mut
|
|
3727
3803
|
|
|
3728
3804
|
|
|
@@ -3759,7 +3835,11 @@ def legacy_project_labels(projects_envelope: object) -> "dict[str, str]":
|
|
|
3759
3835
|
|
|
3760
3836
|
|
|
3761
3837
|
def build_project_aggregate_rows(
|
|
3762
|
-
rows,
|
|
3838
|
+
rows,
|
|
3839
|
+
*,
|
|
3840
|
+
resolver_cache=None,
|
|
3841
|
+
legacy_labels=None,
|
|
3842
|
+
prepared_daily_entries=None,
|
|
3763
3843
|
) -> "list[dict]":
|
|
3764
3844
|
"""Published `providers.claude.projects.aggregate.rows` (spec §3.5.1).
|
|
3765
3845
|
|
|
@@ -3831,8 +3911,17 @@ def build_project_aggregate_rows(
|
|
|
3831
3911
|
total and means nothing over an absolute range. No ``bucket_path``, git
|
|
3832
3912
|
root or raw source path — opaque keys stay non-reversible.
|
|
3833
3913
|
"""
|
|
3914
|
+
folded = fold_projects_over_range(
|
|
3915
|
+
rows,
|
|
3916
|
+
resolver_cache=resolver_cache,
|
|
3917
|
+
prepared_daily_entries=prepared_daily_entries,
|
|
3918
|
+
)
|
|
3919
|
+
return _project_aggregate_rows_from_folded(folded, legacy_labels)
|
|
3920
|
+
|
|
3921
|
+
|
|
3922
|
+
def _project_aggregate_rows_from_folded(folded, legacy_labels):
|
|
3923
|
+
"""Finalize cached/raw project accumulators into the public row shape."""
|
|
3834
3924
|
c = _cctally()
|
|
3835
|
-
folded = fold_projects_over_range(rows, resolver_cache=resolver_cache)
|
|
3836
3925
|
bucket_paths_sorted = sorted(folded)
|
|
3837
3926
|
augmented_by_idx = c._project_disambiguate_labels(
|
|
3838
3927
|
[{"key": folded[bp]["first_key"]} for bp in bucket_paths_sorted],
|
|
@@ -3865,6 +3954,273 @@ def build_project_aggregate_rows(
|
|
|
3865
3954
|
return published
|
|
3866
3955
|
|
|
3867
3956
|
|
|
3957
|
+
_CLAUDE_RANGE_AGGREGATE_MEMO: dict[str, object] = {"state": None}
|
|
3958
|
+
|
|
3959
|
+
|
|
3960
|
+
def reset_claude_range_aggregate_memo() -> None:
|
|
3961
|
+
"""Drop the process-local #567 range-fold accumulator."""
|
|
3962
|
+
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = None
|
|
3963
|
+
|
|
3964
|
+
|
|
3965
|
+
def _shared_range_store_identity(conn):
|
|
3966
|
+
for _seq, name, path in conn.execute("PRAGMA database_list"):
|
|
3967
|
+
if name == "main":
|
|
3968
|
+
if not path:
|
|
3969
|
+
return (f":memory:{id(conn)}", None, None)
|
|
3970
|
+
try:
|
|
3971
|
+
stat = os.stat(path)
|
|
3972
|
+
except OSError:
|
|
3973
|
+
return (str(path), None, None)
|
|
3974
|
+
return (str(path), int(stat.st_dev), int(stat.st_ino))
|
|
3975
|
+
return f":connection:{id(conn)}"
|
|
3976
|
+
|
|
3977
|
+
|
|
3978
|
+
def _shared_range_entry_signature(conn) -> tuple[int, int]:
|
|
3979
|
+
max_id = int(conn.execute(
|
|
3980
|
+
"SELECT COALESCE(MAX(id), 0) FROM main.session_entries"
|
|
3981
|
+
).fetchone()[0])
|
|
3982
|
+
try:
|
|
3983
|
+
max_seq = int(conn.execute(
|
|
3984
|
+
"SELECT COALESCE(MAX(mutation_seq), 0) FROM main.session_entries"
|
|
3985
|
+
).fetchone()[0])
|
|
3986
|
+
except sqlite3.OperationalError:
|
|
3987
|
+
max_seq = 0
|
|
3988
|
+
return max_id, max_seq
|
|
3989
|
+
|
|
3990
|
+
|
|
3991
|
+
def _shared_range_session_files_signature(conn) -> tuple[int, int]:
|
|
3992
|
+
"""Cheap identity signal for lazy session/project metadata backfills."""
|
|
3993
|
+
try:
|
|
3994
|
+
row = conn.execute(
|
|
3995
|
+
"SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM session_files"
|
|
3996
|
+
).fetchone()
|
|
3997
|
+
except sqlite3.Error:
|
|
3998
|
+
return (0, 0)
|
|
3999
|
+
return int(row[0]), int(row[1])
|
|
4000
|
+
|
|
4001
|
+
|
|
4002
|
+
def _shared_range_entries_after_id(conn, after_id: int):
|
|
4003
|
+
"""Yield appended rows in canonical timestamp/id fold order."""
|
|
4004
|
+
cur = conn.execute(
|
|
4005
|
+
"SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
|
|
4006
|
+
" e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
|
|
4007
|
+
" e.cost_usd_raw, e.source_path, "
|
|
4008
|
+
" sf.session_id, sf.project_path, "
|
|
4009
|
+
" e.cache_create_1h_tokens, e.speed "
|
|
4010
|
+
"FROM session_entries e "
|
|
4011
|
+
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
4012
|
+
"WHERE e.id > ? "
|
|
4013
|
+
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
4014
|
+
(after_id,),
|
|
4015
|
+
)
|
|
4016
|
+
yield from cur
|
|
4017
|
+
|
|
4018
|
+
|
|
4019
|
+
def _shared_range_prior_row_mutated(
|
|
4020
|
+
conn, *, after_seq: int, through_id: int,
|
|
4021
|
+
) -> bool:
|
|
4022
|
+
try:
|
|
4023
|
+
row = conn.execute(
|
|
4024
|
+
"SELECT 1 FROM main.session_entries "
|
|
4025
|
+
"WHERE mutation_seq > ? AND id <= ? LIMIT 1",
|
|
4026
|
+
(after_seq, through_id),
|
|
4027
|
+
).fetchone()
|
|
4028
|
+
except sqlite3.OperationalError:
|
|
4029
|
+
return True
|
|
4030
|
+
return row is not None
|
|
4031
|
+
|
|
4032
|
+
|
|
4033
|
+
def _shared_range_cache_base(
|
|
4034
|
+
conn, *, shared_start, display_tz, generation: int,
|
|
4035
|
+
):
|
|
4036
|
+
tz_key = getattr(display_tz, "key", None)
|
|
4037
|
+
if tz_key is None:
|
|
4038
|
+
tz_key = str(display_tz) if display_tz is not None else "local"
|
|
4039
|
+
return (
|
|
4040
|
+
_shared_range_store_identity(conn),
|
|
4041
|
+
shared_start.astimezone(dt.timezone.utc).isoformat(),
|
|
4042
|
+
tz_key,
|
|
4043
|
+
int(generation),
|
|
4044
|
+
_shared_range_session_files_signature(conn),
|
|
4045
|
+
int(conn.execute(
|
|
4046
|
+
"SELECT COALESCE(MIN(id), 0) FROM main.session_entries"
|
|
4047
|
+
).fetchone()[0]),
|
|
4048
|
+
)
|
|
4049
|
+
|
|
4050
|
+
|
|
4051
|
+
def _shared_range_cache_payload(
|
|
4052
|
+
state,
|
|
4053
|
+
*,
|
|
4054
|
+
legacy_labels,
|
|
4055
|
+
now_utc,
|
|
4056
|
+
display_tz,
|
|
4057
|
+
):
|
|
4058
|
+
project_rows = _project_aggregate_rows_from_folded(
|
|
4059
|
+
state["project_mut"], legacy_labels,
|
|
4060
|
+
)
|
|
4061
|
+
daily_buckets = _finalize_daily_accumulators(state["daily_accumulators"])
|
|
4062
|
+
daily_rows = _build_daily_aggregate_rows_from_buckets(
|
|
4063
|
+
daily_buckets, now_utc=now_utc, display_tz=display_tz,
|
|
4064
|
+
)
|
|
4065
|
+
c = _cctally()
|
|
4066
|
+
return {
|
|
4067
|
+
"projects": project_rows,
|
|
4068
|
+
"daily": [c.daily_panel_row_to_wire(row) for row in daily_rows],
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
|
|
4072
|
+
def build_cached_claude_range_aggregates(
|
|
4073
|
+
conn,
|
|
4074
|
+
*,
|
|
4075
|
+
shared_start,
|
|
4076
|
+
shared_end_exclusive,
|
|
4077
|
+
now_utc,
|
|
4078
|
+
display_tz,
|
|
4079
|
+
legacy_labels,
|
|
4080
|
+
max_entry_id: "int | None" = None,
|
|
4081
|
+
entry_mutation_seq: "int | None" = None,
|
|
4082
|
+
generation: int = 0,
|
|
4083
|
+
):
|
|
4084
|
+
"""Build or increment the one-snapshot Claude range folds (#567).
|
|
4085
|
+
|
|
4086
|
+
Pure appends are folded onto the cached raw accumulators. A shifted range
|
|
4087
|
+
floor, backwards clock, generation or session-file identity change,
|
|
4088
|
+
non-monotone signature, or an id-stable mutation of an already-folded row
|
|
4089
|
+
falls back to one full ordered pass. The cache stores no public labels, so
|
|
4090
|
+
the current legacy population is reapplied on every publication.
|
|
4091
|
+
"""
|
|
4092
|
+
if max_entry_id is None or entry_mutation_seq is None:
|
|
4093
|
+
observed_id, observed_seq = _shared_range_entry_signature(conn)
|
|
4094
|
+
if max_entry_id is None:
|
|
4095
|
+
max_entry_id = observed_id
|
|
4096
|
+
if entry_mutation_seq is None:
|
|
4097
|
+
entry_mutation_seq = observed_seq
|
|
4098
|
+
max_entry_id = int(max_entry_id)
|
|
4099
|
+
entry_mutation_seq = int(entry_mutation_seq)
|
|
4100
|
+
base = _shared_range_cache_base(
|
|
4101
|
+
conn,
|
|
4102
|
+
shared_start=shared_start,
|
|
4103
|
+
display_tz=display_tz,
|
|
4104
|
+
generation=generation,
|
|
4105
|
+
)
|
|
4106
|
+
prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
|
|
4107
|
+
state = None
|
|
4108
|
+
if isinstance(prior, dict) and prior.get("base") == base:
|
|
4109
|
+
monotone = (
|
|
4110
|
+
max_entry_id >= prior["max_entry_id"]
|
|
4111
|
+
and entry_mutation_seq >= prior["entry_mutation_seq"]
|
|
4112
|
+
and shared_end_exclusive >= prior["end_exclusive"]
|
|
4113
|
+
)
|
|
4114
|
+
old_row_changed = (
|
|
4115
|
+
entry_mutation_seq != prior["entry_mutation_seq"]
|
|
4116
|
+
and _shared_range_prior_row_mutated(
|
|
4117
|
+
conn,
|
|
4118
|
+
after_seq=prior["entry_mutation_seq"],
|
|
4119
|
+
through_id=prior["max_entry_id"],
|
|
4120
|
+
)
|
|
4121
|
+
)
|
|
4122
|
+
if monotone and not old_row_changed:
|
|
4123
|
+
project_mut = copy.deepcopy(prior["project_mut"])
|
|
4124
|
+
daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
|
|
4125
|
+
resolver_cache = dict(prior["resolver_cache"])
|
|
4126
|
+
delta_by_id = {}
|
|
4127
|
+
for row in _shared_range_entries_after_id(
|
|
4128
|
+
conn, prior["max_entry_id"],
|
|
4129
|
+
):
|
|
4130
|
+
ts = parse_iso_datetime(
|
|
4131
|
+
row[1], "session_entries.timestamp_utc",
|
|
4132
|
+
)
|
|
4133
|
+
if shared_start <= ts < shared_end_exclusive:
|
|
4134
|
+
delta_by_id[row[0]] = row
|
|
4135
|
+
if shared_end_exclusive > prior["end_exclusive"]:
|
|
4136
|
+
for row in iter_shared_range_entries(
|
|
4137
|
+
conn,
|
|
4138
|
+
start=prior["end_exclusive"],
|
|
4139
|
+
end_exclusive=shared_end_exclusive,
|
|
4140
|
+
):
|
|
4141
|
+
if row[0] <= prior["max_entry_id"]:
|
|
4142
|
+
delta_by_id[row[0]] = row
|
|
4143
|
+
delta_rows = sorted(
|
|
4144
|
+
delta_by_id.values(),
|
|
4145
|
+
key=lambda row: (row[1], row[0]),
|
|
4146
|
+
)
|
|
4147
|
+
prior_tail = prior["tail"]
|
|
4148
|
+
if prior_tail is None or all(
|
|
4149
|
+
(row[1], row[0]) > prior_tail
|
|
4150
|
+
for row in delta_rows
|
|
4151
|
+
if row[2] != "<synthetic>"
|
|
4152
|
+
):
|
|
4153
|
+
prepared = []
|
|
4154
|
+
for row in delta_rows:
|
|
4155
|
+
_fold_projects_entry(
|
|
4156
|
+
project_mut,
|
|
4157
|
+
row,
|
|
4158
|
+
resolver_cache=resolver_cache,
|
|
4159
|
+
week_start=None,
|
|
4160
|
+
prepared_daily_entries=prepared,
|
|
4161
|
+
)
|
|
4162
|
+
_fold_prepared_daily_entries(
|
|
4163
|
+
daily_accumulators,
|
|
4164
|
+
prepared,
|
|
4165
|
+
display_tz=display_tz,
|
|
4166
|
+
)
|
|
4167
|
+
tail = prior_tail
|
|
4168
|
+
real_delta = [
|
|
4169
|
+
row for row in delta_rows if row[2] != "<synthetic>"
|
|
4170
|
+
]
|
|
4171
|
+
if real_delta:
|
|
4172
|
+
last = real_delta[-1]
|
|
4173
|
+
tail = (last[1], last[0])
|
|
4174
|
+
state = {
|
|
4175
|
+
"base": base,
|
|
4176
|
+
"max_entry_id": max_entry_id,
|
|
4177
|
+
"entry_mutation_seq": entry_mutation_seq,
|
|
4178
|
+
"end_exclusive": shared_end_exclusive,
|
|
4179
|
+
"tail": tail,
|
|
4180
|
+
"project_mut": project_mut,
|
|
4181
|
+
"daily_accumulators": daily_accumulators,
|
|
4182
|
+
"resolver_cache": resolver_cache,
|
|
4183
|
+
}
|
|
4184
|
+
if state is None:
|
|
4185
|
+
rows = tuple(iter_shared_range_entries(
|
|
4186
|
+
conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
4187
|
+
))
|
|
4188
|
+
prepared = []
|
|
4189
|
+
resolver_cache = {}
|
|
4190
|
+
project_mut = fold_projects_over_range(
|
|
4191
|
+
rows,
|
|
4192
|
+
resolver_cache=resolver_cache,
|
|
4193
|
+
prepared_daily_entries=prepared,
|
|
4194
|
+
)
|
|
4195
|
+
daily_accumulators = {}
|
|
4196
|
+
_fold_prepared_daily_entries(
|
|
4197
|
+
daily_accumulators, prepared, display_tz=display_tz,
|
|
4198
|
+
)
|
|
4199
|
+
real_rows = [row for row in rows if row[2] != "<synthetic>"]
|
|
4200
|
+
tail = None
|
|
4201
|
+
if real_rows:
|
|
4202
|
+
last = real_rows[-1]
|
|
4203
|
+
tail = (last[1], last[0])
|
|
4204
|
+
state = {
|
|
4205
|
+
"base": base,
|
|
4206
|
+
"max_entry_id": max_entry_id,
|
|
4207
|
+
"entry_mutation_seq": entry_mutation_seq,
|
|
4208
|
+
"end_exclusive": shared_end_exclusive,
|
|
4209
|
+
"tail": tail,
|
|
4210
|
+
"project_mut": project_mut,
|
|
4211
|
+
"daily_accumulators": daily_accumulators,
|
|
4212
|
+
"resolver_cache": resolver_cache,
|
|
4213
|
+
}
|
|
4214
|
+
payload = _shared_range_cache_payload(
|
|
4215
|
+
state,
|
|
4216
|
+
legacy_labels=legacy_labels,
|
|
4217
|
+
now_utc=now_utc,
|
|
4218
|
+
display_tz=display_tz,
|
|
4219
|
+
)
|
|
4220
|
+
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
|
|
4221
|
+
return payload
|
|
4222
|
+
|
|
4223
|
+
|
|
3868
4224
|
def _aggregate_projects_week_raw(
|
|
3869
4225
|
conn: "sqlite3.Connection",
|
|
3870
4226
|
*,
|
|
@@ -1662,7 +1662,8 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1662
1662
|
ls.ColumnSpec(key="current", label="Current", align="right"),
|
|
1663
1663
|
ls.ColumnSpec(key="projected", label="Projected", align="right"),
|
|
1664
1664
|
),
|
|
1665
|
-
rows=tuple(rows), chart=None, totals=(),
|
|
1665
|
+
rows=tuple(rows), chart=None, totals=(),
|
|
1666
|
+
notes=_share_budget_notes(data), generated_at=end,
|
|
1666
1667
|
version=sys.modules["cctally"]._share_resolve_version(),
|
|
1667
1668
|
template_id=template_id, source="codex", source_label="Codex",
|
|
1668
1669
|
availability=availability, availability_reason=reason,
|
|
@@ -1979,6 +1980,38 @@ def _share_scope_codex_state(state, account: "str | None"):
|
|
|
1979
1980
|
return replace(state, data=MappingProxyType({**dict(data), **scoped}))
|
|
1980
1981
|
|
|
1981
1982
|
|
|
1983
|
+
def _share_budget_notes(data) -> tuple[str, ...]:
|
|
1984
|
+
"""The configured-budget status, as an artifact note (#556 S5 §5.12).
|
|
1985
|
+
|
|
1986
|
+
The Forecast artifact carried quota projections and nothing about the
|
|
1987
|
+
CONFIGURED budget, so a shared Forecast said less than the panel it was
|
|
1988
|
+
taken from — and after S5 the panel renders both side by side. The note is
|
|
1989
|
+
ADDITIVE and omitted when no status is published, so an install with no
|
|
1990
|
+
budget produces a byte-identical artifact.
|
|
1991
|
+
|
|
1992
|
+
``data`` is already the ACCOUNT-SCOPED provider body when the request named
|
|
1993
|
+
an account (`_share_scope_codex_state` rewrites the scoped
|
|
1994
|
+
domains before this runs), so a focused share carries that account's own
|
|
1995
|
+
budget and never the vendor-wide one.
|
|
1996
|
+
"""
|
|
1997
|
+
budget = data.get("budget") if isinstance(data, Mapping) else None
|
|
1998
|
+
status = budget.get("status") if isinstance(budget, Mapping) else None
|
|
1999
|
+
if not isinstance(status, Mapping):
|
|
2000
|
+
return ()
|
|
2001
|
+
try:
|
|
2002
|
+
spent = float(status["spent_usd"])
|
|
2003
|
+
target = float(status["budget_usd"])
|
|
2004
|
+
consumed = float(status["consumption_pct"])
|
|
2005
|
+
period = str(status["period"])
|
|
2006
|
+
verdict = str(status["verdict"])
|
|
2007
|
+
except (KeyError, TypeError, ValueError):
|
|
2008
|
+
return ()
|
|
2009
|
+
return (
|
|
2010
|
+
f"Budget ({period}): ${spent:,.2f} of ${target:,.2f} "
|
|
2011
|
+
f"({consumed:.1f}%) — {verdict}",
|
|
2012
|
+
)
|
|
2013
|
+
|
|
2014
|
+
|
|
1982
2015
|
def _share_build_source_snapshots(*, ls, template, template_id: str,
|
|
1983
2016
|
panel: str, options: dict, source: str,
|
|
1984
2017
|
source_explicit: bool, data_snap,
|
|
@@ -2010,6 +2043,22 @@ def _share_build_source_snapshots(*, ls, template, template_id: str,
|
|
|
2010
2043
|
claude_snapshot = _share_apply_current_week_freshness(
|
|
2011
2044
|
claude_snapshot, claude_state, panel,
|
|
2012
2045
|
)
|
|
2046
|
+
# #556 S5 §5.12 (Unit 2 review F7). The budget note was wired into
|
|
2047
|
+
# the Codex builder only, while §5.12 says "the configured-budget
|
|
2048
|
+
# sections" and the Claude Forecast panel renders one after S5 — so
|
|
2049
|
+
# a shared Claude Forecast said less than the panel it came from.
|
|
2050
|
+
# Gated exactly like the freshness stamp above: a source-less
|
|
2051
|
+
# request is the shipped legacy Claude contract and stays
|
|
2052
|
+
# byte-identical, because it has no source state to read at all.
|
|
2053
|
+
if panel == "forecast":
|
|
2054
|
+
notes = _share_budget_notes(
|
|
2055
|
+
getattr(claude_state, "data", None) or {},
|
|
2056
|
+
)
|
|
2057
|
+
if notes:
|
|
2058
|
+
claude_snapshot = replace(
|
|
2059
|
+
claude_snapshot,
|
|
2060
|
+
notes=tuple(claude_snapshot.notes) + notes,
|
|
2061
|
+
)
|
|
2013
2062
|
|
|
2014
2063
|
codex_snapshot = None
|
|
2015
2064
|
codex_state = None
|