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
package/bin/_cctally_record.py
CHANGED
|
@@ -173,8 +173,8 @@ import re
|
|
|
173
173
|
import sqlite3
|
|
174
174
|
import sys
|
|
175
175
|
import time
|
|
176
|
-
from dataclasses import dataclass
|
|
177
|
-
from typing import Any
|
|
176
|
+
from dataclasses import dataclass, field
|
|
177
|
+
from typing import Any, Iterable
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
def _cctally():
|
|
@@ -902,8 +902,13 @@ def maybe_record_milestone(
|
|
|
902
902
|
# Filter by reset_event_id so a credited week's
|
|
903
903
|
# alert payload reads the post-credit row, not a
|
|
904
904
|
# stale pre-credit row at the same (week, threshold).
|
|
905
|
+
# `week_start_at` comes from the row rather than the
|
|
906
|
+
# local variable for the same reason `cumulative_cost_usd`
|
|
907
|
+
# does: it is the value persisted on insert, so the alert
|
|
908
|
+
# states the week the milestone actually recorded.
|
|
905
909
|
row = conn.execute(
|
|
906
|
-
"SELECT cumulative_cost_usd
|
|
910
|
+
"SELECT cumulative_cost_usd, week_start_at "
|
|
911
|
+
"FROM percent_milestones "
|
|
907
912
|
"WHERE week_start_date = ? AND percent_threshold = ? "
|
|
908
913
|
" AND reset_event_id = ? AND account_key = ?",
|
|
909
914
|
(week_start_date, pct, reset_event_id, account_key),
|
|
@@ -916,6 +921,7 @@ def maybe_record_milestone(
|
|
|
916
921
|
threshold=pct,
|
|
917
922
|
crossed_at_utc=crossed_at,
|
|
918
923
|
week_start_date=week_start_date,
|
|
924
|
+
week_start_at=row["week_start_at"],
|
|
919
925
|
cumulative_cost_usd=cum,
|
|
920
926
|
dollars_per_percent=dpp,
|
|
921
927
|
account_key=account_key,
|
|
@@ -1503,11 +1509,12 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
|
1503
1509
|
binds the fired value to forecast's ``week_avg_projection_pct`` within
|
|
1504
1510
|
1e-9, so the two MUST share the formula by construction.
|
|
1505
1511
|
|
|
1506
|
-
LOW CONF mirrors the displayed forecast confidence:
|
|
1507
|
-
``_assess_forecast_confidence(elapsed_hours, p_now, len(samples)
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
projected alert
|
|
1512
|
+
LOW CONF mirrors the displayed forecast confidence: ONE call to
|
|
1513
|
+
``_assess_forecast_confidence(elapsed_hours, p_now, len(samples),
|
|
1514
|
+
has_sample_ge_24h=…)``, whose fourth trigger ``no_sample_ge_24h`` moved
|
|
1515
|
+
inside the predicate with #620 S2 E3 — so a thin early-week window that
|
|
1516
|
+
forecast renders ``LOW CONF`` never fires a projected alert, and glue no
|
|
1517
|
+
longer appends a reason of its own.
|
|
1511
1518
|
|
|
1512
1519
|
Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
|
|
1513
1520
|
needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
|
|
@@ -1528,15 +1535,14 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
|
1528
1535
|
r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
|
|
1529
1536
|
projected_pct = p_now + r_avg * remaining_hours
|
|
1530
1537
|
|
|
1531
|
-
# Confidence
|
|
1532
|
-
#
|
|
1538
|
+
# Confidence comes from the predicate in one call, fourth trigger
|
|
1539
|
+
# included, so this LOW CONF gate == forecast's and glue no longer
|
|
1540
|
+
# downgrades a confidence the predicate returned (#620 S2 E3).
|
|
1541
|
+
target_24h = now_utc - dt.timedelta(hours=24)
|
|
1533
1542
|
confidence, _reasons = _assess_forecast_confidence(
|
|
1534
|
-
elapsed_hours, p_now, len(samples)
|
|
1543
|
+
elapsed_hours, p_now, len(samples),
|
|
1544
|
+
has_sample_ge_24h=any(s[0] <= target_24h for s in samples),
|
|
1535
1545
|
)
|
|
1536
|
-
target_24h = now_utc - dt.timedelta(hours=24)
|
|
1537
|
-
has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
|
|
1538
|
-
if not has_sample_ge_24h:
|
|
1539
|
-
confidence = "low"
|
|
1540
1546
|
return (projected_pct, confidence == "low")
|
|
1541
1547
|
|
|
1542
1548
|
|
|
@@ -1876,6 +1882,112 @@ def maybe_record_projected_alert(
|
|
|
1876
1882
|
eprint(f"[projected-alert] payload build failed: {build_exc}")
|
|
1877
1883
|
|
|
1878
1884
|
|
|
1885
|
+
@dataclass
|
|
1886
|
+
class PricedEntry:
|
|
1887
|
+
"""One accounting entry after pricing, as the block fold consumes it.
|
|
1888
|
+
|
|
1889
|
+
The canonical priced record: the fields `fold_block_totals` reads and
|
|
1890
|
+
nothing else. `_compute_block_totals` builds these from
|
|
1891
|
+
`_JoinedClaudeEntry` rows it has already priced; the diagnosis
|
|
1892
|
+
(#620 S2) builds them from its own half-open account-scoped window.
|
|
1893
|
+
"""
|
|
1894
|
+
model: str
|
|
1895
|
+
project_path: str | None
|
|
1896
|
+
input_tokens: int
|
|
1897
|
+
output_tokens: int
|
|
1898
|
+
cache_creation_tokens: int
|
|
1899
|
+
cache_read_tokens: int
|
|
1900
|
+
cost_usd: float
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
@dataclass
|
|
1904
|
+
class BlockBucket:
|
|
1905
|
+
"""One model or project bucket inside a block's totals."""
|
|
1906
|
+
input_tokens: int = 0
|
|
1907
|
+
output_tokens: int = 0
|
|
1908
|
+
cache_create_tokens: int = 0
|
|
1909
|
+
cache_read_tokens: int = 0
|
|
1910
|
+
cost_usd: float = 0.0
|
|
1911
|
+
entry_count: int = 0
|
|
1912
|
+
|
|
1913
|
+
def as_legacy_dict(self) -> dict[str, Any]:
|
|
1914
|
+
return {
|
|
1915
|
+
"input_tokens": self.input_tokens,
|
|
1916
|
+
"output_tokens": self.output_tokens,
|
|
1917
|
+
"cache_create_tokens": self.cache_create_tokens,
|
|
1918
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
1919
|
+
"cost_usd": self.cost_usd,
|
|
1920
|
+
"entry_count": self.entry_count,
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
@dataclass
|
|
1925
|
+
class BlockTotals:
|
|
1926
|
+
"""The summed result of `fold_block_totals`."""
|
|
1927
|
+
input_tokens: int = 0
|
|
1928
|
+
output_tokens: int = 0
|
|
1929
|
+
cache_create_tokens: int = 0
|
|
1930
|
+
cache_read_tokens: int = 0
|
|
1931
|
+
cost_usd: float = 0.0
|
|
1932
|
+
entry_count: int = 0
|
|
1933
|
+
by_model: dict[str, BlockBucket] = field(default_factory=dict)
|
|
1934
|
+
by_project: dict[str, BlockBucket] = field(default_factory=dict)
|
|
1935
|
+
|
|
1936
|
+
def as_legacy_dict(self) -> dict[str, Any]:
|
|
1937
|
+
"""The exact dict `_compute_block_totals` has always returned.
|
|
1938
|
+
|
|
1939
|
+
Key order is preserved because callers and fixture builders read
|
|
1940
|
+
this dict directly, and `entry_count` is deliberately absent at the
|
|
1941
|
+
top level: it exists on the dataclass for the diagnosis, and adding
|
|
1942
|
+
it here would change a shape every existing caller sees.
|
|
1943
|
+
"""
|
|
1944
|
+
return {
|
|
1945
|
+
"input_tokens": self.input_tokens,
|
|
1946
|
+
"output_tokens": self.output_tokens,
|
|
1947
|
+
"cache_create_tokens": self.cache_create_tokens,
|
|
1948
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
1949
|
+
"cost_usd": self.cost_usd,
|
|
1950
|
+
"by_model": {k: v.as_legacy_dict() for k, v in self.by_model.items()},
|
|
1951
|
+
"by_project": {k: v.as_legacy_dict() for k, v in self.by_project.items()},
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def fold_block_totals(entries: "Iterable[PricedEntry]") -> BlockTotals:
|
|
1956
|
+
"""Sum priced entries into block totals plus model and project buckets.
|
|
1957
|
+
|
|
1958
|
+
Pure: it opens nothing, prices nothing, and reads no clock. Summation
|
|
1959
|
+
follows the iteration order of `entries`, and bucket insertion order
|
|
1960
|
+
follows first appearance, both of which the callers observe.
|
|
1961
|
+
|
|
1962
|
+
A NULL `project_path` buckets under `(unknown)` so the reconcile
|
|
1963
|
+
invariant `SUM(child.cost) == parent.total` continues to hold. The
|
|
1964
|
+
JSONL-fallback loader always populates `project_path`, so `(unknown)`
|
|
1965
|
+
only appears on the cache-backed path during the brief `session_files`
|
|
1966
|
+
lazy-backfill window.
|
|
1967
|
+
"""
|
|
1968
|
+
totals = BlockTotals()
|
|
1969
|
+
for entry in entries:
|
|
1970
|
+
totals.input_tokens += entry.input_tokens
|
|
1971
|
+
totals.output_tokens += entry.output_tokens
|
|
1972
|
+
totals.cache_create_tokens += entry.cache_creation_tokens
|
|
1973
|
+
totals.cache_read_tokens += entry.cache_read_tokens
|
|
1974
|
+
totals.cost_usd += entry.cost_usd
|
|
1975
|
+
totals.entry_count += 1
|
|
1976
|
+
|
|
1977
|
+
for key, bucket_dict in (
|
|
1978
|
+
(entry.model, totals.by_model),
|
|
1979
|
+
(entry.project_path or "(unknown)", totals.by_project),
|
|
1980
|
+
):
|
|
1981
|
+
b = bucket_dict.setdefault(key, BlockBucket())
|
|
1982
|
+
b.input_tokens += entry.input_tokens
|
|
1983
|
+
b.output_tokens += entry.output_tokens
|
|
1984
|
+
b.cache_create_tokens += entry.cache_creation_tokens
|
|
1985
|
+
b.cache_read_tokens += entry.cache_read_tokens
|
|
1986
|
+
b.cost_usd += entry.cost_usd
|
|
1987
|
+
b.entry_count += 1
|
|
1988
|
+
return totals
|
|
1989
|
+
|
|
1990
|
+
|
|
1879
1991
|
def _compute_block_totals(
|
|
1880
1992
|
block_start_at: dt.datetime,
|
|
1881
1993
|
range_end: dt.datetime,
|
|
@@ -1901,64 +2013,32 @@ def _compute_block_totals(
|
|
|
1901
2013
|
cost_usd, entry_count}]
|
|
1902
2014
|
by_project: dict[project_path_or_'(unknown)' -> same shape]
|
|
1903
2015
|
"""
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
"cache_create_tokens": 0,
|
|
1908
|
-
"cache_read_tokens": 0,
|
|
1909
|
-
"cost_usd": 0.0,
|
|
1910
|
-
"by_model": {},
|
|
1911
|
-
"by_project": {},
|
|
1912
|
-
}
|
|
1913
|
-
for entry in get_claude_session_entries(
|
|
1914
|
-
block_start_at, range_end, skip_sync=skip_sync,
|
|
1915
|
-
):
|
|
1916
|
-
usage = claude_usage_dict( # #195 chokepoint
|
|
1917
|
-
input_tokens=entry.input_tokens,
|
|
1918
|
-
output_tokens=entry.output_tokens,
|
|
1919
|
-
cache_creation_tokens=entry.cache_creation_tokens,
|
|
1920
|
-
cache_read_tokens=entry.cache_read_tokens,
|
|
1921
|
-
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
1922
|
-
speed=getattr(entry, "speed", None),
|
|
1923
|
-
)
|
|
1924
|
-
cost = _calculate_entry_cost(
|
|
1925
|
-
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
1926
|
-
)
|
|
1927
|
-
|
|
1928
|
-
totals["input_tokens"] += entry.input_tokens
|
|
1929
|
-
totals["output_tokens"] += entry.output_tokens
|
|
1930
|
-
totals["cache_create_tokens"] += entry.cache_creation_tokens
|
|
1931
|
-
totals["cache_read_tokens"] += entry.cache_read_tokens
|
|
1932
|
-
totals["cost_usd"] += cost
|
|
1933
|
-
|
|
1934
|
-
# Bucket by model and by project_path. NULL project_path → sentinel
|
|
1935
|
-
# so reconcile invariant SUM(child.cost) == parent.total holds.
|
|
1936
|
-
# Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
|
|
1937
|
-
# always populates project_path = cwd (never NULL); '(unknown)' only
|
|
1938
|
-
# appears on the cache-backed path during the brief session_files
|
|
1939
|
-
# lazy-backfill window.
|
|
1940
|
-
for key, bucket_dict in (
|
|
1941
|
-
(entry.model, totals["by_model"]),
|
|
1942
|
-
(entry.project_path or "(unknown)", totals["by_project"]),
|
|
2016
|
+
def _priced():
|
|
2017
|
+
for entry in get_claude_session_entries(
|
|
2018
|
+
block_start_at, range_end, skip_sync=skip_sync,
|
|
1943
2019
|
):
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
"cost_usd": 0.0,
|
|
1952
|
-
"entry_count": 0,
|
|
1953
|
-
},
|
|
2020
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
2021
|
+
input_tokens=entry.input_tokens,
|
|
2022
|
+
output_tokens=entry.output_tokens,
|
|
2023
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
2024
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
2025
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
2026
|
+
speed=getattr(entry, "speed", None),
|
|
1954
2027
|
)
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2028
|
+
cost = _calculate_entry_cost(
|
|
2029
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
2030
|
+
)
|
|
2031
|
+
yield PricedEntry(
|
|
2032
|
+
model=entry.model,
|
|
2033
|
+
project_path=entry.project_path,
|
|
2034
|
+
input_tokens=entry.input_tokens,
|
|
2035
|
+
output_tokens=entry.output_tokens,
|
|
2036
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
2037
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
2038
|
+
cost_usd=cost,
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
return fold_block_totals(_priced()).as_legacy_dict()
|
|
1962
2042
|
|
|
1963
2043
|
|
|
1964
2044
|
def maybe_update_five_hour_block(
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -918,6 +918,58 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
918
918
|
# Hook-tick OAuth refresh path
|
|
919
919
|
# =========================================================================
|
|
920
920
|
|
|
921
|
+
def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float) -> str | None:
|
|
922
|
+
"""Return the automatic-refresh suppression reason under selected lock."""
|
|
923
|
+
now_epoch = int(time.time())
|
|
924
|
+
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
925
|
+
obs_age = c._statusline_observe_age_seconds()
|
|
926
|
+
if (not needs_repair
|
|
927
|
+
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
928
|
+
return f"skipped(statusline-fresh:{int(obs_age)}s)"
|
|
929
|
+
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
930
|
+
if backoff_remaining > 0:
|
|
931
|
+
return f"skipped(backoff:{int(backoff_remaining)}s)"
|
|
932
|
+
age_s = c._newest_snapshot_age_seconds()
|
|
933
|
+
if age_s is not None and age_s < throttle_seconds:
|
|
934
|
+
return f"skipped(fresh:{int(age_s)}s)"
|
|
935
|
+
return None
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _hook_tick_parse_oauth_payload(c, api):
|
|
939
|
+
"""Validate one OAuth response outside the selected-state lock."""
|
|
940
|
+
try:
|
|
941
|
+
seven = api["seven_day"]
|
|
942
|
+
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
943
|
+
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
944
|
+
except (TypeError, ValueError, KeyError):
|
|
945
|
+
return None
|
|
946
|
+
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
947
|
+
five_pct: float | None = None
|
|
948
|
+
five_resets_epoch: int | None = None
|
|
949
|
+
if (five is not None and "utilization" in five
|
|
950
|
+
and isinstance(five.get("resets_at"), str)):
|
|
951
|
+
try:
|
|
952
|
+
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
953
|
+
five_resets_epoch = _iso_to_epoch(five["resets_at"])
|
|
954
|
+
except (TypeError, ValueError):
|
|
955
|
+
five_pct = None
|
|
956
|
+
five_resets_epoch = None
|
|
957
|
+
record_args = argparse.Namespace(
|
|
958
|
+
percent=seven_pct,
|
|
959
|
+
resets_at=str(seven_resets_epoch),
|
|
960
|
+
five_hour_percent=five_pct,
|
|
961
|
+
five_hour_resets_at=(
|
|
962
|
+
str(five_resets_epoch) if five_resets_epoch is not None else None
|
|
963
|
+
),
|
|
964
|
+
source="api",
|
|
965
|
+
)
|
|
966
|
+
axes = {"sevenDay", *({"fiveHour"} if five_pct is not None else set())}
|
|
967
|
+
parts = [f"7d={int(round(seven_pct))}"]
|
|
968
|
+
if five_pct is not None:
|
|
969
|
+
parts.append(f"5h={int(round(five_pct))}")
|
|
970
|
+
return record_args, axes, parts
|
|
971
|
+
|
|
972
|
+
|
|
921
973
|
def _hook_tick_oauth_refresh(
|
|
922
974
|
timeout_seconds: float = 5.0,
|
|
923
975
|
throttle_seconds: float | None = None,
|
|
@@ -950,115 +1002,75 @@ def _hook_tick_oauth_refresh(
|
|
|
950
1002
|
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
951
1003
|
except OauthUsageConfigError:
|
|
952
1004
|
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
953
|
-
#
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
957
|
-
#
|
|
958
|
-
# owns the lock across the OAuth fetch AND the authoritative record, so
|
|
959
|
-
# `_authoritative_record_usage` cannot release it — the deferral has to
|
|
960
|
-
# reach the frame that acquired it. The nudge is a loopback POST with a
|
|
961
|
-
# multi-second timeout and this lock is an `fcntl.flock` every cctally
|
|
962
|
-
# process contends on, so one unresponsive listener would otherwise stall
|
|
963
|
-
# every other process's selected-state writes at status-line cadence.
|
|
964
|
-
deferred = []
|
|
1005
|
+
# Check freshness under the selected-state lock, release it for the
|
|
1006
|
+
# potentially five-second network request, then recheck after reacquiring
|
|
1007
|
+
# before publication. A concurrent winner can therefore suppress this
|
|
1008
|
+
# response without the high-cadence hook holding the cross-process flock
|
|
1009
|
+
# during network I/O.
|
|
965
1010
|
try:
|
|
966
1011
|
with c._selected_state_lock():
|
|
967
|
-
|
|
968
|
-
c,
|
|
969
|
-
token=token,
|
|
970
|
-
timeout_seconds=timeout_seconds,
|
|
971
|
-
throttle_seconds=throttle_seconds,
|
|
972
|
-
nudge_sink=lambda: deferred.append(1),
|
|
1012
|
+
skipped = _hook_tick_oauth_skip_status(
|
|
1013
|
+
c, throttle_seconds=throttle_seconds,
|
|
973
1014
|
)
|
|
974
1015
|
except OSError:
|
|
975
1016
|
return "err(record-usage=exc)", None
|
|
976
|
-
if
|
|
977
|
-
|
|
978
|
-
return out
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
def _hook_tick_oauth_refresh_locked(
|
|
982
|
-
c,
|
|
983
|
-
*,
|
|
984
|
-
token: str,
|
|
985
|
-
timeout_seconds: float,
|
|
986
|
-
throttle_seconds: float,
|
|
987
|
-
nudge_sink=None,
|
|
988
|
-
) -> tuple[str, dict | None]:
|
|
989
|
-
"""Automatic OAuth path with the selected-state lock already held.
|
|
1017
|
+
if skipped is not None:
|
|
1018
|
+
return skipped, None
|
|
990
1019
|
|
|
991
|
-
``nudge_sink`` records that a dashboard nudge is warranted; the caller
|
|
992
|
-
fires it after releasing the lock (#583 S2).
|
|
993
|
-
"""
|
|
994
|
-
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
995
|
-
# suppression so a later authoritative result can repair it. The normal
|
|
996
|
-
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
997
|
-
now_epoch = int(time.time())
|
|
998
|
-
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
999
|
-
obs_age = c._statusline_observe_age_seconds()
|
|
1000
|
-
if (not needs_repair
|
|
1001
|
-
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
1002
|
-
return f"skipped(statusline-fresh:{int(obs_age)}s)", None
|
|
1003
|
-
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
1004
|
-
if backoff_remaining > 0:
|
|
1005
|
-
return f"skipped(backoff:{int(backoff_remaining)}s)", None
|
|
1006
|
-
age_s = c._newest_snapshot_age_seconds()
|
|
1007
|
-
if age_s is not None and age_s < throttle_seconds:
|
|
1008
|
-
return f"skipped(fresh:{int(age_s)}s)", None
|
|
1009
1020
|
try:
|
|
1010
1021
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
1011
1022
|
except RefreshUsageRateLimitError as exc:
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1023
|
+
try:
|
|
1024
|
+
with c._selected_state_lock():
|
|
1025
|
+
c._oauth_backoff_register_429(
|
|
1026
|
+
retry_after_deadline=getattr(
|
|
1027
|
+
exc, "retry_after_deadline", None,
|
|
1028
|
+
),
|
|
1029
|
+
now=time.time(),
|
|
1030
|
+
)
|
|
1031
|
+
except OSError:
|
|
1032
|
+
return "err(record-usage=exc)", None
|
|
1016
1033
|
return "err(rate-limit)", None
|
|
1017
1034
|
except RefreshUsageNetworkError:
|
|
1018
1035
|
return "err(network)", None
|
|
1019
1036
|
except RefreshUsageMalformedError:
|
|
1020
1037
|
return "err(parse)", None
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
1025
|
-
except (TypeError, ValueError, KeyError):
|
|
1038
|
+
|
|
1039
|
+
parsed = _hook_tick_parse_oauth_payload(c, api)
|
|
1040
|
+
if parsed is None:
|
|
1026
1041
|
return "err(parse)", None
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1042
|
+
record_args, axes, parts = parsed
|
|
1043
|
+
|
|
1044
|
+
# #583 S2: the nudge remains deferred until after the publication lock is
|
|
1045
|
+
# released. The sink contract is now enforced by the authority kernel, so
|
|
1046
|
+
# every lock-owning caller has to make the deferral explicit.
|
|
1047
|
+
deferred = []
|
|
1048
|
+
try:
|
|
1049
|
+
with c._selected_state_lock():
|
|
1050
|
+
skipped = _hook_tick_oauth_skip_status(
|
|
1051
|
+
c,
|
|
1052
|
+
throttle_seconds=throttle_seconds,
|
|
1053
|
+
)
|
|
1054
|
+
if skipped is not None:
|
|
1055
|
+
return skipped, None
|
|
1056
|
+
authoritative = c._authoritative_record_usage(
|
|
1057
|
+
record_args,
|
|
1058
|
+
axes,
|
|
1059
|
+
lock_held=True,
|
|
1060
|
+
nudge_sink=lambda: deferred.append(1),
|
|
1061
|
+
)
|
|
1062
|
+
if authoritative.status != "ok":
|
|
1063
|
+
reason = authoritative.reason or ""
|
|
1064
|
+
if reason.startswith("exit "):
|
|
1065
|
+
return f"err(record-usage={reason[5:]})", None
|
|
1066
|
+
return "err(record-usage=exc)", None
|
|
1067
|
+
# A success reaches shared backoff only after tombstones, control,
|
|
1068
|
+
# and freshness are authoritative under this same lock.
|
|
1069
|
+
c._oauth_backoff_reset()
|
|
1070
|
+
except OSError:
|
|
1055
1071
|
return "err(record-usage=exc)", None
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
c._oauth_backoff_reset()
|
|
1059
|
-
parts = [f"7d={int(round(seven_pct))}"]
|
|
1060
|
-
if five_pct is not None:
|
|
1061
|
-
parts.append(f"5h={int(round(five_pct))}")
|
|
1072
|
+
if deferred:
|
|
1073
|
+
c._nudge_dashboard_repaint()
|
|
1062
1074
|
return f"ok({','.join(parts)})", api
|
|
1063
1075
|
|
|
1064
1076
|
|
package/bin/_cctally_share.py
CHANGED
|
@@ -949,7 +949,7 @@ def _build_forecast_snapshot(
|
|
|
949
949
|
projected_low_pct: float,
|
|
950
950
|
projected_high_pct: float,
|
|
951
951
|
days_remaining: float,
|
|
952
|
-
dollars_per_percent: float,
|
|
952
|
+
dollars_per_percent: "float | None",
|
|
953
953
|
dollars_per_percent_source: str,
|
|
954
954
|
low_conf: bool,
|
|
955
955
|
notes: tuple[str, ...] = (),
|
|
@@ -1038,6 +1038,13 @@ def _build_forecast_snapshot(
|
|
|
1038
1038
|
f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
|
|
1039
1039
|
)
|
|
1040
1040
|
dpp_source_label = dollars_per_percent_source.replace("_", " ")
|
|
1041
|
+
# #620 S1 D5: a withheld rate renders as the same `n/a` the report trend
|
|
1042
|
+
# table already uses for an absent $/1%. Forcing it through MoneyCell
|
|
1043
|
+
# would print $0.00, which is the fabrication this contract removes.
|
|
1044
|
+
dpp_cell = (
|
|
1045
|
+
_lib_share.TextCell("n/a") if dollars_per_percent is None
|
|
1046
|
+
else _lib_share.MoneyCell(float(dollars_per_percent))
|
|
1047
|
+
)
|
|
1041
1048
|
snap_rows = (
|
|
1042
1049
|
_lib_share.Row(cells={
|
|
1043
1050
|
"metric": _lib_share.TextCell("Current %"),
|
|
@@ -1053,7 +1060,7 @@ def _build_forecast_snapshot(
|
|
|
1053
1060
|
}),
|
|
1054
1061
|
_lib_share.Row(cells={
|
|
1055
1062
|
"metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
|
|
1056
|
-
"value":
|
|
1063
|
+
"value": dpp_cell,
|
|
1057
1064
|
}),
|
|
1058
1065
|
)
|
|
1059
1066
|
# Caller-provided `notes` (e.g., empty-data path's clearer message)
|
|
@@ -1140,6 +1140,14 @@ def _run_claude_json_adapter(args: object, command: str) -> SourceResult[dict[st
|
|
|
1140
1140
|
captured: list[dict[str, object]] = []
|
|
1141
1141
|
claude_args._source_result_sink = captured.append
|
|
1142
1142
|
exit_code = handler(claude_args)
|
|
1143
|
+
# Every Claude handler resolves the display zone onto its own namespace,
|
|
1144
|
+
# and ``claude_args`` is a copy, so that resolution never reached the
|
|
1145
|
+
# caller. The all-source terminal report needs the SAME zone the handler
|
|
1146
|
+
# used or its week windows and capture timestamps would render in a
|
|
1147
|
+
# different zone from `report --source claude` (#620 S1 D7). Reading it
|
|
1148
|
+
# back off the copy is exact by construction; re-deriving it here would
|
|
1149
|
+
# be a second resolution that can disagree with the first.
|
|
1150
|
+
args._claude_resolved_tz = getattr(claude_args, "_resolved_tz", None)
|
|
1143
1151
|
if exit_code != 0 or len(captured) != 1:
|
|
1144
1152
|
return SourceResult("claude", "unavailable", None)
|
|
1145
1153
|
payload = captured[0]
|
|
@@ -1951,8 +1959,15 @@ def _render_codex_terminal(
|
|
|
1951
1959
|
return "\n".join(lines)
|
|
1952
1960
|
|
|
1953
1961
|
|
|
1954
|
-
def _render_claude_terminal(
|
|
1955
|
-
|
|
1962
|
+
def _render_claude_terminal(
|
|
1963
|
+
command: str, result: SourceResult, *, tz: object = None,
|
|
1964
|
+
) -> str:
|
|
1965
|
+
"""Render an all-source Claude section without changing legacy CLI paths.
|
|
1966
|
+
|
|
1967
|
+
``tz`` is the display zone the Claude handler itself resolved, captured
|
|
1968
|
+
by ``_run_claude_json_adapter``; it reaches only the report renderer
|
|
1969
|
+
(#620 S1 D7), which formats week windows and capture timestamps.
|
|
1970
|
+
"""
|
|
1956
1971
|
title = _TERMINAL_TITLES.get(command, "Analytics Report")
|
|
1957
1972
|
lines = [f"Claude {title}"]
|
|
1958
1973
|
if result.status == "unavailable":
|
|
@@ -1962,11 +1977,29 @@ def _render_claude_terminal(command: str, result: SourceResult) -> str:
|
|
|
1962
1977
|
if command == "diff" and isinstance(result.data, dict):
|
|
1963
1978
|
_append_diff_terminal(lines, result.data)
|
|
1964
1979
|
return "\n".join(lines)
|
|
1980
|
+
if command == "report" and isinstance(result.data, dict):
|
|
1981
|
+
# #620 S1 D7: render the real report. `_legacy_claude_totals` reads a
|
|
1982
|
+
# `totals` object this payload does not have, so it returned (0, 0)
|
|
1983
|
+
# and the section collapsed to `Data available.` — no current-week
|
|
1984
|
+
# table, no trend table, no `$ / 1%` column — while `report` on its
|
|
1985
|
+
# own printed all of it. The renderer is the SAME pure function
|
|
1986
|
+
# `cmd_report` calls, so the two cannot diverge.
|
|
1987
|
+
c = _cctally()
|
|
1988
|
+
lines.append(c.render_report_terminal(result.data, tz=tz))
|
|
1989
|
+
return "\n".join(lines)
|
|
1965
1990
|
cost, tokens = _legacy_claude_totals(result.data)
|
|
1966
1991
|
if cost or tokens:
|
|
1967
1992
|
lines.append(f"Total: {_terminal_amount(cost)} · {_terminal_tokens(tokens)}")
|
|
1968
1993
|
else:
|
|
1969
|
-
|
|
1994
|
+
# #620 S1 D7: a POPULATED result whose compatible cost and token
|
|
1995
|
+
# totals are both exactly zero. `Data available.` said nothing about
|
|
1996
|
+
# what was available or where to see it. State what is absent, and
|
|
1997
|
+
# name the command that shows the detail this section cannot.
|
|
1998
|
+
lines.append(
|
|
1999
|
+
"No compatible cost or token total for Claude in this result. "
|
|
2000
|
+
f"Run `cctally {command} --source claude` for the full Claude "
|
|
2001
|
+
"detail."
|
|
2002
|
+
)
|
|
1970
2003
|
return "\n".join(lines)
|
|
1971
2004
|
|
|
1972
2005
|
|
|
@@ -2055,7 +2088,10 @@ def _emit_source_result(
|
|
|
2055
2088
|
and result.status == "unavailable"
|
|
2056
2089
|
)
|
|
2057
2090
|
sections = [
|
|
2058
|
-
_render_claude_terminal(
|
|
2091
|
+
_render_claude_terminal(
|
|
2092
|
+
command, claude,
|
|
2093
|
+
tz=getattr(args, "_claude_resolved_tz", None),
|
|
2094
|
+
),
|
|
2059
2095
|
_render_codex_terminal(
|
|
2060
2096
|
command, result, diff=diff,
|
|
2061
2097
|
include_unavailable_diagnostic=not project_degradation,
|
|
@@ -1008,8 +1008,15 @@ def _authoritative_record_usage(
|
|
|
1008
1008
|
selected-state writes. This frame owns the lock only on the
|
|
1009
1009
|
``lock_held=False`` path, so that is the only path that can fire the
|
|
1010
1010
|
deferred nudge itself; a caller passing ``lock_held=True`` owns the lock
|
|
1011
|
-
and must supply its own sink
|
|
1011
|
+
and must supply its own sink. The fail-closed guard below enforces that
|
|
1012
|
+
contract before any tombstone or journal write begins.
|
|
1012
1013
|
"""
|
|
1014
|
+
if lock_held and nudge_dashboard and nudge_sink is None:
|
|
1015
|
+
return _AuthoritativeRecordResult(
|
|
1016
|
+
"record_failed",
|
|
1017
|
+
"lock_held authoritative record requires nudge_sink",
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1013
1020
|
if not lock_held:
|
|
1014
1021
|
deferred = []
|
|
1015
1022
|
try:
|