cctally 1.96.2 → 1.98.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 +41 -0
- package/bin/_cctally_dashboard.py +595 -46
- package/bin/_cctally_dashboard_envelope.py +57 -23
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +123 -36
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +515 -63
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_dashboard_sources.py +837 -102
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_share.py +191 -36
- package/bin/_lib_snapshot_cache.py +12 -0
- package/bin/cctally +48 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +1 -0
- package/dashboard/static/assets/index-CChXFhs_.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BRFMIN18.js +0 -97
- package/dashboard/static/assets/index-Crj7bzyj.css +0 -1
|
@@ -1019,17 +1019,128 @@ def _claude_project_key_for_path(snapshot, project_path: object) -> str | None:
|
|
|
1019
1019
|
return None
|
|
1020
1020
|
|
|
1021
1021
|
|
|
1022
|
+
def _claude_aggregate_project_rows(snapshot) -> "tuple":
|
|
1023
|
+
"""The published bounded project rows, or an empty tuple.
|
|
1024
|
+
|
|
1025
|
+
They live on the frozen source bundle rather than on the legacy envelope,
|
|
1026
|
+
and a provider whose bounded fold failed publishes none.
|
|
1027
|
+
"""
|
|
1028
|
+
bundle = getattr(snapshot, "source_bundle", None)
|
|
1029
|
+
try:
|
|
1030
|
+
data = bundle.sources["claude"].data
|
|
1031
|
+
except (AttributeError, KeyError, TypeError):
|
|
1032
|
+
return ()
|
|
1033
|
+
if not isinstance(data, Mapping):
|
|
1034
|
+
return ()
|
|
1035
|
+
projects = data.get("projects")
|
|
1036
|
+
aggregate = projects.get("aggregate") if isinstance(projects, Mapping) else None
|
|
1037
|
+
rows = aggregate.get("rows") if isinstance(aggregate, Mapping) else None
|
|
1038
|
+
return tuple(rows) if isinstance(rows, (list, tuple)) else ()
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
# The window sizes `GET /api/source/<source>/project/<key>?weeks=N` accepts.
|
|
1042
|
+
PROJECT_WINDOW_WEEKS_CHOICES = (1, 4, 8, 12)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def resolve_aggregate_project_window_weeks(
|
|
1046
|
+
requested: int, *, shared_start_at: object, current_week_start_at: object,
|
|
1047
|
+
) -> int:
|
|
1048
|
+
"""The drill window a row published by the All ranking must resolve with.
|
|
1049
|
+
|
|
1050
|
+
The two windows are anchored differently and one does not contain the
|
|
1051
|
+
other. The ranking is ``n`` calendar days ending today. The drill is
|
|
1052
|
+
``weeks_back`` weeks anchored at the CURRENT MONDAY —
|
|
1053
|
+
``[cw_start - 7 * (weeks_back - 1), cw_start + 7)`` — so at the default four
|
|
1054
|
+
weeks it reaches twenty-one days before that Monday, which is twenty-one to
|
|
1055
|
+
twenty-seven days before today. Against a thirty-day ranking that leaves up
|
|
1056
|
+
to nine days at the start of the ranking window ranked and undrillable: a
|
|
1057
|
+
row published at a real dollar figure opens a modal reporting
|
|
1058
|
+
``window_cost_usd: 0.0`` and no sessions.
|
|
1059
|
+
|
|
1060
|
+
So the window is the SMALLEST accepted choice whose span reaches the
|
|
1061
|
+
resolved shared start, and never narrower than the caller asked for. When
|
|
1062
|
+
NO accepted choice reaches it — a shared start further back than twelve
|
|
1063
|
+
weeks before the current Monday — the widest choice is returned instead,
|
|
1064
|
+
which is the closest the accepted set can come rather than a span that
|
|
1065
|
+
reaches. It is not silent either way: ``window_weeks`` is in the payload
|
|
1066
|
+
and the detail states the resolved span it covered, so a window that falls
|
|
1067
|
+
short says so rather than implying it reached. When either bound is
|
|
1068
|
+
unresolvable the caller's value stands, because guessing a wider window
|
|
1069
|
+
over an unknown range would state a span nothing established.
|
|
1070
|
+
|
|
1071
|
+
Pure. ``_build_claude_source_detail`` supplies the two bounds.
|
|
1072
|
+
"""
|
|
1073
|
+
if not isinstance(shared_start_at, str) or not isinstance(
|
|
1074
|
+
current_week_start_at, str,
|
|
1075
|
+
):
|
|
1076
|
+
return requested
|
|
1077
|
+
try:
|
|
1078
|
+
shared_start = parse_iso_datetime(
|
|
1079
|
+
shared_start_at, "aggregates.range.start_at",
|
|
1080
|
+
)
|
|
1081
|
+
cw_start = parse_iso_datetime(
|
|
1082
|
+
current_week_start_at, "projects.current_week.week_start_at",
|
|
1083
|
+
)
|
|
1084
|
+
except ValueError:
|
|
1085
|
+
return requested
|
|
1086
|
+
covering = [
|
|
1087
|
+
weeks for weeks in PROJECT_WINDOW_WEEKS_CHOICES
|
|
1088
|
+
if cw_start - dt.timedelta(days=7 * (weeks - 1)) <= shared_start
|
|
1089
|
+
]
|
|
1090
|
+
if not covering:
|
|
1091
|
+
return max(PROJECT_WINDOW_WEEKS_CHOICES[-1], requested)
|
|
1092
|
+
return max(min(covering), requested)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _published_aggregate_start_at(snapshot) -> object:
|
|
1096
|
+
"""``sources.all.data.aggregates.range.start_at``, or ``None``."""
|
|
1097
|
+
bundle = getattr(snapshot, "source_bundle", None)
|
|
1098
|
+
try:
|
|
1099
|
+
data = bundle.sources["all"].data
|
|
1100
|
+
except (AttributeError, KeyError, TypeError):
|
|
1101
|
+
return None
|
|
1102
|
+
if not isinstance(data, Mapping):
|
|
1103
|
+
return None
|
|
1104
|
+
aggregates = data.get("aggregates")
|
|
1105
|
+
published = (
|
|
1106
|
+
aggregates.get("range") if isinstance(aggregates, Mapping) else None
|
|
1107
|
+
)
|
|
1108
|
+
return published.get("start_at") if isinstance(published, Mapping) else None
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _project_window_weeks_for_key(snapshot, key: str, requested: int) -> int:
|
|
1112
|
+
"""Widen the drill window for a key the All ranking published.
|
|
1113
|
+
|
|
1114
|
+
A key absent from the published aggregate rows was not ranked over the
|
|
1115
|
+
shared range, so nothing about that range describes it and the caller's
|
|
1116
|
+
window stands unchanged.
|
|
1117
|
+
"""
|
|
1118
|
+
if not any(
|
|
1119
|
+
isinstance(row, Mapping) and row.get("key") == key
|
|
1120
|
+
for row in _claude_aggregate_project_rows(snapshot)
|
|
1121
|
+
):
|
|
1122
|
+
return requested
|
|
1123
|
+
env = getattr(snapshot, "projects_envelope", None)
|
|
1124
|
+
current = env.get("current_week") if isinstance(env, Mapping) else None
|
|
1125
|
+
return resolve_aggregate_project_window_weeks(
|
|
1126
|
+
requested,
|
|
1127
|
+
shared_start_at=_published_aggregate_start_at(snapshot),
|
|
1128
|
+
current_week_start_at=(
|
|
1129
|
+
current.get("week_start_at") if isinstance(current, Mapping) else None
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1022
1134
|
def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
|
|
1023
1135
|
env = getattr(snapshot, "projects_envelope", None)
|
|
1024
|
-
if not isinstance(env, Mapping):
|
|
1025
|
-
return None
|
|
1026
1136
|
candidates: list[object] = []
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1137
|
+
if isinstance(env, Mapping):
|
|
1138
|
+
current = env.get("current_week")
|
|
1139
|
+
if isinstance(current, Mapping):
|
|
1140
|
+
candidates.extend(current.get("rows") or ())
|
|
1141
|
+
trend = env.get("trend")
|
|
1142
|
+
if isinstance(trend, Mapping):
|
|
1143
|
+
candidates.extend(trend.get("projects") or ())
|
|
1033
1144
|
for row in candidates:
|
|
1034
1145
|
if not isinstance(row, Mapping):
|
|
1035
1146
|
continue
|
|
@@ -1038,6 +1149,16 @@ def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
|
|
|
1038
1149
|
dashboard_resource_key("project", "claude", project_key) == key
|
|
1039
1150
|
):
|
|
1040
1151
|
return project_key
|
|
1152
|
+
# #556 S2 remediation: there is deliberately NO fallback to the published
|
|
1153
|
+
# aggregate rows here. A retained bundle outlives the per-tick envelope
|
|
1154
|
+
# rebuild, so its labels can be stale, and `_project_detail_for_window`
|
|
1155
|
+
# resolves whatever this returns against the CURRENT envelope. Labels are
|
|
1156
|
+
# population-sensitive, so a stale label either misses there anyway — the
|
|
1157
|
+
# same 404, reached one step later — or names a DIFFERENT bucket and serves
|
|
1158
|
+
# another project's sessions under this key. A guarded fallback would be
|
|
1159
|
+
# dead code besides: the aggregate key is minted from its label by the same
|
|
1160
|
+
# rule the loop above inverts, so any label the current envelope still
|
|
1161
|
+
# carries has already matched.
|
|
1041
1162
|
return None
|
|
1042
1163
|
|
|
1043
1164
|
|
|
@@ -1094,6 +1215,13 @@ def _build_claude_source_detail(
|
|
|
1094
1215
|
project_key = _claude_project_key_for_source_key(snapshot, key)
|
|
1095
1216
|
if project_key is None:
|
|
1096
1217
|
raise SourceResourceNotFound()
|
|
1218
|
+
# #556 S2 remediation: a row the All ranking published states a figure
|
|
1219
|
+
# over the shared range, so its detail has to reach that range. The
|
|
1220
|
+
# default four-week drill is anchored at the current Monday and misses
|
|
1221
|
+
# up to nine days at the start of a thirty-day ranking.
|
|
1222
|
+
window_weeks = _project_window_weeks_for_key(
|
|
1223
|
+
snapshot, key, window_weeks,
|
|
1224
|
+
)
|
|
1097
1225
|
conn = open_db()
|
|
1098
1226
|
try:
|
|
1099
1227
|
conn.execute("ATTACH DATABASE ? AS cache_db", (str(_cctally_core.CACHE_DB_PATH),))
|
|
@@ -2976,6 +3104,87 @@ def _group_a_daily_buckets(now_utc, *, n, display_tz):
|
|
|
2976
3104
|
cache_conn.close()
|
|
2977
3105
|
|
|
2978
3106
|
|
|
3107
|
+
def materialise_daily_calendar(
|
|
3108
|
+
view_rows,
|
|
3109
|
+
*,
|
|
3110
|
+
now_utc: "dt.datetime",
|
|
3111
|
+
n: int = 30,
|
|
3112
|
+
display_tz: "ZoneInfo | None" = None,
|
|
3113
|
+
) -> "list[DailyPanelRow]":
|
|
3114
|
+
"""Materialize the contiguous ``n``-day calendar. Pure, no I/O.
|
|
3115
|
+
|
|
3116
|
+
``view_rows`` is ``build_daily_view``'s gap-free newest-first row sequence,
|
|
3117
|
+
which carries the data-plane fields and leaves ``label`` and
|
|
3118
|
+
``intensity_bucket`` at dataclass defaults (spec §4.4). This adapter
|
|
3119
|
+
overlays those rows onto the calendar window, adds a zero-cost row for
|
|
3120
|
+
every gap day so the heatmap shows a faded cell, and fills the two
|
|
3121
|
+
presentation-only fields.
|
|
3122
|
+
|
|
3123
|
+
#556 S2 §6.3a extracted this from ``_dashboard_build_daily_panel``, which
|
|
3124
|
+
opens its own connection through Group A or ``get_entries`` and cannot be
|
|
3125
|
+
called from the pinned source-bundle fold. Unlike that function, this one
|
|
3126
|
+
emits a COMPLETE shape for an empty provider rather than nothing: under All
|
|
3127
|
+
an empty provider is a zero leg, and a zero leg still has a shape, so the
|
|
3128
|
+
client never needs a second source for the calendar it renders.
|
|
3129
|
+
"""
|
|
3130
|
+
rows_by_date = {r.date: r for r in (view_rows or ())}
|
|
3131
|
+
today_local = (
|
|
3132
|
+
now_utc.astimezone(display_tz) if display_tz is not None
|
|
3133
|
+
# internal fallback: host-local intentional
|
|
3134
|
+
else now_utc.astimezone()
|
|
3135
|
+
).date()
|
|
3136
|
+
|
|
3137
|
+
rows: list[DailyPanelRow] = []
|
|
3138
|
+
for i in range(n):
|
|
3139
|
+
d = today_local - dt.timedelta(days=i)
|
|
3140
|
+
date_str = d.isoformat()
|
|
3141
|
+
existing = rows_by_date.get(date_str)
|
|
3142
|
+
if existing is not None:
|
|
3143
|
+
# Use the view-model row but fill the presentation-only
|
|
3144
|
+
# ``label`` (intensity_bucket is set by
|
|
3145
|
+
# ``_compute_intensity_buckets`` below).
|
|
3146
|
+
rows.append(dataclasses.replace(existing, label=date_str[5:]))
|
|
3147
|
+
else:
|
|
3148
|
+
# Zero-cost gap day: tokens default to 0, cache_hit_pct to None
|
|
3149
|
+
# (avoids /0 and signals 'no data' cleanly to the modal tile).
|
|
3150
|
+
rows.append(DailyPanelRow(
|
|
3151
|
+
date=date_str,
|
|
3152
|
+
label=date_str[5:],
|
|
3153
|
+
cost_usd=0.0,
|
|
3154
|
+
is_today=(d == today_local),
|
|
3155
|
+
intensity_bucket=0,
|
|
3156
|
+
models=[],
|
|
3157
|
+
))
|
|
3158
|
+
|
|
3159
|
+
_compute_intensity_buckets(rows)
|
|
3160
|
+
return rows
|
|
3161
|
+
|
|
3162
|
+
|
|
3163
|
+
def daily_panel_row_to_wire(row: "DailyPanelRow") -> dict:
|
|
3164
|
+
"""One wire shape for a daily row, whichever sibling published it.
|
|
3165
|
+
|
|
3166
|
+
``snapshot_to_envelope``'s ``_daily_row_to_dict`` delegates here, so the
|
|
3167
|
+
All-only ``periods.daily_aggregate.rows`` sibling and the legacy
|
|
3168
|
+
``periods.daily.rows`` cannot drift into two shapes the client would have
|
|
3169
|
+
to tell apart.
|
|
3170
|
+
"""
|
|
3171
|
+
return {
|
|
3172
|
+
"date": row.date,
|
|
3173
|
+
"label": row.label,
|
|
3174
|
+
"cost_usd": row.cost_usd,
|
|
3175
|
+
"is_today": row.is_today,
|
|
3176
|
+
"intensity_bucket": row.intensity_bucket,
|
|
3177
|
+
"models": list(row.models),
|
|
3178
|
+
# ---- v2.3 additions ----
|
|
3179
|
+
"input_tokens": row.input_tokens,
|
|
3180
|
+
"output_tokens": row.output_tokens,
|
|
3181
|
+
"cache_creation_tokens": row.cache_creation_tokens,
|
|
3182
|
+
"cache_read_tokens": row.cache_read_tokens,
|
|
3183
|
+
"total_tokens": row.total_tokens,
|
|
3184
|
+
"cache_hit_pct": row.cache_hit_pct,
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
|
|
2979
3188
|
def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
|
|
2980
3189
|
now_utc: "dt.datetime",
|
|
2981
3190
|
*,
|
|
@@ -3050,42 +3259,13 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
|
|
|
3050
3259
|
if not view.rows:
|
|
3051
3260
|
return []
|
|
3052
3261
|
|
|
3053
|
-
# Materialize the contiguous N-day window.
|
|
3054
|
-
#
|
|
3055
|
-
#
|
|
3056
|
-
#
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
now_utc.astimezone(display_tz) if display_tz is not None
|
|
3061
|
-
# internal fallback: host-local intentional
|
|
3062
|
-
else now_utc.astimezone()
|
|
3063
|
-
).date()
|
|
3064
|
-
|
|
3065
|
-
rows: list[DailyPanelRow] = []
|
|
3066
|
-
for i in range(n):
|
|
3067
|
-
d = today_local - dt.timedelta(days=i)
|
|
3068
|
-
date_str = d.isoformat()
|
|
3069
|
-
existing = rows_by_date.get(date_str)
|
|
3070
|
-
if existing is not None:
|
|
3071
|
-
# Use the view-model row but fill the presentation-only
|
|
3072
|
-
# ``label`` (intensity_bucket is set by
|
|
3073
|
-
# ``_compute_intensity_buckets`` below).
|
|
3074
|
-
rows.append(dataclasses.replace(existing, label=date_str[5:]))
|
|
3075
|
-
else:
|
|
3076
|
-
# Zero-cost gap day: tokens default to 0, cache_hit_pct to None
|
|
3077
|
-
# (avoids /0 and signals 'no data' cleanly to the modal tile).
|
|
3078
|
-
rows.append(DailyPanelRow(
|
|
3079
|
-
date=date_str,
|
|
3080
|
-
label=date_str[5:],
|
|
3081
|
-
cost_usd=0.0,
|
|
3082
|
-
is_today=(d == today_local),
|
|
3083
|
-
intensity_bucket=0,
|
|
3084
|
-
models=[],
|
|
3085
|
-
))
|
|
3086
|
-
|
|
3087
|
-
_compute_intensity_buckets(rows)
|
|
3088
|
-
return rows
|
|
3262
|
+
# Materialize the contiguous N-day window. #556 S2 §6.3a extracted the
|
|
3263
|
+
# block below into ``materialise_daily_calendar`` so the All-only Claude
|
|
3264
|
+
# daily leg can reach it without this function's I/O; the Claude tab's
|
|
3265
|
+
# behaviour is unchanged, including the empty-provider early return above.
|
|
3266
|
+
return materialise_daily_calendar(
|
|
3267
|
+
view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
|
|
3268
|
+
)
|
|
3089
3269
|
|
|
3090
3270
|
|
|
3091
3271
|
# --- Projects panel / modal (spec 2026-05-19-projects-panel-design.md) ------
|
|
@@ -3217,6 +3397,202 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
|
|
|
3217
3397
|
yield row
|
|
3218
3398
|
|
|
3219
3399
|
|
|
3400
|
+
# === #556 S2 — the shared cross-provider aggregate range (spec §3.2, §3.4) ===
|
|
3401
|
+
#
|
|
3402
|
+
# One UTC interval, resolved once per tick and passed immutably to both Claude
|
|
3403
|
+
# folds. Membership is ``shared_start <= parsed_timestamp < shared_end_exclusive``
|
|
3404
|
+
# enforced on PARSED datetimes with microseconds preserved.
|
|
3405
|
+
#
|
|
3406
|
+
# ``_projects_iter_session_entries`` deliberately cannot serve this read. It
|
|
3407
|
+
# implements an inclusive ``[since, until]`` query, truncates both bounds to
|
|
3408
|
+
# whole seconds and spells them with a ``Z`` suffix, and compares with ``<=``.
|
|
3409
|
+
# Production ingestion writes ``timestamp.astimezone(utc).isoformat()``
|
|
3410
|
+
# (``bin/_cctally_cache.py``), so a stored value keeps its ``+00:00`` offset and
|
|
3411
|
+
# its microseconds. SQLite compares that TEXT column lexically, and ``+`` (0x2B)
|
|
3412
|
+
# sorts BELOW ``Z`` (0x5A) — so an entry exactly at the lower bound would be
|
|
3413
|
+
# dropped, while an entry inside the closing second would be admitted past the
|
|
3414
|
+
# exclusive upper bound. This iterator therefore uses the SQL predicate only as
|
|
3415
|
+
# an outward-widened CANDIDATE filter and enforces the real comparison in Python.
|
|
3416
|
+
#
|
|
3417
|
+
# The published range's `kind` and `label` are NOT declared here. They live once,
|
|
3418
|
+
# beside the canonicaliser that emits them, as `AGGREGATE_RANGE_KIND` and
|
|
3419
|
+
# `AGGREGATE_RANGE_LABEL` in `bin/_lib_dashboard_sources.py`.
|
|
3420
|
+
|
|
3421
|
+
|
|
3422
|
+
def resolve_shared_range(daily_panel, *, now_utc, display_tz, n: int = 30):
|
|
3423
|
+
"""Return ``(shared_start, shared_end_exclusive)`` for the All aggregates.
|
|
3424
|
+
|
|
3425
|
+
``shared_start`` is the earliest day of the already-built ``n``-day daily
|
|
3426
|
+
panel, taken at midnight in the resolved display timezone.
|
|
3427
|
+
``shared_end_exclusive`` is ``now_utc + 1 microsecond``, which is the bound
|
|
3428
|
+
the Codex projects read already applies, so the Codex tab stays
|
|
3429
|
+
byte-stable.
|
|
3430
|
+
|
|
3431
|
+
BOTH branches floor to display-timezone midnight, and the fallback branch
|
|
3432
|
+
resolves the same calendar day the panel branch would have named **provided
|
|
3433
|
+
the panel is exactly ``n`` contiguous days ending today**. Every production
|
|
3434
|
+
panel has that shape (``materialise_daily_calendar`` gap-fills a contiguous
|
|
3435
|
+
calendar), so the two branches agree there. They do NOT agree in general: a
|
|
3436
|
+
caller that hands in a sparse or shorter panel gets that panel's own oldest
|
|
3437
|
+
day from the first branch and ``today - (n - 1) days`` from the fallback.
|
|
3438
|
+
Two separate defects require the flooring, and neither is visible with a
|
|
3439
|
+
pinned bound:
|
|
3440
|
+
|
|
3441
|
+
1. The start is folded into version material at DAY granularity
|
|
3442
|
+
(``aggregate_scope_identity``), while ``compose_all_aggregates`` compares
|
|
3443
|
+
the two providers' carriers on the exact canonical string. A start that
|
|
3444
|
+
moves within a day therefore passes the reuse gate for an unchanged
|
|
3445
|
+
provider while a rebuilt provider records the newer instant, and the two
|
|
3446
|
+
carriers then disagree. The old fallback, ``now_utc - 30 days``, is a
|
|
3447
|
+
microsecond-precise instant that advances on every tick, so on a
|
|
3448
|
+
Claude-inactive, Codex-active install every tick withheld both
|
|
3449
|
+
aggregates as ``retained_range_mismatch`` and published no range at all.
|
|
3450
|
+
2. ``daily_aggregate.rows`` is ``n`` CALENDAR days ending today
|
|
3451
|
+
(``materialise_daily_calendar``). A rolling ``n * 24h`` fallback started
|
|
3452
|
+
part-way through a further day, so the published range stated a span the
|
|
3453
|
+
row table did not cover.
|
|
3454
|
+
"""
|
|
3455
|
+
now_utc = now_utc.astimezone(dt.timezone.utc)
|
|
3456
|
+
if daily_panel:
|
|
3457
|
+
# The panel is newest-first, so the oldest of its FIRST `n` rows is the
|
|
3458
|
+
# `n`-day floor. Reading `daily_panel[-1]` unconditionally ignored `n`,
|
|
3459
|
+
# so a caller asking for a shorter window than the panel it passed got
|
|
3460
|
+
# the panel's full extent from this branch and the shorter window from
|
|
3461
|
+
# the fallback — two branches describing different spans under one
|
|
3462
|
+
# parameter.
|
|
3463
|
+
earliest_day = dt.date.fromisoformat(
|
|
3464
|
+
daily_panel[min(max(n, 1), len(daily_panel)) - 1].date,
|
|
3465
|
+
)
|
|
3466
|
+
else:
|
|
3467
|
+
today_local = (
|
|
3468
|
+
now_utc.astimezone(display_tz) if display_tz is not None
|
|
3469
|
+
# internal fallback: host-local intentional
|
|
3470
|
+
else now_utc.astimezone()
|
|
3471
|
+
).date()
|
|
3472
|
+
earliest_day = today_local - dt.timedelta(days=n - 1)
|
|
3473
|
+
if display_tz is not None:
|
|
3474
|
+
start = dt.datetime.combine(
|
|
3475
|
+
earliest_day, dt.time.min, tzinfo=display_tz,
|
|
3476
|
+
).astimezone(dt.timezone.utc)
|
|
3477
|
+
else:
|
|
3478
|
+
# internal fallback: host-local intentional
|
|
3479
|
+
start = dt.datetime.combine(
|
|
3480
|
+
earliest_day, dt.time.min,
|
|
3481
|
+
).astimezone(dt.timezone.utc)
|
|
3482
|
+
return start, now_utc + dt.timedelta(microseconds=1)
|
|
3483
|
+
|
|
3484
|
+
|
|
3485
|
+
def _shared_range_candidate_bounds(start, end_exclusive):
|
|
3486
|
+
"""Whole-second SQL bounds widened OUTWARD on both ends.
|
|
3487
|
+
|
|
3488
|
+
One second of slack in each direction is what makes the candidate set a
|
|
3489
|
+
strict superset regardless of how a stored timestamp spells its offset
|
|
3490
|
+
(``+00:00`` or ``Z``) or whether it carries a fractional part.
|
|
3491
|
+
"""
|
|
3492
|
+
low = start.astimezone(dt.timezone.utc).replace(
|
|
3493
|
+
microsecond=0,
|
|
3494
|
+
) - dt.timedelta(seconds=1)
|
|
3495
|
+
high = end_exclusive.astimezone(dt.timezone.utc).replace(
|
|
3496
|
+
microsecond=0,
|
|
3497
|
+
) + dt.timedelta(seconds=1)
|
|
3498
|
+
return low.isoformat(), high.isoformat()
|
|
3499
|
+
|
|
3500
|
+
|
|
3501
|
+
def iter_shared_range_entries(conn, *, start, end_exclusive):
|
|
3502
|
+
"""Yield ``session_entries`` rows inside ``[start, end_exclusive)``.
|
|
3503
|
+
|
|
3504
|
+
Column order matches ``_projects_iter_session_entries`` exactly, so
|
|
3505
|
+
``_fold_projects_entry``'s per-row arithmetic consumes these rows unchanged
|
|
3506
|
+
(spec §3.4 — reuse the arithmetic, not the week-bucket gate).
|
|
3507
|
+
|
|
3508
|
+
Two stages: an indexed candidate ``SELECT`` over widened whole-second
|
|
3509
|
+
bounds, then the authoritative half-open comparison on parsed datetimes.
|
|
3510
|
+
"""
|
|
3511
|
+
since_iso, until_iso = _shared_range_candidate_bounds(start, end_exclusive)
|
|
3512
|
+
cur = conn.execute(
|
|
3513
|
+
"SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
|
|
3514
|
+
" e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
|
|
3515
|
+
" e.cost_usd_raw, e.source_path, "
|
|
3516
|
+
" sf.session_id, sf.project_path, "
|
|
3517
|
+
" e.cache_create_1h_tokens, e.speed "
|
|
3518
|
+
"FROM session_entries e "
|
|
3519
|
+
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
3520
|
+
"WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
|
|
3521
|
+
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
3522
|
+
(since_iso, until_iso),
|
|
3523
|
+
)
|
|
3524
|
+
for row in cur:
|
|
3525
|
+
ts = parse_iso_datetime(row[1], "session_entries.timestamp_utc")
|
|
3526
|
+
if start <= ts < end_exclusive:
|
|
3527
|
+
yield row
|
|
3528
|
+
|
|
3529
|
+
|
|
3530
|
+
def _shared_range_row_to_usage_entry(row):
|
|
3531
|
+
"""Rebuild the ``UsageEntry`` the daily aggregator consumes.
|
|
3532
|
+
|
|
3533
|
+
Field-for-field identical to ``_cctally_cache.iter_entries``, including
|
|
3534
|
+
``datetime.fromisoformat`` rather than ``parse_iso_datetime`` — the daily
|
|
3535
|
+
aggregate has to reproduce what the legacy daily panel would compute over
|
|
3536
|
+
the same rows, so this construction must not diverge from that one.
|
|
3537
|
+
"""
|
|
3538
|
+
(_entry_id, ts_iso, model, input_tok, output_tok,
|
|
3539
|
+
cache_create, cache_read, cost_raw, source_path,
|
|
3540
|
+
_session_id, _project_path, cache_1h, speed) = row
|
|
3541
|
+
return _cctally().UsageEntry(
|
|
3542
|
+
timestamp=dt.datetime.fromisoformat(ts_iso),
|
|
3543
|
+
model=model,
|
|
3544
|
+
usage=claude_usage_dict( # #195 chokepoint
|
|
3545
|
+
input_tokens=input_tok,
|
|
3546
|
+
output_tokens=output_tok,
|
|
3547
|
+
cache_creation_tokens=cache_create,
|
|
3548
|
+
cache_read_tokens=cache_read,
|
|
3549
|
+
cache_1h_tokens=cache_1h,
|
|
3550
|
+
speed=speed,
|
|
3551
|
+
),
|
|
3552
|
+
cost_usd=cost_raw,
|
|
3553
|
+
source_path=source_path,
|
|
3554
|
+
)
|
|
3555
|
+
|
|
3556
|
+
|
|
3557
|
+
def fold_daily_over_range(rows, *, display_tz=None, mode: str = "auto"):
|
|
3558
|
+
"""Fold the shared candidate stream into per-day ``BucketUsage``.
|
|
3559
|
+
|
|
3560
|
+
Consumes the SAME already-materialised sequence the projects fold reads
|
|
3561
|
+
(spec §3.4 — one candidate read, two folds). ``_aggregate_daily`` skips
|
|
3562
|
+
``<synthetic>`` rows itself, so both folds share that policy.
|
|
3563
|
+
"""
|
|
3564
|
+
return _aggregate_daily(
|
|
3565
|
+
[_shared_range_row_to_usage_entry(row) for row in rows],
|
|
3566
|
+
mode=mode,
|
|
3567
|
+
tz=display_tz,
|
|
3568
|
+
)
|
|
3569
|
+
|
|
3570
|
+
|
|
3571
|
+
def build_daily_aggregate_rows(
|
|
3572
|
+
rows,
|
|
3573
|
+
*,
|
|
3574
|
+
now_utc: "dt.datetime",
|
|
3575
|
+
display_tz=None,
|
|
3576
|
+
n: int = 30,
|
|
3577
|
+
mode: str = "auto",
|
|
3578
|
+
) -> "list[DailyPanelRow]":
|
|
3579
|
+
"""The complete canonical thirty-day shape for the All Daily aggregate.
|
|
3580
|
+
|
|
3581
|
+
Pure apart from the caller's already-completed read. Folds the shared
|
|
3582
|
+
candidate stream, runs it through the same ``build_daily_view`` data plane
|
|
3583
|
+
the legacy panel uses, then materializes the contiguous calendar — so an
|
|
3584
|
+
empty Claude provider still publishes a full zero-cost shape (§6.3a).
|
|
3585
|
+
"""
|
|
3586
|
+
buckets = fold_daily_over_range(rows, display_tz=display_tz, mode=mode)
|
|
3587
|
+
view = _cctally().build_daily_view(
|
|
3588
|
+
(), now_utc=now_utc, display_tz=display_tz, mode=mode,
|
|
3589
|
+
aggregated_override=buckets,
|
|
3590
|
+
)
|
|
3591
|
+
return materialise_daily_calendar(
|
|
3592
|
+
view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
|
|
3593
|
+
)
|
|
3594
|
+
|
|
3595
|
+
|
|
3220
3596
|
class _ProjWeekBucket(NamedTuple):
|
|
3221
3597
|
"""One (bucket_path, week) immutable aggregate for the projects-envelope
|
|
3222
3598
|
per-week cache (#269 §14 Win 2).
|
|
@@ -3248,7 +3624,7 @@ def _fold_projects_entry(
|
|
|
3248
3624
|
row: tuple,
|
|
3249
3625
|
*,
|
|
3250
3626
|
resolver_cache: dict,
|
|
3251
|
-
week_start: "dt.datetime",
|
|
3627
|
+
week_start: "dt.datetime | None",
|
|
3252
3628
|
) -> "float | None":
|
|
3253
3629
|
"""Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
|
|
3254
3630
|
per-row body, #271 §20 Codex-P1a).
|
|
@@ -3267,6 +3643,13 @@ def _fold_projects_entry(
|
|
|
3267
3643
|
order-safe for the warm append because every delta row sorts strictly after
|
|
3268
3644
|
``tail`` (#271 §20), so a delta row is never the first-seen of a bucket that
|
|
3269
3645
|
already exists in ``mut``.
|
|
3646
|
+
|
|
3647
|
+
``week_start=None`` (#556 S2 §3.4) disables ONLY the week-bucket gate, for
|
|
3648
|
+
the range-native fold that ranks projects over one absolute interval. Every
|
|
3649
|
+
other caller passes a real week start and is byte-unchanged. The gate is the
|
|
3650
|
+
single line below; the cost, identity, session and first/last-seen
|
|
3651
|
+
arithmetic beneath it is what both callers share, and sharing it is what
|
|
3652
|
+
keeps the dashboard reconcilable with the CLI.
|
|
3270
3653
|
"""
|
|
3271
3654
|
c = _cctally()
|
|
3272
3655
|
(entry_id, ts_iso, model, input_tok, output_tok,
|
|
@@ -3275,7 +3658,7 @@ def _fold_projects_entry(
|
|
|
3275
3658
|
if model == "<synthetic>":
|
|
3276
3659
|
return None
|
|
3277
3660
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
3278
|
-
if _projects_week_start_monday_utc(ts) != week_start:
|
|
3661
|
+
if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
|
|
3279
3662
|
return None
|
|
3280
3663
|
entry_cost = _calculate_entry_cost(
|
|
3281
3664
|
model,
|
|
@@ -3316,6 +3699,172 @@ def _fold_projects_entry(
|
|
|
3316
3699
|
return entry_cost
|
|
3317
3700
|
|
|
3318
3701
|
|
|
3702
|
+
def fold_projects_over_range(rows, *, resolver_cache=None) -> "dict[str, dict]":
|
|
3703
|
+
"""Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
|
|
3704
|
+
|
|
3705
|
+
#556 S2 §3.4. Takes rows rather than a connection because one candidate read
|
|
3706
|
+
serves both All-only Claude folds: reopening or rescanning would price the
|
|
3707
|
+
same thirty-day population twice and extend the pinned cache snapshot's
|
|
3708
|
+
WAL-holding lifetime for no gain.
|
|
3709
|
+
|
|
3710
|
+
Returns the RAW ``mut`` shape ``_fold_projects_entry`` maintains —
|
|
3711
|
+
``{bucket_path: {"cost_usd", "sessions" (a set), "first_seen", "last_seen",
|
|
3712
|
+
"first_order", "first_id", "first_key" (the ``ProjectKey``)}}``. The
|
|
3713
|
+
``ProjectKey`` is retained deliberately: §3.8 disambiguates labels over
|
|
3714
|
+
exactly this bounded population, and the legacy builder's collision-safe
|
|
3715
|
+
label has already been replaced by an opaque key by the time the source
|
|
3716
|
+
bundle sees ``claude_data``, so there is nothing left to capture there.
|
|
3717
|
+
|
|
3718
|
+
No ``attributed_pct`` is computed. Weekly quota attribution divides by a
|
|
3719
|
+
subscription week's total (``_build_projects_envelope`` does it later), which
|
|
3720
|
+
is not meaningful for an absolute-range ranking.
|
|
3721
|
+
"""
|
|
3722
|
+
mut: "dict[str, dict]" = {}
|
|
3723
|
+
cache = {} if resolver_cache is None else resolver_cache
|
|
3724
|
+
for row in rows:
|
|
3725
|
+
_fold_projects_entry(mut, row, resolver_cache=cache, week_start=None)
|
|
3726
|
+
return mut
|
|
3727
|
+
|
|
3728
|
+
|
|
3729
|
+
def legacy_project_labels(projects_envelope: object) -> "dict[str, str]":
|
|
3730
|
+
"""``bucket_path`` -> legacy display key, over the two published collections.
|
|
3731
|
+
|
|
3732
|
+
These are exactly the collections ``_project_detail_for_window`` resolves a
|
|
3733
|
+
requested display key against (`current_week.rows`, then `trend.projects`),
|
|
3734
|
+
so this is the population in which agreement between the aggregate identity
|
|
3735
|
+
and the legacy one is both possible and useful. A bucket outside it cannot
|
|
3736
|
+
be served by the drill-down under either label.
|
|
3737
|
+
|
|
3738
|
+
Pure; the caller supplies the envelope the sync thread already built.
|
|
3739
|
+
"""
|
|
3740
|
+
mapped: "dict[str, str]" = {}
|
|
3741
|
+
env = projects_envelope if isinstance(projects_envelope, Mapping) else {}
|
|
3742
|
+
current = env.get("current_week")
|
|
3743
|
+
trend = env.get("trend")
|
|
3744
|
+
collections = (
|
|
3745
|
+
(current.get("rows") if isinstance(current, Mapping) else None),
|
|
3746
|
+
(trend.get("projects") if isinstance(trend, Mapping) else None),
|
|
3747
|
+
)
|
|
3748
|
+
for collection in collections:
|
|
3749
|
+
for row in collection or ():
|
|
3750
|
+
if not isinstance(row, Mapping):
|
|
3751
|
+
continue
|
|
3752
|
+
bucket_path = row.get("bucket_path")
|
|
3753
|
+
display_key = row.get("key")
|
|
3754
|
+
if isinstance(bucket_path, str) and bucket_path and (
|
|
3755
|
+
isinstance(display_key, str) and display_key
|
|
3756
|
+
):
|
|
3757
|
+
mapped.setdefault(bucket_path, display_key)
|
|
3758
|
+
return mapped
|
|
3759
|
+
|
|
3760
|
+
|
|
3761
|
+
def build_project_aggregate_rows(
|
|
3762
|
+
rows, *, resolver_cache=None, legacy_labels=None,
|
|
3763
|
+
) -> "list[dict]":
|
|
3764
|
+
"""Published `providers.claude.projects.aggregate.rows` (spec §3.5.1).
|
|
3765
|
+
|
|
3766
|
+
Folds the shared candidate stream, labels each bucket, and publishes it as
|
|
3767
|
+
``{key, label, source, cost_usd, sessions_count, drillable}``.
|
|
3768
|
+
|
|
3769
|
+
**The label is the legacy display key wherever the legacy population knows
|
|
3770
|
+
the bucket, and the bounded disambiguated label otherwise.** The opaque key
|
|
3771
|
+
is minted from whichever label is published, through the same rule the
|
|
3772
|
+
legacy Claude project rows use (``dashboard_resource_key("project",
|
|
3773
|
+
"claude", <display key>)``).
|
|
3774
|
+
|
|
3775
|
+
**The published population is not the routable one, and each row says
|
|
3776
|
+
which it is in.** The legacy population is not a superset of the bounded
|
|
3777
|
+
one on either axis. It is not a superset in COST, because ``trend_projects``
|
|
3778
|
+
skips a bucket whose cost is 0.0 across every trend week and
|
|
3779
|
+
``current_week.rows`` carries only buckets with a current-week entry. It is
|
|
3780
|
+
not a superset in TIME either: the envelope walks
|
|
3781
|
+
``[weeks_full[0], cw_start + 7d]`` and drops any entry whose
|
|
3782
|
+
Monday-anchored week falls outside ``weeks_full``, so an entry after the
|
|
3783
|
+
current week's nominal end is invisible to it while this fold correctly
|
|
3784
|
+
reaches ``now``. ``tests/fixtures/dashboard/tz-override`` is that shape: one
|
|
3785
|
+
priced project at exactly ``now``, published in the aggregate and absent
|
|
3786
|
+
from both legacy collections.
|
|
3787
|
+
|
|
3788
|
+
Filtering the published rows to the legacy population would drop that real
|
|
3789
|
+
row rather than fix it, and widening ``_build_projects_envelope``'s window
|
|
3790
|
+
is out of scope here because the Claude tab and the reconcile harness both
|
|
3791
|
+
read it. So the gap is resolved at the SEAM instead: ``drillable`` states,
|
|
3792
|
+
per row, whether the drill-down can reach that bucket, computed from the
|
|
3793
|
+
same collections ``_claude_project_key_for_source_key`` searches. A
|
|
3794
|
+
``False`` row keeps its rank, its label and its cost — the ranking stays
|
|
3795
|
+
complete — and the client renders it without a drill affordance rather
|
|
3796
|
+
than offering an interaction that 404s.
|
|
3797
|
+
|
|
3798
|
+
``drillable`` is exactly ``bucket_path in legacy_labels``. That is the
|
|
3799
|
+
condition under which the published label IS a legacy display key, so the
|
|
3800
|
+
minted opaque key is the one the route inverts. Deliberately NOT "the
|
|
3801
|
+
published label happens to appear among the legacy display keys": for a
|
|
3802
|
+
bucket the legacy population does not carry, a bounded label that collided
|
|
3803
|
+
with some OTHER bucket's legacy key would resolve, and serve that other
|
|
3804
|
+
project's sessions under this row's identity. A mis-route is worse than a
|
|
3805
|
+
withheld drill, so the predicate names the bucket, not the string.
|
|
3806
|
+
|
|
3807
|
+
The split is what makes the aggregate rows ROUTABLE. Labels are
|
|
3808
|
+
population-sensitive by design (`bin/_cctally_project.py`), so
|
|
3809
|
+
disambiguating over the bounded thirty-day population and over the legacy
|
|
3810
|
+
twelve-week one can produce different overrides for the same project — a
|
|
3811
|
+
project sharing a basename with an older root forces an override in one
|
|
3812
|
+
population and not in the other. Two labels means two opaque keys, and the
|
|
3813
|
+
drill-down resolves the requested key against the LEGACY display keys
|
|
3814
|
+
(`_claude_project_key_for_source_key` -> `_project_detail_for_window`), so
|
|
3815
|
+
a bounded-only key 404s even for a project both collections know.
|
|
3816
|
+
Preferring the legacy key where one exists makes the two identities agree
|
|
3817
|
+
by construction, and it does not weaken §9.2's twin-pair requirement:
|
|
3818
|
+
the legacy population disambiguates that pair too, so the two rows keep
|
|
3819
|
+
distinct labels and distinct identities.
|
|
3820
|
+
|
|
3821
|
+
``legacy_labels`` is ``{bucket_path: display_key}`` from
|
|
3822
|
+
``legacy_project_labels``. A bucket it does not carry falls back to the
|
|
3823
|
+
bounded disambiguated label. ``None`` means "no legacy population was
|
|
3824
|
+
supplied" and is NOT a production path: production withholds the aggregate
|
|
3825
|
+
when no envelope was built (`_tui_build_claude_aggregates`), because
|
|
3826
|
+
publishing rows against an unknown routable population is the silent
|
|
3827
|
+
identity change this rule exists to prevent. It is kept for kernel callers
|
|
3828
|
+
that fold a store with no envelope beside it.
|
|
3829
|
+
|
|
3830
|
+
No ``attributed_pct``: quota attribution divides by a subscription week's
|
|
3831
|
+
total and means nothing over an absolute range. No ``bucket_path``, git
|
|
3832
|
+
root or raw source path — opaque keys stay non-reversible.
|
|
3833
|
+
"""
|
|
3834
|
+
c = _cctally()
|
|
3835
|
+
folded = fold_projects_over_range(rows, resolver_cache=resolver_cache)
|
|
3836
|
+
bucket_paths_sorted = sorted(folded)
|
|
3837
|
+
augmented_by_idx = c._project_disambiguate_labels(
|
|
3838
|
+
[{"key": folded[bp]["first_key"]} for bp in bucket_paths_sorted],
|
|
3839
|
+
)
|
|
3840
|
+
legacy = legacy_labels if isinstance(legacy_labels, Mapping) else {}
|
|
3841
|
+
published: "list[dict]" = []
|
|
3842
|
+
for idx, bucket_path in enumerate(bucket_paths_sorted):
|
|
3843
|
+
accumulator = folded[bucket_path]
|
|
3844
|
+
legacy_label = legacy.get(bucket_path)
|
|
3845
|
+
label = legacy_label or augmented_by_idx.get(
|
|
3846
|
+
idx, accumulator["first_key"].display_key,
|
|
3847
|
+
)
|
|
3848
|
+
published.append({
|
|
3849
|
+
"key": dashboard_resource_key("project", "claude", label),
|
|
3850
|
+
"label": label,
|
|
3851
|
+
"source": "claude",
|
|
3852
|
+
# Whether `GET /api/source/claude/project/<key>` can resolve this
|
|
3853
|
+
# row. See the docstring: the predicate names the BUCKET, so a
|
|
3854
|
+
# bounded label that collides with an unrelated legacy key never
|
|
3855
|
+
# advertises a drill that would serve the wrong project.
|
|
3856
|
+
"drillable": legacy_label is not None,
|
|
3857
|
+
# NOT rounded, for the same reason `current_week.rows` is not:
|
|
3858
|
+
# `round(..., 6)` introduces ~1e-6 error that breaks the 1e-9
|
|
3859
|
+
# reconcile tolerance.
|
|
3860
|
+
"cost_usd": accumulator["cost_usd"],
|
|
3861
|
+
"sessions_count": len(accumulator["sessions"]),
|
|
3862
|
+
})
|
|
3863
|
+
# Desc by cost, ties broken by label so the ranking is byte-stable.
|
|
3864
|
+
published.sort(key=lambda row: (-row["cost_usd"], row["label"]))
|
|
3865
|
+
return published
|
|
3866
|
+
|
|
3867
|
+
|
|
3319
3868
|
def _aggregate_projects_week_raw(
|
|
3320
3869
|
conn: "sqlite3.Connection",
|
|
3321
3870
|
*,
|