cctally 1.6.3 → 1.7.1
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 +22 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +961 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11061 -34659
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +25 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
"""Record-usage / hook-tick hot-path subsystem for cctally.
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup. Holds the
|
|
4
|
+
runtime path that every Claude Code statusline tick and every CC
|
|
5
|
+
hook fires:
|
|
6
|
+
|
|
7
|
+
- ``cmd_record_usage`` — the statusline-driven entry point. Parses
|
|
8
|
+
``--percent`` / ``--resets-at`` / ``--five-hour-*``, applies
|
|
9
|
+
ULP-noise sanitization at the ingress (``_normalize_percent``),
|
|
10
|
+
resolves the canonical 5h window key (Tier 1 blocks-table + Tier
|
|
11
|
+
2 snapshots fallback), runs the mid-week reset-event detector,
|
|
12
|
+
applies the per-window 7d/5h monotonicity clamps, dedup-skips
|
|
13
|
+
no-op ticks (with a self-heal probe that re-fires the milestone
|
|
14
|
+
+ 5h-block helpers when a prior process was killed between
|
|
15
|
+
``insert_usage_snapshot`` and the helpers), inserts the snapshot
|
|
16
|
+
row, queues the milestone + 5h-block updates, and writes the
|
|
17
|
+
``hwm-7d`` / ``hwm-5h`` files.
|
|
18
|
+
- ``cmd_hook_tick`` — the CC hook entry point. Reads CC's JSON
|
|
19
|
+
payload from stdin BEFORE fork (POSIX §2.9.3 makes ``cmd &``
|
|
20
|
+
blank stdin), forks to background so CC unblocks immediately,
|
|
21
|
+
detaches stdio to ``hook-tick.log``, runs ``sync_cache`` + a
|
|
22
|
+
throttled OAuth refresh under ``hook-tick.last-fetch.lock``, and
|
|
23
|
+
writes one log line. Normal mode returns 0 unconditionally
|
|
24
|
+
(hook discipline); ``--explain`` returns a decision-tree exit code.
|
|
25
|
+
- ``maybe_record_milestone`` — percent-crossing detector. Runs
|
|
26
|
+
``cmd_sync_week`` to refresh cost-on-disk, computes cumulative +
|
|
27
|
+
marginal cost via ``_compute_cost_for_weekref`` for reset-affected
|
|
28
|
+
weeks or ``get_latest_cost_for_week`` otherwise, inserts a
|
|
29
|
+
``percent_milestones`` row per crossed threshold inside a single
|
|
30
|
+
transaction, and queues ``_dispatch_alert_notification`` jobs
|
|
31
|
+
for thresholds configured in ``alerts.weekly_thresholds`` (set-
|
|
32
|
+
then-dispatch invariant, spec §3.2).
|
|
33
|
+
- ``maybe_update_five_hour_block`` — 5h block upsert + rollup-children
|
|
34
|
+
replace-all + 5h-% milestone detection. Resolves block_start_at
|
|
35
|
+
from prior row (or computes from ``five_hour_resets_at - 5h`` on
|
|
36
|
+
first observation), recomputes totals via ``_compute_block_totals``,
|
|
37
|
+
upserts the parent row with ON CONFLICT DO UPDATE, replaces
|
|
38
|
+
per-(block, model) and per-(block, project) children, fires the
|
|
39
|
+
5h-% alert dispatch, and runs the cross-reset cross-flag JOIN
|
|
40
|
+
sweep — all inside one BEGIN.
|
|
41
|
+
- ``_compute_block_totals`` — sums tokens + cost over
|
|
42
|
+
[block_start_at, range_end] from ``session_entries``, with
|
|
43
|
+
per-model and per-project breakdowns. Routes through
|
|
44
|
+
``get_claude_session_entries`` (cache-first / lock-contention
|
|
45
|
+
fallback / direct-JSONL fallback) so the rollup children inherit
|
|
46
|
+
the cache subsystem's correctness envelope.
|
|
47
|
+
- ``insert_usage_snapshot`` / ``_saved_dict_from_usage_row`` —
|
|
48
|
+
``weekly_usage_snapshots`` INSERT and its inverse (rebuild the
|
|
49
|
+
``saved`` dict from an existing row for the dedup self-heal
|
|
50
|
+
path).
|
|
51
|
+
- ``DerivedWeekWindow`` + ``_derive_week_from_payload`` +
|
|
52
|
+
``_coerce_payload_captured_at`` — payload-to-week-bucket
|
|
53
|
+
resolution shared by ``insert_usage_snapshot``. Anchors the
|
|
54
|
+
bucket-key date on the canonical UTC ISO (regression: Israel host
|
|
55
|
+
briefly running with TZ=America/Los_Angeles spawned ghost
|
|
56
|
+
``week_start_date`` rows; see ``tests/test_derive_week_utc_anchor.py``).
|
|
57
|
+
- ``_normalize_percent`` — single chokepoint that flushes IEEE 754
|
|
58
|
+
ULP noise out of ingress percent floats. Applied at every
|
|
59
|
+
cmd_record_usage ingress site (CLI args, hook-tick OAuth refresh,
|
|
60
|
+
refresh-usage OAuth fetch). 10dp round is well below any
|
|
61
|
+
meaningful consumer precision but above IEEE 754 ULP scale near
|
|
62
|
+
100.
|
|
63
|
+
- ``_hook_tick_*`` helpers — log/throttle file primitives,
|
|
64
|
+
stdin-read, session-id short, log-line formatter.
|
|
65
|
+
- ``_safe_float`` / ``_validate_date_optional`` — payload-validation
|
|
66
|
+
helpers consumed only by ``insert_usage_snapshot``.
|
|
67
|
+
- ``_logged_window_key_coerce_failure`` — one-shot module-level
|
|
68
|
+
guard so a misbehaving caller passing a non-int ``fiveHourWindowKey``
|
|
69
|
+
doesn't spam stderr on every insert.
|
|
70
|
+
|
|
71
|
+
What stays in bin/cctally:
|
|
72
|
+
- Path constants ``APP_DIR``, ``HOOK_TICK_LOG_DIR``,
|
|
73
|
+
``HOOK_TICK_LOG_PATH``, ``HOOK_TICK_LOG_ROTATED_PATH``,
|
|
74
|
+
``HOOK_TICK_LOG_ROTATE_BYTES``, ``HOOK_TICK_THROTTLE_PATH``,
|
|
75
|
+
``HOOK_TICK_THROTTLE_LOCK_PATH``,
|
|
76
|
+
``HOOK_TICK_DEFAULT_THROTTLE_SECONDS`` — referenced from the
|
|
77
|
+
moved bodies via the ``c = _cctally()`` call-time accessor pattern
|
|
78
|
+
(spec §5.5, same as ``bin/_cctally_cache.py``). The accessor
|
|
79
|
+
resolves ``sys.modules['cctally'].X`` on every call, so the
|
|
80
|
+
conftest ``redirect_paths`` ``setitem(ns, "APP_DIR", tmp)``
|
|
81
|
+
propagates transparently — no sibling-side patches needed.
|
|
82
|
+
- Alerts-config surface (``_AlertsConfigError``, ``_get_alerts_config``,
|
|
83
|
+
``_warn_alerts_bad_config_once``, ``_ALERTS_BAD_CONFIG_WARNED``)
|
|
84
|
+
— stays in bin/cctally per task brief; consumed by dashboard
|
|
85
|
+
and other surfaces beyond record/hook-tick. Routed through
|
|
86
|
+
module-level shims here so the moved bodies keep bare-name
|
|
87
|
+
call shape.
|
|
88
|
+
- ``_dispatch_alert_notification`` — already lives in
|
|
89
|
+
``bin/_cctally_alerts.py`` (Phase B). Accessed via shim that
|
|
90
|
+
resolves through ``sys.modules['cctally']._dispatch_alert_notification``
|
|
91
|
+
so the eager re-export in bin/cctally propagates the same
|
|
92
|
+
function object both sides see.
|
|
93
|
+
- ``cmd_sync_week`` (Phase B sibling), ``cmd_refresh_usage`` /
|
|
94
|
+
``_hook_tick_oauth_refresh`` / ``_hook_tick_make_mock_refresh``
|
|
95
|
+
/ ``_get_oauth_usage_config`` / ``OauthUsageConfigError``
|
|
96
|
+
(Phase C ``_cctally_refresh.py``) — consumed from this sibling
|
|
97
|
+
via the same bare-name shim or ``c.X`` pattern.
|
|
98
|
+
- ``open_db``, ``open_cache_db``, ``sync_cache``, ``parse_iso_datetime``,
|
|
99
|
+
``now_utc_iso``, ``load_config``, ``get_week_start_name``,
|
|
100
|
+
``compute_week_bounds``, ``parse_date_str``,
|
|
101
|
+
``_canonicalize_optional_iso``, ``_canonical_5h_window_key``,
|
|
102
|
+
``_floor_to_hour``, ``_get_canonical_boundary_for_date``,
|
|
103
|
+
``_apply_reset_events_to_weekrefs``, ``_week_ref_has_reset_event``,
|
|
104
|
+
``_compute_cost_for_weekref``, ``get_latest_cost_for_week``,
|
|
105
|
+
``get_max_milestone_for_week``, ``get_milestone_cost_for_week``,
|
|
106
|
+
``insert_percent_milestone``, ``make_week_ref``,
|
|
107
|
+
``_calculate_entry_cost``, ``_resolve_primary_model_for_block``,
|
|
108
|
+
``_resolve_display_tz_obj``, ``_build_alert_payload_weekly``,
|
|
109
|
+
``_build_alert_payload_five_hour``, ``eprint``,
|
|
110
|
+
``get_claude_session_entries``, ``_FIVE_HOUR_JITTER_FLOOR_SECONDS``,
|
|
111
|
+
``_RESET_PCT_DROP_THRESHOLD`` — boundary helpers, already-extracted
|
|
112
|
+
subsystems, or constants that belong in bin/cctally. All accessed
|
|
113
|
+
via the same shim/``c.X`` pattern.
|
|
114
|
+
|
|
115
|
+
§5.6 audit on this extraction's monkeypatch surface:
|
|
116
|
+
- ``cmd_record_usage`` — patched via ``monkeypatch.setitem(ns, …)``
|
|
117
|
+
by 5 test files (``test_hook_tick_rate_limit.py``,
|
|
118
|
+
``test_refresh_usage_inproc.py``, ``test_refresh_usage_cmd.py``,
|
|
119
|
+
callers via ``ns["cmd_record_usage"](...)``). Re-export in
|
|
120
|
+
bin/cctally propagates patches; the moved body never reaches
|
|
121
|
+
for itself.
|
|
122
|
+
- ``_hook_tick_oauth_refresh`` — patched via
|
|
123
|
+
``monkeypatch.setitem(ns, …)`` by ``test_hook_tick_rate_limit.py``.
|
|
124
|
+
Moved ``cmd_hook_tick`` uses a module-level shim that resolves
|
|
125
|
+
via ``sys.modules['cctally']`` at call time; the
|
|
126
|
+
``globals()["_hook_tick_oauth_refresh"] = …`` mock-injection
|
|
127
|
+
branch is rewritten to mutate ``sys.modules['cctally']`` so
|
|
128
|
+
``--mock-oauth-response`` still propagates.
|
|
129
|
+
- ``_normalize_percent`` — test reads via
|
|
130
|
+
``ns["_normalize_percent"]`` (``test_record_usage_precision.py``).
|
|
131
|
+
Re-export in bin/cctally propagates the same function object.
|
|
132
|
+
- ``_derive_week_from_payload`` — test reads via
|
|
133
|
+
``ns["_derive_week_from_payload"]``
|
|
134
|
+
(``test_derive_week_utc_anchor.py``). Re-export in bin/cctally
|
|
135
|
+
propagates.
|
|
136
|
+
|
|
137
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
138
|
+
"""
|
|
139
|
+
from __future__ import annotations
|
|
140
|
+
|
|
141
|
+
import argparse
|
|
142
|
+
import datetime as dt
|
|
143
|
+
import fcntl
|
|
144
|
+
import json
|
|
145
|
+
import math
|
|
146
|
+
import os
|
|
147
|
+
import sqlite3
|
|
148
|
+
import sys
|
|
149
|
+
import time
|
|
150
|
+
from dataclasses import dataclass
|
|
151
|
+
from typing import Any
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _cctally():
|
|
155
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
156
|
+
return sys.modules["cctally"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Module-level back-ref shims. Each shim resolves
|
|
160
|
+
# ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
|
|
161
|
+
# monkeypatches on cctally's namespace propagate into the moved code
|
|
162
|
+
# unchanged. Mirrors the precedent established in
|
|
163
|
+
# ``bin/_cctally_cache.py`` and ``bin/_cctally_db.py``.
|
|
164
|
+
def eprint(*args, **kwargs):
|
|
165
|
+
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def now_utc_iso(*args, **kwargs):
|
|
169
|
+
return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_iso_datetime(*args, **kwargs):
|
|
173
|
+
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def open_db(*args, **kwargs):
|
|
177
|
+
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def open_cache_db(*args, **kwargs):
|
|
181
|
+
return sys.modules["cctally"].open_cache_db(*args, **kwargs)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sync_cache(*args, **kwargs):
|
|
185
|
+
return sys.modules["cctally"].sync_cache(*args, **kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_config(*args, **kwargs):
|
|
189
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_week_start_name(*args, **kwargs):
|
|
193
|
+
return sys.modules["cctally"].get_week_start_name(*args, **kwargs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def compute_week_bounds(*args, **kwargs):
|
|
197
|
+
return sys.modules["cctally"].compute_week_bounds(*args, **kwargs)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_date_str(*args, **kwargs):
|
|
201
|
+
return sys.modules["cctally"].parse_date_str(*args, **kwargs)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _canonicalize_optional_iso(*args, **kwargs):
|
|
205
|
+
return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _canonical_5h_window_key(*args, **kwargs):
|
|
209
|
+
return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _floor_to_hour(*args, **kwargs):
|
|
213
|
+
return sys.modules["cctally"]._floor_to_hour(*args, **kwargs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _get_canonical_boundary_for_date(*args, **kwargs):
|
|
217
|
+
return sys.modules["cctally"]._get_canonical_boundary_for_date(*args, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _apply_reset_events_to_weekrefs(*args, **kwargs):
|
|
221
|
+
return sys.modules["cctally"]._apply_reset_events_to_weekrefs(*args, **kwargs)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _week_ref_has_reset_event(*args, **kwargs):
|
|
225
|
+
return sys.modules["cctally"]._week_ref_has_reset_event(*args, **kwargs)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _compute_cost_for_weekref(*args, **kwargs):
|
|
229
|
+
return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_latest_cost_for_week(*args, **kwargs):
|
|
233
|
+
return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_max_milestone_for_week(*args, **kwargs):
|
|
237
|
+
return sys.modules["cctally"].get_max_milestone_for_week(*args, **kwargs)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_milestone_cost_for_week(*args, **kwargs):
|
|
241
|
+
return sys.modules["cctally"].get_milestone_cost_for_week(*args, **kwargs)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def insert_percent_milestone(*args, **kwargs):
|
|
245
|
+
return sys.modules["cctally"].insert_percent_milestone(*args, **kwargs)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def make_week_ref(*args, **kwargs):
|
|
249
|
+
return sys.modules["cctally"].make_week_ref(*args, **kwargs)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_sync_week(*args, **kwargs):
|
|
253
|
+
return sys.modules["cctally"].cmd_sync_week(*args, **kwargs)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _calculate_entry_cost(*args, **kwargs):
|
|
257
|
+
return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_claude_session_entries(*args, **kwargs):
|
|
261
|
+
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _resolve_primary_model_for_block(*args, **kwargs):
|
|
265
|
+
return sys.modules["cctally"]._resolve_primary_model_for_block(*args, **kwargs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve_display_tz_obj(*args, **kwargs):
|
|
269
|
+
return sys.modules["cctally"]._resolve_display_tz_obj(*args, **kwargs)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _build_alert_payload_weekly(*args, **kwargs):
|
|
273
|
+
return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_alert_payload_five_hour(*args, **kwargs):
|
|
277
|
+
return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _dispatch_alert_notification(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _get_alerts_config(*args, **kwargs):
|
|
285
|
+
return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _warn_alerts_bad_config_once(*args, **kwargs):
|
|
289
|
+
return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _get_oauth_usage_config(*args, **kwargs):
|
|
293
|
+
return sys.modules["cctally"]._get_oauth_usage_config(*args, **kwargs)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _hook_tick_oauth_refresh(*args, **kwargs):
|
|
297
|
+
"""Shim for ``_hook_tick_oauth_refresh``.
|
|
298
|
+
|
|
299
|
+
Resolves via ``sys.modules['cctally']`` at call time so
|
|
300
|
+
``monkeypatch.setitem(ns, "_hook_tick_oauth_refresh", boom)``
|
|
301
|
+
propagates. The ``--mock-oauth-response`` flag below rewrites
|
|
302
|
+
``sys.modules['cctally']._hook_tick_oauth_refresh`` so this
|
|
303
|
+
shim picks up the mock on the very next call.
|
|
304
|
+
"""
|
|
305
|
+
return sys.modules["cctally"]._hook_tick_oauth_refresh(*args, **kwargs)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _hook_tick_make_mock_refresh(*args, **kwargs):
|
|
309
|
+
return sys.modules["cctally"]._hook_tick_make_mock_refresh(*args, **kwargs)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Exception classes raised by callees that stay in bin/cctally
|
|
313
|
+
# (``_AlertsConfigError``) or in another sibling (``OauthUsageConfigError``
|
|
314
|
+
# in ``bin/_cctally_refresh.py``) are caught here via
|
|
315
|
+
# ``except sys.modules['cctally'].SomeError`` — Python evaluates the
|
|
316
|
+
# ``except`` expression at except-time, so each catch resolves to the
|
|
317
|
+
# live class object that the raiser also reaches. See call sites in
|
|
318
|
+
# ``maybe_record_milestone``, ``maybe_update_five_hour_block``, and
|
|
319
|
+
# ``cmd_hook_tick`` for the three rewrites.
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# Constants referenced by the moved bodies. Defined here (rather than
|
|
323
|
+
# `c.X`-routed) because they're pure literals — no monkeypatch surface
|
|
324
|
+
# and no dependency on cctally's module instance.
|
|
325
|
+
_PERCENT_NORMALIZE_DECIMALS = 10
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# One-shot guard so a misbehaving caller passing a non-int
|
|
329
|
+
# fiveHourWindowKey doesn't spam the log on every insert. Set on first
|
|
330
|
+
# loud-skip in insert_usage_snapshot. Moved into this sibling alongside
|
|
331
|
+
# insert_usage_snapshot — the `global` statement inside that function
|
|
332
|
+
# now binds to THIS module's namespace, which is correct (cctally re-
|
|
333
|
+
# exports the function via the eager-load block, but the `global` write
|
|
334
|
+
# stays in the sibling's __dict__ and the per-process one-shot semantics
|
|
335
|
+
# are preserved across both call routes).
|
|
336
|
+
_logged_window_key_coerce_failure = False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# === BEGIN MOVED REGIONS ===
|
|
340
|
+
# Path constants (APP_DIR, HOOK_TICK_*) are accessed via the
|
|
341
|
+
# `c = _cctally()` call-time accessor inside each function that needs
|
|
342
|
+
# them — so ``monkeypatch.setitem(ns, "APP_DIR", tmp)`` in tests
|
|
343
|
+
# resolves on every read (no stale module-level binding).
|
|
344
|
+
#
|
|
345
|
+
# Constants pulled from cctally at call time:
|
|
346
|
+
# c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
|
|
347
|
+
# c._RESET_PCT_DROP_THRESHOLD — bin/cctally module-level constant
|
|
348
|
+
# c.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
|
|
349
|
+
# c.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
|
|
350
|
+
# c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
|
|
351
|
+
# c.APP_DIR
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _normalize_percent(value: "float | int | None") -> "float | None":
|
|
355
|
+
"""Flush IEEE 754 ULP noise out of an ingress percent value.
|
|
356
|
+
|
|
357
|
+
Single chokepoint applied at every site where a raw percent enters
|
|
358
|
+
cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
|
|
359
|
+
the cmd_record_usage CLI ingress). Downstream consumers — HWM
|
|
360
|
+
files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
|
|
361
|
+
columns, ``five_hour_blocks.final_five_hour_percent``, milestone
|
|
362
|
+
crossing values, and the SSE envelope's ``used_percent`` field —
|
|
363
|
+
all read the cleaned value, so a single round here stops
|
|
364
|
+
``5h=7.000000000000001`` style strings from reaching any log or
|
|
365
|
+
serialized surface.
|
|
366
|
+
|
|
367
|
+
``None`` is the canonical absent-percent sentinel; preserve it
|
|
368
|
+
unchanged so the optional-5h branches stay simple.
|
|
369
|
+
"""
|
|
370
|
+
if value is None:
|
|
371
|
+
return None
|
|
372
|
+
return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def maybe_record_milestone(
|
|
376
|
+
saved: dict[str, Any],
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Check if a new integer percent threshold was crossed, and if so,
|
|
379
|
+
fetch cost and record the milestone. Errors are logged, not raised."""
|
|
380
|
+
weekly_percent = saved.get("weeklyPercent")
|
|
381
|
+
if weekly_percent is None or weekly_percent < 1:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Snap near-integer values up before flooring: the status-line API returns
|
|
385
|
+
# N% as 0.N * 100, which in IEEE 754 can land one ULP below N.0 (e.g.
|
|
386
|
+
# 0.58 * 100 == 57.99999999999999). A bare math.floor() then returns N-1
|
|
387
|
+
# and the N-threshold milestone is never recorded.
|
|
388
|
+
current_floor = math.floor(weekly_percent + 1e-9)
|
|
389
|
+
if current_floor < 1:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
week_start_date = saved["weekStartDate"]
|
|
393
|
+
week_end_date = saved["weekEndDate"]
|
|
394
|
+
week_start_at = saved.get("weekStartAt")
|
|
395
|
+
week_end_at = saved.get("weekEndAt")
|
|
396
|
+
usage_snapshot_id = saved["id"]
|
|
397
|
+
five_hour_percent = saved.get("fiveHourPercent")
|
|
398
|
+
|
|
399
|
+
conn = open_db()
|
|
400
|
+
try:
|
|
401
|
+
max_existing = get_max_milestone_for_week(conn, week_start_date)
|
|
402
|
+
if max_existing is not None and current_floor <= max_existing:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Threshold crossed — sync cost before recording so the milestone
|
|
406
|
+
# captures up-to-date cumulative cost, not a stale snapshot.
|
|
407
|
+
try:
|
|
408
|
+
sync_ns = argparse.Namespace(
|
|
409
|
+
week_start=None,
|
|
410
|
+
week_end=None,
|
|
411
|
+
week_start_name=None,
|
|
412
|
+
mode="auto",
|
|
413
|
+
offline=False,
|
|
414
|
+
project=None,
|
|
415
|
+
json=False,
|
|
416
|
+
quiet=True,
|
|
417
|
+
)
|
|
418
|
+
cmd_sync_week(sync_ns)
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
eprint(f"[milestone] cost sync failed, using latest available: {exc}")
|
|
421
|
+
|
|
422
|
+
week_start = dt.date.fromisoformat(week_start_date)
|
|
423
|
+
week_end = dt.date.fromisoformat(week_end_date)
|
|
424
|
+
week_ref = make_week_ref(
|
|
425
|
+
week_start_date=week_start_date,
|
|
426
|
+
week_end_date=week_end_date,
|
|
427
|
+
week_start_at=week_start_at,
|
|
428
|
+
week_end_at=week_end_at,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# For reset-affected weeks, the cached weekly_cost_snapshots row
|
|
432
|
+
# covers the API-derived range (which for a post-reset week
|
|
433
|
+
# backdates into the old window). Live-compute over the effective
|
|
434
|
+
# range so the milestone captures cost from the reset moment
|
|
435
|
+
# forward, not from the phantom backdated start.
|
|
436
|
+
effective_ref = week_ref
|
|
437
|
+
adjusted = _apply_reset_events_to_weekrefs(conn, [week_ref])
|
|
438
|
+
if adjusted:
|
|
439
|
+
effective_ref = adjusted[0]
|
|
440
|
+
|
|
441
|
+
if _week_ref_has_reset_event(conn, effective_ref):
|
|
442
|
+
live_cost = _compute_cost_for_weekref(effective_ref)
|
|
443
|
+
if live_cost is None:
|
|
444
|
+
eprint("[milestone] could not compute effective-range cost, skipping")
|
|
445
|
+
return
|
|
446
|
+
cumulative_cost = live_cost
|
|
447
|
+
cost_snapshot_id = 0 # no snapshot row to anchor against
|
|
448
|
+
else:
|
|
449
|
+
latest_cost = get_latest_cost_for_week(conn, week_ref)
|
|
450
|
+
if latest_cost is None:
|
|
451
|
+
eprint("[milestone] no cost snapshot yet for this week, skipping")
|
|
452
|
+
return
|
|
453
|
+
cumulative_cost = float(latest_cost["cost_usd"])
|
|
454
|
+
cost_snapshot_id = int(latest_cost["id"])
|
|
455
|
+
|
|
456
|
+
# Determine which thresholds to record
|
|
457
|
+
start_threshold = (max_existing + 1) if max_existing is not None else current_floor
|
|
458
|
+
|
|
459
|
+
# Hoist `_get_alerts_config(load_config())` above the per-pct loop:
|
|
460
|
+
# in the catch-up case (multi-percent jump on first observation) the
|
|
461
|
+
# loop iterates N times and the config never changes mid-loop. One
|
|
462
|
+
# read serves all iterations.
|
|
463
|
+
# `load_config()` is safe outside the writer lock — atomic-rename
|
|
464
|
+
# guarantees readers see whole bytes (CLAUDE.md gotcha).
|
|
465
|
+
# `_ALERTS_BAD_CONFIG_WARNED` (module-level, M3) rate-limits the
|
|
466
|
+
# warning to once per process; both axis paths share the flag since
|
|
467
|
+
# the underlying problem is config-wide, not axis-specific.
|
|
468
|
+
try:
|
|
469
|
+
alerts_cfg: "dict | None" = _get_alerts_config(load_config())
|
|
470
|
+
except sys.modules["cctally"]._AlertsConfigError as exc:
|
|
471
|
+
_warn_alerts_bad_config_once(exc)
|
|
472
|
+
alerts_cfg = None
|
|
473
|
+
|
|
474
|
+
# Collect dispatch jobs across the per-pct loop and fire AFTER the
|
|
475
|
+
# single commit below. Mirrors the 5h path's pending_alerts pattern
|
|
476
|
+
# (set-then-dispatch + atomic INSERT/UPDATE, spec §3.2). Without
|
|
477
|
+
# this, `insert_percent_milestone`'s prior internal commit would
|
|
478
|
+
# split INSERT and the alerted_at UPDATE across two transactions —
|
|
479
|
+
# a crash in the gap left `alerted_at` NULL forever, since the
|
|
480
|
+
# next call's INSERT OR IGNORE returns rowcount==0 and the
|
|
481
|
+
# `if inserted == 1` dispatch guard skips re-firing.
|
|
482
|
+
pending_alerts: list[dict[str, Any]] = []
|
|
483
|
+
for pct in range(start_threshold, current_floor + 1):
|
|
484
|
+
if pct == start_threshold and max_existing is not None:
|
|
485
|
+
prev_cost = get_milestone_cost_for_week(conn, week_start_date, max_existing)
|
|
486
|
+
marginal = (cumulative_cost - prev_cost) if prev_cost is not None else None
|
|
487
|
+
else:
|
|
488
|
+
marginal = None
|
|
489
|
+
inserted = insert_percent_milestone(
|
|
490
|
+
conn,
|
|
491
|
+
week_start_date=week_start_date,
|
|
492
|
+
week_end_date=week_end_date,
|
|
493
|
+
week_start_at=week_start_at,
|
|
494
|
+
week_end_at=week_end_at,
|
|
495
|
+
percent_threshold=pct,
|
|
496
|
+
cumulative_cost_usd=cumulative_cost,
|
|
497
|
+
marginal_cost_usd=marginal,
|
|
498
|
+
usage_snapshot_id=usage_snapshot_id,
|
|
499
|
+
cost_snapshot_id=cost_snapshot_id,
|
|
500
|
+
five_hour_percent_at_crossing=five_hour_percent,
|
|
501
|
+
commit=False,
|
|
502
|
+
)
|
|
503
|
+
# ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
|
|
504
|
+
# Only the genuine-new-crossing winner (rowcount==1) reaches this
|
|
505
|
+
# path; concurrent record-usage instances that race on the same
|
|
506
|
+
# (week_start_date, percent_threshold) get rowcount==0 from the
|
|
507
|
+
# INSERT OR IGNORE and skip dispatch entirely. The
|
|
508
|
+
# `alerted_at IS NULL` guard on the UPDATE is defense-in-depth:
|
|
509
|
+
# write-once even if two writers somehow both think they won.
|
|
510
|
+
if inserted == 1:
|
|
511
|
+
if (
|
|
512
|
+
alerts_cfg is not None
|
|
513
|
+
and alerts_cfg["enabled"]
|
|
514
|
+
and pct in alerts_cfg["weekly_thresholds"]
|
|
515
|
+
):
|
|
516
|
+
crossed_at = now_utc_iso()
|
|
517
|
+
# set-then-dispatch: alerted_at lands on the row BEFORE
|
|
518
|
+
# the osascript Popen, so a dismissed-after-spawn
|
|
519
|
+
# notification still surfaces in the dashboard alerts
|
|
520
|
+
# envelope (T5). UPDATE shares the transaction with
|
|
521
|
+
# the preceding INSERT (commit=False above) so a
|
|
522
|
+
# crash between them is impossible.
|
|
523
|
+
conn.execute(
|
|
524
|
+
"UPDATE percent_milestones SET alerted_at = ? "
|
|
525
|
+
"WHERE week_start_date = ? AND percent_threshold = ? "
|
|
526
|
+
"AND alerted_at IS NULL",
|
|
527
|
+
(crossed_at, week_start_date, pct),
|
|
528
|
+
)
|
|
529
|
+
# Cheap re-read for payload context (cumulative_cost_usd
|
|
530
|
+
# reflects the value persisted on insert, immune to any
|
|
531
|
+
# subsequent recompute drift). SELECT inside the open
|
|
532
|
+
# transaction is fine; values reflect post-INSERT state.
|
|
533
|
+
row = conn.execute(
|
|
534
|
+
"SELECT cumulative_cost_usd FROM percent_milestones "
|
|
535
|
+
"WHERE week_start_date = ? AND percent_threshold = ?",
|
|
536
|
+
(week_start_date, pct),
|
|
537
|
+
).fetchone()
|
|
538
|
+
if row is not None:
|
|
539
|
+
cum = float(row["cumulative_cost_usd"])
|
|
540
|
+
# $/1% rough trend metric: cumulative / threshold.
|
|
541
|
+
dpp = (cum / pct) if pct else None
|
|
542
|
+
payload = _build_alert_payload_weekly(
|
|
543
|
+
threshold=pct,
|
|
544
|
+
crossed_at_utc=crossed_at,
|
|
545
|
+
week_start_date=week_start_date,
|
|
546
|
+
cumulative_cost_usd=cum,
|
|
547
|
+
dollars_per_percent=dpp,
|
|
548
|
+
)
|
|
549
|
+
pending_alerts.append(payload)
|
|
550
|
+
# Single commit after the loop durably writes every milestone row
|
|
551
|
+
# AND its alerted_at marker together.
|
|
552
|
+
conn.commit()
|
|
553
|
+
# Dispatch deferred to AFTER commit (matches 5h path). Per-payload
|
|
554
|
+
# exception logged so a bad-payload alert can't suppress healthy ones.
|
|
555
|
+
# Production caller ignores _dispatch_alert_notification's return
|
|
556
|
+
# value (spec §6.4).
|
|
557
|
+
for payload in pending_alerts:
|
|
558
|
+
try:
|
|
559
|
+
_dispatch_alert_notification(payload, mode="real")
|
|
560
|
+
except Exception as dispatch_exc:
|
|
561
|
+
eprint(f"[alerts] dispatch failed: {dispatch_exc}")
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
eprint(f"[milestone] error recording milestone: {exc}")
|
|
564
|
+
finally:
|
|
565
|
+
conn.close()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _compute_block_totals(
|
|
569
|
+
block_start_at: dt.datetime,
|
|
570
|
+
range_end: dt.datetime,
|
|
571
|
+
*,
|
|
572
|
+
skip_sync: bool = False,
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
"""Sum tokens + cost over [block_start_at, range_end] from session_entries,
|
|
575
|
+
plus per-model and per-project breakdowns in the same walk.
|
|
576
|
+
|
|
577
|
+
Used by the live write path (maybe_update_five_hour_block) and the
|
|
578
|
+
historical backfill (_backfill_five_hour_blocks /
|
|
579
|
+
_backfill_five_hour_block_models / _backfill_five_hour_block_projects).
|
|
580
|
+
|
|
581
|
+
Routes through get_claude_session_entries (rather than the parent
|
|
582
|
+
get_entries which returns UsageEntry without project_path) — same
|
|
583
|
+
cache-first / lock-contention / direct-JSONL fallback chain.
|
|
584
|
+
|
|
585
|
+
Returns a dict with:
|
|
586
|
+
input_tokens, output_tokens, cache_create_tokens, cache_read_tokens (int)
|
|
587
|
+
cost_usd (float)
|
|
588
|
+
by_model: dict[model_name -> {input_tokens, output_tokens,
|
|
589
|
+
cache_create_tokens, cache_read_tokens,
|
|
590
|
+
cost_usd, entry_count}]
|
|
591
|
+
by_project: dict[project_path_or_'(unknown)' -> same shape]
|
|
592
|
+
"""
|
|
593
|
+
totals: dict[str, Any] = {
|
|
594
|
+
"input_tokens": 0,
|
|
595
|
+
"output_tokens": 0,
|
|
596
|
+
"cache_create_tokens": 0,
|
|
597
|
+
"cache_read_tokens": 0,
|
|
598
|
+
"cost_usd": 0.0,
|
|
599
|
+
"by_model": {},
|
|
600
|
+
"by_project": {},
|
|
601
|
+
}
|
|
602
|
+
for entry in get_claude_session_entries(
|
|
603
|
+
block_start_at, range_end, skip_sync=skip_sync,
|
|
604
|
+
):
|
|
605
|
+
usage = {
|
|
606
|
+
"input_tokens": entry.input_tokens,
|
|
607
|
+
"output_tokens": entry.output_tokens,
|
|
608
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
609
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
610
|
+
}
|
|
611
|
+
cost = _calculate_entry_cost(
|
|
612
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
totals["input_tokens"] += entry.input_tokens
|
|
616
|
+
totals["output_tokens"] += entry.output_tokens
|
|
617
|
+
totals["cache_create_tokens"] += entry.cache_creation_tokens
|
|
618
|
+
totals["cache_read_tokens"] += entry.cache_read_tokens
|
|
619
|
+
totals["cost_usd"] += cost
|
|
620
|
+
|
|
621
|
+
# Bucket by model and by project_path. NULL project_path → sentinel
|
|
622
|
+
# so reconcile invariant SUM(child.cost) == parent.total holds.
|
|
623
|
+
# Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
|
|
624
|
+
# always populates project_path = cwd (never NULL); '(unknown)' only
|
|
625
|
+
# appears on the cache-backed path during the brief session_files
|
|
626
|
+
# lazy-backfill window.
|
|
627
|
+
for key, bucket_dict in (
|
|
628
|
+
(entry.model, totals["by_model"]),
|
|
629
|
+
(entry.project_path or "(unknown)", totals["by_project"]),
|
|
630
|
+
):
|
|
631
|
+
b = bucket_dict.setdefault(
|
|
632
|
+
key,
|
|
633
|
+
{
|
|
634
|
+
"input_tokens": 0,
|
|
635
|
+
"output_tokens": 0,
|
|
636
|
+
"cache_create_tokens": 0,
|
|
637
|
+
"cache_read_tokens": 0,
|
|
638
|
+
"cost_usd": 0.0,
|
|
639
|
+
"entry_count": 0,
|
|
640
|
+
},
|
|
641
|
+
)
|
|
642
|
+
b["input_tokens"] += entry.input_tokens
|
|
643
|
+
b["output_tokens"] += entry.output_tokens
|
|
644
|
+
b["cache_create_tokens"] += entry.cache_creation_tokens
|
|
645
|
+
b["cache_read_tokens"] += entry.cache_read_tokens
|
|
646
|
+
b["cost_usd"] += cost
|
|
647
|
+
b["entry_count"] += 1
|
|
648
|
+
return totals
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
|
|
652
|
+
"""Upsert the current 5h block in five_hour_blocks; close strictly
|
|
653
|
+
older open blocks; sweep naturally-expired blocks; flag blocks
|
|
654
|
+
spanning a recorded mid-week 7d-reset.
|
|
655
|
+
|
|
656
|
+
Errors are logged and swallowed — record-usage must not regress
|
|
657
|
+
because of this helper, same posture as maybe_record_milestone.
|
|
658
|
+
"""
|
|
659
|
+
five_hour_percent = saved.get("fiveHourPercent")
|
|
660
|
+
five_hour_resets_at = saved.get("fiveHourResetsAt")
|
|
661
|
+
five_hour_window_key = saved.get("fiveHourWindowKey")
|
|
662
|
+
if (
|
|
663
|
+
five_hour_percent is None
|
|
664
|
+
or five_hour_resets_at is None
|
|
665
|
+
or five_hour_window_key is None
|
|
666
|
+
):
|
|
667
|
+
return # no canonical 5h anchor — nothing to record
|
|
668
|
+
|
|
669
|
+
captured_at = saved["capturedAt"]
|
|
670
|
+
weekly_percent = saved.get("weeklyPercent")
|
|
671
|
+
snapshot_id = saved["id"]
|
|
672
|
+
|
|
673
|
+
# Note: this is the 4th open_db() invocation per record-usage call
|
|
674
|
+
# (after cmd_record_usage's prior-state read, insert_usage_snapshot,
|
|
675
|
+
# and maybe_record_milestone). Each open re-runs the inline schema
|
|
676
|
+
# migrations and the empty-table check that gates _backfill_five_hour_blocks.
|
|
677
|
+
# The backfill itself only runs once per process (the gate fires only when
|
|
678
|
+
# five_hour_blocks is empty), so the cost is benign — but the count is
|
|
679
|
+
# surprising. If any future helper grows expensive open_db() side effects,
|
|
680
|
+
# consolidate by passing the connection through rather than reopening.
|
|
681
|
+
conn = open_db()
|
|
682
|
+
try:
|
|
683
|
+
# Step 3 (per spec §3.2): read prior state including immutable
|
|
684
|
+
# fields we'll re-use. Re-deriving block_start_at from saved.
|
|
685
|
+
# fiveHourResetsAt would reintroduce the seconds-level Anthropic
|
|
686
|
+
# ISO jitter that five_hour_window_key was designed to collapse.
|
|
687
|
+
prior = conn.execute(
|
|
688
|
+
"""
|
|
689
|
+
SELECT id AS prior_block_id,
|
|
690
|
+
block_start_at AS block_start_at
|
|
691
|
+
FROM five_hour_blocks
|
|
692
|
+
WHERE five_hour_window_key = ?
|
|
693
|
+
""",
|
|
694
|
+
(int(five_hour_window_key),),
|
|
695
|
+
).fetchone()
|
|
696
|
+
|
|
697
|
+
if prior is None:
|
|
698
|
+
# First observation of this window. Compute block_start_at
|
|
699
|
+
# from the canonical resets timestamp.
|
|
700
|
+
try:
|
|
701
|
+
resets_dt = parse_iso_datetime(
|
|
702
|
+
five_hour_resets_at, "five_hour_resets_at",
|
|
703
|
+
)
|
|
704
|
+
except ValueError as exc:
|
|
705
|
+
eprint(f"[5h-block] bad resets_at, skipping: {exc}")
|
|
706
|
+
return
|
|
707
|
+
block_start_dt = resets_dt - dt.timedelta(hours=5)
|
|
708
|
+
block_start_at = block_start_dt.isoformat(timespec="seconds")
|
|
709
|
+
else:
|
|
710
|
+
block_start_at = prior["block_start_at"]
|
|
711
|
+
block_start_dt = parse_iso_datetime(
|
|
712
|
+
block_start_at, "five_hour_blocks.block_start_at",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# Step 6 (totals) — done outside the transaction so the
|
|
716
|
+
# cache.db read doesn't hold the stats.db write lock open.
|
|
717
|
+
captured_at_dt = parse_iso_datetime(captured_at, "capturedAt")
|
|
718
|
+
totals = _compute_block_totals(block_start_dt, captured_at_dt)
|
|
719
|
+
|
|
720
|
+
# Hoist alerts config above BEGIN (M1 + M2): single read serves
|
|
721
|
+
# all per-pct iterations in the catch-up case, AND keeps the
|
|
722
|
+
# filesystem read out of the transaction window so the stats.db
|
|
723
|
+
# write lock isn't held across config.json I/O.
|
|
724
|
+
# `load_config()` is safe outside the writer lock — atomic-rename
|
|
725
|
+
# guarantees readers see whole bytes (CLAUDE.md gotcha).
|
|
726
|
+
# `_ALERTS_BAD_CONFIG_WARNED` (module-level, M3) rate-limits the
|
|
727
|
+
# warning to once per process; both axis paths share the flag since
|
|
728
|
+
# the underlying problem is config-wide, not axis-specific.
|
|
729
|
+
cfg_for_alerts = load_config()
|
|
730
|
+
try:
|
|
731
|
+
alerts_cfg: "dict | None" = _get_alerts_config(cfg_for_alerts)
|
|
732
|
+
except sys.modules["cctally"]._AlertsConfigError as exc:
|
|
733
|
+
_warn_alerts_bad_config_once(exc)
|
|
734
|
+
alerts_cfg = None
|
|
735
|
+
# Resolve display.tz once (shares the cfg load above). Threaded
|
|
736
|
+
# into _dispatch_alert_notification so the macOS notification
|
|
737
|
+
# subtitle (block-start time) matches the dashboard / TUI render
|
|
738
|
+
# rather than falling back to host-local via tz=None.
|
|
739
|
+
display_tz_for_alerts = _resolve_display_tz_obj(cfg_for_alerts)
|
|
740
|
+
|
|
741
|
+
# Collect dispatch jobs while inside BEGIN (set-then-dispatch:
|
|
742
|
+
# alerted_at UPDATE stays inside the transaction per spec §3.2)
|
|
743
|
+
# but DEFER `_dispatch_alert_notification` until AFTER the outer
|
|
744
|
+
# commit (I1: prevents the inner Popen-time conn.commit() from
|
|
745
|
+
# ending the surrounding BEGIN mid-sequence and breaking the
|
|
746
|
+
# close-older + upsert + cross-flag atomicity envelope).
|
|
747
|
+
pending_alerts: list[dict[str, Any]] = []
|
|
748
|
+
|
|
749
|
+
# Steps 4-5 + 7: transaction wraps close-older + upsert so a
|
|
750
|
+
# mid-sequence failure doesn't leave the prior block closed
|
|
751
|
+
# without the current block opened/updated.
|
|
752
|
+
now_iso = now_utc_iso()
|
|
753
|
+
conn.execute("BEGIN")
|
|
754
|
+
try:
|
|
755
|
+
# Step 5: close any STRICTLY OLDER open block. `<` not `!=`
|
|
756
|
+
# — record-usage runs in parallel via background hook-tick &
|
|
757
|
+
# detach + status-line ticks; an older invocation completing
|
|
758
|
+
# after a newer one would close the now-current block under
|
|
759
|
+
# `!=`. With `<`, an older invocation only closes still-older
|
|
760
|
+
# blocks. window_key is a 10-min-floored monotonic epoch.
|
|
761
|
+
conn.execute(
|
|
762
|
+
"""
|
|
763
|
+
UPDATE five_hour_blocks
|
|
764
|
+
SET is_closed = 1, last_updated_at_utc = ?
|
|
765
|
+
WHERE is_closed = 0
|
|
766
|
+
AND five_hour_window_key < ?
|
|
767
|
+
""",
|
|
768
|
+
(now_iso, int(five_hour_window_key)),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# Step 5b: natural-expiration sweep. The close-older predicate
|
|
772
|
+
# above only fires when a strictly-newer window arrives. A user
|
|
773
|
+
# who lets a block expire without a successor (idle / shut down
|
|
774
|
+
# past the 5h reset) would otherwise leave the row at
|
|
775
|
+
# is_closed = 0 forever. Idempotent (only flips 0 → 1); safe to
|
|
776
|
+
# re-run every tick. ISO-string compare is monotonic so it
|
|
777
|
+
# works directly on five_hour_resets_at.
|
|
778
|
+
conn.execute(
|
|
779
|
+
"""
|
|
780
|
+
UPDATE five_hour_blocks
|
|
781
|
+
SET is_closed = 1, last_updated_at_utc = ?
|
|
782
|
+
WHERE is_closed = 0
|
|
783
|
+
AND five_hour_resets_at < ?
|
|
784
|
+
""",
|
|
785
|
+
(now_iso, now_iso),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Step 7: atomic upsert. Single statement collapses the
|
|
789
|
+
# insert-vs-update branches and is race-safe: when two
|
|
790
|
+
# record-usage invocations both observe `prior is None`
|
|
791
|
+
# for a brand-new window (the SELECT at line 8636 happens
|
|
792
|
+
# before BEGIN), the loser's INSERT lands as DO UPDATE
|
|
793
|
+
# rather than raising IntegrityError on the
|
|
794
|
+
# UNIQUE(five_hour_window_key) constraint and dropping the
|
|
795
|
+
# tick. Immutable columns (block_start_at,
|
|
796
|
+
# first_observed_at_utc, five_hour_resets_at,
|
|
797
|
+
# seven_day_pct_at_block_start, created_at_utc) are
|
|
798
|
+
# deliberately omitted from DO UPDATE — first writer
|
|
799
|
+
# owns them.
|
|
800
|
+
conn.execute(
|
|
801
|
+
"""
|
|
802
|
+
INSERT INTO five_hour_blocks (
|
|
803
|
+
five_hour_window_key,
|
|
804
|
+
five_hour_resets_at,
|
|
805
|
+
block_start_at,
|
|
806
|
+
first_observed_at_utc,
|
|
807
|
+
last_observed_at_utc,
|
|
808
|
+
final_five_hour_percent,
|
|
809
|
+
seven_day_pct_at_block_start,
|
|
810
|
+
seven_day_pct_at_block_end,
|
|
811
|
+
crossed_seven_day_reset,
|
|
812
|
+
total_input_tokens,
|
|
813
|
+
total_output_tokens,
|
|
814
|
+
total_cache_create_tokens,
|
|
815
|
+
total_cache_read_tokens,
|
|
816
|
+
total_cost_usd,
|
|
817
|
+
is_closed,
|
|
818
|
+
created_at_utc,
|
|
819
|
+
last_updated_at_utc
|
|
820
|
+
)
|
|
821
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
822
|
+
ON CONFLICT(five_hour_window_key) DO UPDATE SET
|
|
823
|
+
last_observed_at_utc = excluded.last_observed_at_utc,
|
|
824
|
+
final_five_hour_percent = excluded.final_five_hour_percent,
|
|
825
|
+
seven_day_pct_at_block_end = excluded.seven_day_pct_at_block_end,
|
|
826
|
+
total_input_tokens = excluded.total_input_tokens,
|
|
827
|
+
total_output_tokens = excluded.total_output_tokens,
|
|
828
|
+
total_cache_create_tokens = excluded.total_cache_create_tokens,
|
|
829
|
+
total_cache_read_tokens = excluded.total_cache_read_tokens,
|
|
830
|
+
total_cost_usd = excluded.total_cost_usd,
|
|
831
|
+
last_updated_at_utc = excluded.last_updated_at_utc
|
|
832
|
+
""",
|
|
833
|
+
(
|
|
834
|
+
int(five_hour_window_key),
|
|
835
|
+
str(five_hour_resets_at),
|
|
836
|
+
block_start_at,
|
|
837
|
+
captured_at,
|
|
838
|
+
captured_at,
|
|
839
|
+
float(five_hour_percent),
|
|
840
|
+
weekly_percent,
|
|
841
|
+
weekly_percent,
|
|
842
|
+
totals["input_tokens"],
|
|
843
|
+
totals["output_tokens"],
|
|
844
|
+
totals["cache_create_tokens"],
|
|
845
|
+
totals["cache_read_tokens"],
|
|
846
|
+
totals["cost_usd"],
|
|
847
|
+
now_iso,
|
|
848
|
+
now_iso,
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# ── Resolve current block_id once for reuse by the per-(block, model)
|
|
853
|
+
# / per-(block, project) child writes below AND the existing milestone
|
|
854
|
+
# detection (which previously did its own SELECT — drop that SELECT in
|
|
855
|
+
# favor of this variable).
|
|
856
|
+
block_id_row = conn.execute(
|
|
857
|
+
"SELECT id FROM five_hour_blocks WHERE five_hour_window_key = ?",
|
|
858
|
+
(int(five_hour_window_key),),
|
|
859
|
+
).fetchone()
|
|
860
|
+
block_id = int(block_id_row["id"])
|
|
861
|
+
|
|
862
|
+
# ── Replace-all per-tick: per-(block, model) and per-(block, project_path)
|
|
863
|
+
# rollup-children. DELETE keyed on five_hour_window_key (NOT block_id) so
|
|
864
|
+
# orphan child rows from a prior parent rebuild are cleaned up automatically.
|
|
865
|
+
# Same transaction as the parent upsert; if these raise, the whole tick
|
|
866
|
+
# rolls back and the next tick recomputes from scratch.
|
|
867
|
+
conn.execute(
|
|
868
|
+
"DELETE FROM five_hour_block_models WHERE five_hour_window_key = ?",
|
|
869
|
+
(int(five_hour_window_key),),
|
|
870
|
+
)
|
|
871
|
+
if totals.get("by_model"):
|
|
872
|
+
conn.executemany(
|
|
873
|
+
"""
|
|
874
|
+
INSERT INTO five_hour_block_models (
|
|
875
|
+
block_id, five_hour_window_key, model,
|
|
876
|
+
input_tokens, output_tokens,
|
|
877
|
+
cache_create_tokens, cache_read_tokens,
|
|
878
|
+
cost_usd, entry_count
|
|
879
|
+
)
|
|
880
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
881
|
+
""",
|
|
882
|
+
[
|
|
883
|
+
(
|
|
884
|
+
block_id,
|
|
885
|
+
int(five_hour_window_key),
|
|
886
|
+
model,
|
|
887
|
+
b["input_tokens"],
|
|
888
|
+
b["output_tokens"],
|
|
889
|
+
b["cache_create_tokens"],
|
|
890
|
+
b["cache_read_tokens"],
|
|
891
|
+
b["cost_usd"],
|
|
892
|
+
b["entry_count"],
|
|
893
|
+
)
|
|
894
|
+
for model, b in totals["by_model"].items()
|
|
895
|
+
],
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
conn.execute(
|
|
899
|
+
"DELETE FROM five_hour_block_projects WHERE five_hour_window_key = ?",
|
|
900
|
+
(int(five_hour_window_key),),
|
|
901
|
+
)
|
|
902
|
+
if totals.get("by_project"):
|
|
903
|
+
conn.executemany(
|
|
904
|
+
"""
|
|
905
|
+
INSERT INTO five_hour_block_projects (
|
|
906
|
+
block_id, five_hour_window_key, project_path,
|
|
907
|
+
input_tokens, output_tokens,
|
|
908
|
+
cache_create_tokens, cache_read_tokens,
|
|
909
|
+
cost_usd, entry_count
|
|
910
|
+
)
|
|
911
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
912
|
+
""",
|
|
913
|
+
[
|
|
914
|
+
(
|
|
915
|
+
block_id,
|
|
916
|
+
int(five_hour_window_key),
|
|
917
|
+
project_path,
|
|
918
|
+
b["input_tokens"],
|
|
919
|
+
b["output_tokens"],
|
|
920
|
+
b["cache_create_tokens"],
|
|
921
|
+
b["cache_read_tokens"],
|
|
922
|
+
b["cost_usd"],
|
|
923
|
+
b["entry_count"],
|
|
924
|
+
)
|
|
925
|
+
for project_path, b in totals["by_project"].items()
|
|
926
|
+
],
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# ── 5h-% milestone detection (mirrors maybe_record_milestone) ──
|
|
930
|
+
# Snap-up-by-1e-9 per the gotcha: 0.50 * 100 == 49.99...9 in
|
|
931
|
+
# IEEE-754, so bare math.floor would miss the 50 threshold.
|
|
932
|
+
current_floor = math.floor(float(five_hour_percent) + 1e-9)
|
|
933
|
+
if current_floor >= 1:
|
|
934
|
+
# Use max(percent_threshold) directly (not prior block's
|
|
935
|
+
# final_pct) so first-observation already-mid-stream doesn't
|
|
936
|
+
# synthesize crossings 1..(current_floor - 1) we never had
|
|
937
|
+
# authentic moment-of-detection data for. Same shape as
|
|
938
|
+
# maybe_record_milestone's max_existing path.
|
|
939
|
+
row = conn.execute(
|
|
940
|
+
"SELECT MAX(percent_threshold) AS m FROM five_hour_milestones "
|
|
941
|
+
"WHERE five_hour_window_key = ?",
|
|
942
|
+
(int(five_hour_window_key),),
|
|
943
|
+
).fetchone()
|
|
944
|
+
max_existing = row["m"] if row and row["m"] is not None else None
|
|
945
|
+
|
|
946
|
+
if max_existing is None:
|
|
947
|
+
start_threshold = current_floor # first observation: only current floor
|
|
948
|
+
else:
|
|
949
|
+
start_threshold = int(max_existing) + 1
|
|
950
|
+
|
|
951
|
+
if start_threshold <= current_floor:
|
|
952
|
+
# block_id was resolved above (before the children writes) and
|
|
953
|
+
# is still in scope here.
|
|
954
|
+
|
|
955
|
+
# Marginal-cost lookup for the start_threshold milestone
|
|
956
|
+
# (only when there's a prior milestone in this block).
|
|
957
|
+
prior_cost: float | None = None
|
|
958
|
+
if max_existing is not None:
|
|
959
|
+
prev_row = conn.execute(
|
|
960
|
+
"SELECT block_cost_usd FROM five_hour_milestones "
|
|
961
|
+
"WHERE five_hour_window_key = ? AND percent_threshold = ?",
|
|
962
|
+
(int(five_hour_window_key), int(max_existing)),
|
|
963
|
+
).fetchone()
|
|
964
|
+
if prev_row is not None:
|
|
965
|
+
prior_cost = float(prev_row["block_cost_usd"])
|
|
966
|
+
|
|
967
|
+
for pct in range(start_threshold, current_floor + 1):
|
|
968
|
+
if pct == start_threshold and prior_cost is not None:
|
|
969
|
+
marginal: float | None = totals["cost_usd"] - prior_cost
|
|
970
|
+
else:
|
|
971
|
+
marginal = None
|
|
972
|
+
cur = conn.execute(
|
|
973
|
+
"""
|
|
974
|
+
INSERT OR IGNORE INTO five_hour_milestones (
|
|
975
|
+
block_id,
|
|
976
|
+
five_hour_window_key,
|
|
977
|
+
percent_threshold,
|
|
978
|
+
captured_at_utc,
|
|
979
|
+
usage_snapshot_id,
|
|
980
|
+
block_input_tokens,
|
|
981
|
+
block_output_tokens,
|
|
982
|
+
block_cache_create_tokens,
|
|
983
|
+
block_cache_read_tokens,
|
|
984
|
+
block_cost_usd,
|
|
985
|
+
marginal_cost_usd,
|
|
986
|
+
seven_day_pct_at_crossing
|
|
987
|
+
)
|
|
988
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
989
|
+
""",
|
|
990
|
+
(
|
|
991
|
+
block_id,
|
|
992
|
+
int(five_hour_window_key),
|
|
993
|
+
int(pct),
|
|
994
|
+
captured_at,
|
|
995
|
+
int(snapshot_id),
|
|
996
|
+
totals["input_tokens"],
|
|
997
|
+
totals["output_tokens"],
|
|
998
|
+
totals["cache_create_tokens"],
|
|
999
|
+
totals["cache_read_tokens"],
|
|
1000
|
+
totals["cost_usd"],
|
|
1001
|
+
marginal,
|
|
1002
|
+
weekly_percent,
|
|
1003
|
+
),
|
|
1004
|
+
)
|
|
1005
|
+
# ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
|
|
1006
|
+
# Only the genuine-new-crossing winner (rowcount==1)
|
|
1007
|
+
# reaches dispatch. Concurrent record-usage instances
|
|
1008
|
+
# that race on the same (five_hour_window_key,
|
|
1009
|
+
# percent_threshold) get rowcount==0 from the
|
|
1010
|
+
# INSERT OR IGNORE and skip dispatch entirely.
|
|
1011
|
+
# `alerted_at IS NULL` on the UPDATE preserves
|
|
1012
|
+
# write-once even if two writers somehow both think
|
|
1013
|
+
# they won.
|
|
1014
|
+
#
|
|
1015
|
+
# I1: alerted_at UPDATE stays inside BEGIN (set-then-
|
|
1016
|
+
# dispatch invariant per spec §3.2 — the row carries
|
|
1017
|
+
# alerted_at BEFORE any externally-observable side
|
|
1018
|
+
# effect). The single outer commit at the bottom of
|
|
1019
|
+
# this BEGIN durably writes the milestone row AND the
|
|
1020
|
+
# alerted_at update together. Dispatch itself is
|
|
1021
|
+
# collected into pending_alerts and fired AFTER the
|
|
1022
|
+
# outer commit so the inner Popen-time bookkeeping
|
|
1023
|
+
# never ends the surrounding BEGIN mid-sequence.
|
|
1024
|
+
if (
|
|
1025
|
+
cur.rowcount == 1
|
|
1026
|
+
and alerts_cfg is not None
|
|
1027
|
+
and alerts_cfg["enabled"]
|
|
1028
|
+
and pct in alerts_cfg["five_hour_thresholds"]
|
|
1029
|
+
):
|
|
1030
|
+
crossed_at = now_utc_iso()
|
|
1031
|
+
conn.execute(
|
|
1032
|
+
"UPDATE five_hour_milestones SET alerted_at = ? "
|
|
1033
|
+
"WHERE five_hour_window_key = ? AND percent_threshold = ? "
|
|
1034
|
+
"AND alerted_at IS NULL",
|
|
1035
|
+
(crossed_at, int(five_hour_window_key), int(pct)),
|
|
1036
|
+
)
|
|
1037
|
+
# Cheap re-reads inside BEGIN are SELECT-only and
|
|
1038
|
+
# safe; values reflect post-INSERT state. We
|
|
1039
|
+
# build the payload now (while block_id / totals
|
|
1040
|
+
# are in scope) and defer ONLY the Popen-side
|
|
1041
|
+
# _dispatch_alert_notification to after the outer
|
|
1042
|
+
# commit.
|
|
1043
|
+
cost_row = conn.execute(
|
|
1044
|
+
"SELECT block_cost_usd FROM five_hour_milestones "
|
|
1045
|
+
"WHERE five_hour_window_key = ? AND percent_threshold = ?",
|
|
1046
|
+
(int(five_hour_window_key), int(pct)),
|
|
1047
|
+
).fetchone()
|
|
1048
|
+
block_row = conn.execute(
|
|
1049
|
+
"SELECT block_start_at FROM five_hour_blocks "
|
|
1050
|
+
"WHERE five_hour_window_key = ?",
|
|
1051
|
+
(int(five_hour_window_key),),
|
|
1052
|
+
).fetchone()
|
|
1053
|
+
primary_model = _resolve_primary_model_for_block(
|
|
1054
|
+
conn, int(five_hour_window_key)
|
|
1055
|
+
)
|
|
1056
|
+
payload = _build_alert_payload_five_hour(
|
|
1057
|
+
threshold=int(pct),
|
|
1058
|
+
crossed_at_utc=crossed_at,
|
|
1059
|
+
five_hour_window_key=int(five_hour_window_key),
|
|
1060
|
+
block_start_at=(
|
|
1061
|
+
block_row["block_start_at"] if block_row else ""
|
|
1062
|
+
),
|
|
1063
|
+
block_cost_usd=(
|
|
1064
|
+
float(cost_row["block_cost_usd"])
|
|
1065
|
+
if cost_row
|
|
1066
|
+
else 0.0
|
|
1067
|
+
),
|
|
1068
|
+
primary_model=primary_model,
|
|
1069
|
+
)
|
|
1070
|
+
pending_alerts.append(payload)
|
|
1071
|
+
|
|
1072
|
+
# ── Reset-crossing cross-flag (opportunistic, JOIN-based) ──
|
|
1073
|
+
# Self-healing sweep: every tick, flag any open block whose
|
|
1074
|
+
# [block_start_at, last_observed_at_utc] interval crosses a
|
|
1075
|
+
# weekly reset, from either of two sources:
|
|
1076
|
+
# (a) week_reset_events — Anthropic-shifted MID-week resets
|
|
1077
|
+
# (prior week_end_at was still in the future at detect
|
|
1078
|
+
# time; see cmd_record_usage's reset-event detection).
|
|
1079
|
+
# (b) weekly_usage_snapshots.week_start_at — NATURAL weekly
|
|
1080
|
+
# boundaries. These never get a week_reset_events row
|
|
1081
|
+
# (mid-week detection requires the prior end to be in
|
|
1082
|
+
# the future), so source (a) silently misses blocks
|
|
1083
|
+
# that span a routine week reset. Without this clause
|
|
1084
|
+
# the dashboard's "Δ pp this block" delta is computed
|
|
1085
|
+
# against the pre-reset 7d% (~94%) versus post-reset
|
|
1086
|
+
# (~0%) and renders as a misleading −94pp drop.
|
|
1087
|
+
# Predicate (b) uses strict ``>`` on the lower bound so a
|
|
1088
|
+
# block that starts EXACTLY at the boundary (post-reset) is
|
|
1089
|
+
# not flagged. Symmetric with the historical-backfill
|
|
1090
|
+
# predicate (§4.2 step 5). Idempotent (only flips 0 → 1).
|
|
1091
|
+
#
|
|
1092
|
+
# Comparisons go through ``unixepoch()`` rather than a raw
|
|
1093
|
+
# lex BETWEEN: ``parse_iso_datetime`` returns host-local
|
|
1094
|
+
# tz-aware datetimes (line 9433: ``return parsed.astimezone()``),
|
|
1095
|
+
# so ``block_start_at`` is stored with the host's display
|
|
1096
|
+
# offset (e.g. ``+03:00``) while ``week_start_at`` is
|
|
1097
|
+
# ``+00:00`` and ``last_observed_at_utc`` is ``Z``. A lex
|
|
1098
|
+
# compare across mixed offsets silently mis-orders moments
|
|
1099
|
+
# for non-UTC hosts; ``unixepoch()`` normalizes all three
|
|
1100
|
+
# to seconds-since-epoch and is correct regardless of
|
|
1101
|
+
# offset suffix.
|
|
1102
|
+
#
|
|
1103
|
+
# Why the JOIN rather than a per-tick param: an earlier
|
|
1104
|
+
# design passed mid_week_reset_at only on the tick that
|
|
1105
|
+
# cmd_record_usage's INSERT OR IGNORE actually inserted
|
|
1106
|
+
# the event row. If the helper raised after the event
|
|
1107
|
+
# commit but before the flag UPDATE, the next tick's
|
|
1108
|
+
# INSERT OR IGNORE was a duplicate and the flag stayed 0
|
|
1109
|
+
# forever. The JOIN re-derives from durable state on
|
|
1110
|
+
# every tick and self-heals.
|
|
1111
|
+
conn.execute(
|
|
1112
|
+
"""
|
|
1113
|
+
UPDATE five_hour_blocks
|
|
1114
|
+
SET crossed_seven_day_reset = 1
|
|
1115
|
+
WHERE crossed_seven_day_reset = 0
|
|
1116
|
+
AND (
|
|
1117
|
+
EXISTS (
|
|
1118
|
+
SELECT 1 FROM week_reset_events e
|
|
1119
|
+
WHERE unixepoch(e.effective_reset_at_utc)
|
|
1120
|
+
BETWEEN unixepoch(five_hour_blocks.block_start_at)
|
|
1121
|
+
AND unixepoch(five_hour_blocks.last_observed_at_utc)
|
|
1122
|
+
)
|
|
1123
|
+
OR EXISTS (
|
|
1124
|
+
SELECT 1 FROM weekly_usage_snapshots ws
|
|
1125
|
+
WHERE ws.week_start_at IS NOT NULL
|
|
1126
|
+
AND unixepoch(ws.week_start_at)
|
|
1127
|
+
> unixepoch(five_hour_blocks.block_start_at)
|
|
1128
|
+
AND unixepoch(ws.week_start_at)
|
|
1129
|
+
<= unixepoch(five_hour_blocks.last_observed_at_utc)
|
|
1130
|
+
)
|
|
1131
|
+
)
|
|
1132
|
+
""",
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
conn.commit()
|
|
1136
|
+
except Exception:
|
|
1137
|
+
conn.rollback()
|
|
1138
|
+
raise
|
|
1139
|
+
|
|
1140
|
+
# I1: dispatch deferred to AFTER the outer commit. The milestone
|
|
1141
|
+
# row + alerted_at update + close-older + parent upsert + child
|
|
1142
|
+
# rebuilds + cross-flag sweep are all durably written together
|
|
1143
|
+
# before any externally-observable osascript Popen fires. If the
|
|
1144
|
+
# inner BEGIN rolled back above, `pending_alerts` is unreachable
|
|
1145
|
+
# (the `raise` above bubbles out via the outer try). Production
|
|
1146
|
+
# caller ignores _dispatch_alert_notification's return value
|
|
1147
|
+
# (spec §6.4); a per-payload exception is logged and the loop
|
|
1148
|
+
# continues so a bad-payload alert can't suppress healthy ones.
|
|
1149
|
+
for payload in pending_alerts:
|
|
1150
|
+
try:
|
|
1151
|
+
_dispatch_alert_notification(
|
|
1152
|
+
payload, mode="real", tz=display_tz_for_alerts
|
|
1153
|
+
)
|
|
1154
|
+
except Exception as dispatch_exc:
|
|
1155
|
+
eprint(f"[alerts] dispatch failed: {dispatch_exc}")
|
|
1156
|
+
except Exception as exc:
|
|
1157
|
+
eprint(f"[5h-block] error updating block: {exc}")
|
|
1158
|
+
finally:
|
|
1159
|
+
conn.close()
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
1163
|
+
"""Record usage data from Claude Code status line rate_limits."""
|
|
1164
|
+
c = _cctally()
|
|
1165
|
+
config = load_config()
|
|
1166
|
+
week_start_name = get_week_start_name(config, getattr(args, "week_start_name", None))
|
|
1167
|
+
|
|
1168
|
+
# ULP-noise sanitization is applied at the cmd_record_usage ingress
|
|
1169
|
+
# boundary so every downstream consumer (HWM files, DB rows,
|
|
1170
|
+
# five_hour_blocks rollup, milestones) reads a stable value. See
|
|
1171
|
+
# `_normalize_percent` for the rationale.
|
|
1172
|
+
weekly_percent = _normalize_percent(args.percent)
|
|
1173
|
+
resets_at = int(args.resets_at)
|
|
1174
|
+
|
|
1175
|
+
five_hour_percent: float | None = None
|
|
1176
|
+
five_hour_resets_at_str: str | None = None
|
|
1177
|
+
five_hour_window_key: int | None = None
|
|
1178
|
+
five_hour_resets_at_epoch: int | None = None
|
|
1179
|
+
if args.five_hour_percent is not None:
|
|
1180
|
+
five_hour_percent = _normalize_percent(args.five_hour_percent)
|
|
1181
|
+
if args.five_hour_resets_at is not None:
|
|
1182
|
+
five_hour_resets_at_epoch = int(args.five_hour_resets_at)
|
|
1183
|
+
five_hour_resets_at_str = dt.datetime.fromtimestamp(
|
|
1184
|
+
five_hour_resets_at_epoch, tz=dt.timezone.utc
|
|
1185
|
+
).isoformat(timespec="seconds")
|
|
1186
|
+
# five_hour_window_key derivation is deferred until after open_db()
|
|
1187
|
+
# so we can pass the most-recent stored sample as the prior anchor.
|
|
1188
|
+
# See _canonical_5h_window_key docstring (spec invariant #3:
|
|
1189
|
+
# boundary-straddling jitter must collapse to the first-seen key).
|
|
1190
|
+
|
|
1191
|
+
# Derive week boundaries from resets_at (exact UTC epoch)
|
|
1192
|
+
week_end_at_dt = dt.datetime.fromtimestamp(resets_at, tz=dt.timezone.utc)
|
|
1193
|
+
week_start_at_dt = week_end_at_dt - dt.timedelta(days=7)
|
|
1194
|
+
week_start_date = week_start_at_dt.date().isoformat()
|
|
1195
|
+
week_end_date = week_end_at_dt.date().isoformat()
|
|
1196
|
+
week_start_at = week_start_at_dt.isoformat(timespec="seconds")
|
|
1197
|
+
week_end_at = week_end_at_dt.isoformat(timespec="seconds")
|
|
1198
|
+
|
|
1199
|
+
# Deduplication: skip if nothing changed since last snapshot
|
|
1200
|
+
should_insert = True
|
|
1201
|
+
conn = open_db()
|
|
1202
|
+
try:
|
|
1203
|
+
# Resolve the canonical 5h window key. Pass the most-recent stored
|
|
1204
|
+
# sample as the prior anchor so seconds-level jitter that straddles
|
|
1205
|
+
# a 600-second floor-bucket boundary (e.g. resets_at=1746014999 vs.
|
|
1206
|
+
# 1746015000) collapses to the first-seen key instead of forking
|
|
1207
|
+
# a new one. Without this, both the DB clamp below and the hwm-5h
|
|
1208
|
+
# file write further down would treat the same physical window as
|
|
1209
|
+
# distinct, regressing the monotonic 5h percent (spec invariant #3).
|
|
1210
|
+
if five_hour_resets_at_epoch is not None:
|
|
1211
|
+
prior_5h_epoch: int | None = None
|
|
1212
|
+
prior_5h_key: int | None = None
|
|
1213
|
+
# Tier 1: blocks-table lookup (steady state). Find the closest
|
|
1214
|
+
# canonical block whose five_hour_resets_at is within ±1800s of
|
|
1215
|
+
# the new resets_at. The blocks table has one canonical row per
|
|
1216
|
+
# physical window after the merge_5h_block_duplicates_v1
|
|
1217
|
+
# migration, so this is more reliable than scanning
|
|
1218
|
+
# weekly_usage_snapshots for an "anchor" row — snapshots can be
|
|
1219
|
+
# noisy when the status line returns out-of-order rate-limit
|
|
1220
|
+
# data from older windows (the F4 incident: snap N+1 carrying a
|
|
1221
|
+
# window-A boundary-jitter resets_at, but snap N reported an
|
|
1222
|
+
# OLDER window B; pre-fix the prior-anchor lookup picked B as
|
|
1223
|
+
# the anchor and the |epoch-prior| > 600 check then forked a
|
|
1224
|
+
# new key for what was actually still window A). 1800s is wide
|
|
1225
|
+
# enough to absorb known jitter, narrow enough that consecutive
|
|
1226
|
+
# 5h blocks (>4h apart in resets_at) cannot collide.
|
|
1227
|
+
try:
|
|
1228
|
+
prior_block_row = conn.execute(
|
|
1229
|
+
"""
|
|
1230
|
+
SELECT five_hour_window_key, five_hour_resets_at
|
|
1231
|
+
FROM five_hour_blocks
|
|
1232
|
+
WHERE abs(? - CAST(strftime('%s', five_hour_resets_at) AS INTEGER)) <= ?
|
|
1233
|
+
ORDER BY abs(? - CAST(strftime('%s', five_hour_resets_at) AS INTEGER)) ASC
|
|
1234
|
+
LIMIT 1
|
|
1235
|
+
""",
|
|
1236
|
+
(
|
|
1237
|
+
five_hour_resets_at_epoch,
|
|
1238
|
+
c._FIVE_HOUR_JITTER_FLOOR_SECONDS * 3,
|
|
1239
|
+
five_hour_resets_at_epoch,
|
|
1240
|
+
),
|
|
1241
|
+
).fetchone()
|
|
1242
|
+
if prior_block_row is not None:
|
|
1243
|
+
prior_iso = prior_block_row["five_hour_resets_at"]
|
|
1244
|
+
prior_5h_epoch = int(parse_iso_datetime(
|
|
1245
|
+
prior_iso, "prior 5h block anchor"
|
|
1246
|
+
).timestamp())
|
|
1247
|
+
prior_5h_key = int(prior_block_row["five_hour_window_key"])
|
|
1248
|
+
except (sqlite3.DatabaseError, ValueError, TypeError) as exc:
|
|
1249
|
+
eprint(f"[record-usage] prior 5h block-anchor lookup failed: {exc}")
|
|
1250
|
+
|
|
1251
|
+
# Tier 2: snapshot lookup (legacy fallback). Only run when Tier
|
|
1252
|
+
# 1 missed (no canonical block row exists yet — the brand-new-
|
|
1253
|
+
# window case before any record-usage tick has materialized a
|
|
1254
|
+
# five_hour_blocks row). Tier 1's empty-result guard is the
|
|
1255
|
+
# `prior_block_row is not None` test above; replicating it
|
|
1256
|
+
# here keeps Tier 2 strictly secondary.
|
|
1257
|
+
if prior_5h_key is None:
|
|
1258
|
+
try:
|
|
1259
|
+
prior_5h_row = conn.execute(
|
|
1260
|
+
"SELECT five_hour_resets_at, five_hour_window_key "
|
|
1261
|
+
"FROM weekly_usage_snapshots "
|
|
1262
|
+
"WHERE five_hour_resets_at IS NOT NULL "
|
|
1263
|
+
" AND five_hour_window_key IS NOT NULL "
|
|
1264
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
1265
|
+
).fetchone()
|
|
1266
|
+
if prior_5h_row is not None:
|
|
1267
|
+
prior_iso = prior_5h_row["five_hour_resets_at"]
|
|
1268
|
+
prior_5h_epoch = int(parse_iso_datetime(
|
|
1269
|
+
prior_iso, "prior 5h anchor"
|
|
1270
|
+
).timestamp())
|
|
1271
|
+
prior_5h_key = int(prior_5h_row["five_hour_window_key"])
|
|
1272
|
+
except (sqlite3.DatabaseError, ValueError, TypeError) as exc:
|
|
1273
|
+
eprint(f"[record-usage] prior 5h anchor lookup failed: {exc}")
|
|
1274
|
+
|
|
1275
|
+
# Tier 3 is implicit: with no anchor, _canonical_5h_window_key
|
|
1276
|
+
# falls back to the pure 600-second floor.
|
|
1277
|
+
five_hour_window_key = _canonical_5h_window_key(
|
|
1278
|
+
five_hour_resets_at_epoch,
|
|
1279
|
+
prior_epoch=prior_5h_epoch,
|
|
1280
|
+
prior_key=prior_5h_key,
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# Mid-week reset detection. When `resets_at` advances before the
|
|
1284
|
+
# previously-declared reset actually fires (Anthropic-initiated
|
|
1285
|
+
# goodwill reset, or any API-side shift), record one week_reset_events
|
|
1286
|
+
# row so display + cost layers can treat the observed moment as the
|
|
1287
|
+
# old week's effective end AND the new week's effective start. The
|
|
1288
|
+
# monotonic check below stays keyed on week_start_date so it still
|
|
1289
|
+
# guards the new week against stale rate-limit data independently.
|
|
1290
|
+
# Both boundaries canonicalize to hour (same rule make_week_ref uses)
|
|
1291
|
+
# so minute/second-level Anthropic jitter doesn't masquerade as a
|
|
1292
|
+
# reset and the stored values match what WeekRef.week_end_at carries.
|
|
1293
|
+
# The 5h-block cross-flag is no longer threaded from here —
|
|
1294
|
+
# maybe_update_five_hour_block re-derives it every tick by JOINing
|
|
1295
|
+
# against week_reset_events (self-healing, see helper for rationale).
|
|
1296
|
+
try:
|
|
1297
|
+
cur_end_canon = _canonicalize_optional_iso(week_end_at, "record.cur")
|
|
1298
|
+
prior = conn.execute(
|
|
1299
|
+
"SELECT week_end_at, weekly_percent FROM weekly_usage_snapshots "
|
|
1300
|
+
"WHERE week_end_at IS NOT NULL "
|
|
1301
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
1302
|
+
).fetchone()
|
|
1303
|
+
if prior and prior["week_end_at"] and cur_end_canon:
|
|
1304
|
+
prior_end_canon = _canonicalize_optional_iso(
|
|
1305
|
+
prior["week_end_at"], "record.prior"
|
|
1306
|
+
)
|
|
1307
|
+
prior_pct = prior["weekly_percent"]
|
|
1308
|
+
if prior_end_canon and prior_end_canon != cur_end_canon:
|
|
1309
|
+
prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
|
|
1310
|
+
now_utc = dt.datetime.now(dt.timezone.utc)
|
|
1311
|
+
# Fire only when (a) prior window was still in the FUTURE
|
|
1312
|
+
# (Anthropic shifted the boundary before natural expiration),
|
|
1313
|
+
# AND (b) weekly_percent dropped by RESET_PCT_DROP_THRESHOLD
|
|
1314
|
+
# or more (filters out API flaps / transient boundary
|
|
1315
|
+
# jitter where usage stays roughly the same).
|
|
1316
|
+
if (
|
|
1317
|
+
prior_end_dt > now_utc
|
|
1318
|
+
and prior_pct is not None
|
|
1319
|
+
and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
|
|
1320
|
+
):
|
|
1321
|
+
# See _backfill_week_reset_events for why we floor
|
|
1322
|
+
# the reset moment to the hour (natural display
|
|
1323
|
+
# boundary, aligned with Anthropic's hour-only
|
|
1324
|
+
# resets_at values).
|
|
1325
|
+
effective_iso = _floor_to_hour(now_utc).isoformat(timespec="seconds")
|
|
1326
|
+
conn.execute(
|
|
1327
|
+
"INSERT OR IGNORE INTO week_reset_events "
|
|
1328
|
+
"(detected_at_utc, old_week_end_at, new_week_end_at, "
|
|
1329
|
+
" effective_reset_at_utc) VALUES (?, ?, ?, ?)",
|
|
1330
|
+
(now_utc_iso(), prior_end_canon, cur_end_canon,
|
|
1331
|
+
effective_iso),
|
|
1332
|
+
)
|
|
1333
|
+
conn.commit()
|
|
1334
|
+
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
1335
|
+
eprint(f"[record-usage] reset-event detection failed: {exc}")
|
|
1336
|
+
|
|
1337
|
+
# 7-day usage is monotonically non-decreasing within a billing week.
|
|
1338
|
+
# A lower value means stale rate-limit data from a previous API call;
|
|
1339
|
+
# skip the insert to avoid regressing the reported usage.
|
|
1340
|
+
max_row = conn.execute(
|
|
1341
|
+
"SELECT MAX(weekly_percent) AS v FROM weekly_usage_snapshots WHERE week_start_date = ?",
|
|
1342
|
+
(week_start_date,),
|
|
1343
|
+
).fetchone()
|
|
1344
|
+
if max_row and max_row["v"] is not None and round(weekly_percent, 1) < round(float(max_row["v"]), 1):
|
|
1345
|
+
should_insert = False
|
|
1346
|
+
else:
|
|
1347
|
+
# 5-hour usage is monotonically non-decreasing within a window.
|
|
1348
|
+
# A lower value means stale API data; clamp to existing max.
|
|
1349
|
+
# Joining on five_hour_window_key (canonical 10-min-floored
|
|
1350
|
+
# epoch) absorbs Anthropic's seconds-level jitter on
|
|
1351
|
+
# resets_at; an ISO-string equality at this site silently
|
|
1352
|
+
# skipped the clamp every time a jittered fetch landed in
|
|
1353
|
+
# the same physical 5h window (spec Bug B).
|
|
1354
|
+
if five_hour_percent is not None and five_hour_window_key is not None:
|
|
1355
|
+
max_5h_row = conn.execute(
|
|
1356
|
+
"SELECT MAX(five_hour_percent) AS v FROM weekly_usage_snapshots WHERE five_hour_window_key = ?",
|
|
1357
|
+
(five_hour_window_key,),
|
|
1358
|
+
).fetchone()
|
|
1359
|
+
if max_5h_row and max_5h_row["v"] is not None and round(five_hour_percent, 1) < round(float(max_5h_row["v"]), 1):
|
|
1360
|
+
five_hour_percent = float(max_5h_row["v"])
|
|
1361
|
+
|
|
1362
|
+
# Dedup vs last snapshot: if BOTH weekly_percent and
|
|
1363
|
+
# five_hour_percent are unchanged from the most recent row in
|
|
1364
|
+
# this week, swallow the insert. Tests of the 5h clamp must
|
|
1365
|
+
# vary --percent (or --five-hour-percent) between calls, or
|
|
1366
|
+
# the second call is dropped here before the clamp even runs
|
|
1367
|
+
# — see bin/cctally-5h-canonical-test scenario B.
|
|
1368
|
+
last = conn.execute(
|
|
1369
|
+
"""
|
|
1370
|
+
SELECT weekly_percent, five_hour_percent
|
|
1371
|
+
FROM weekly_usage_snapshots
|
|
1372
|
+
WHERE week_start_date = ?
|
|
1373
|
+
ORDER BY captured_at_utc DESC, id DESC
|
|
1374
|
+
LIMIT 1
|
|
1375
|
+
""",
|
|
1376
|
+
(week_start_date,),
|
|
1377
|
+
).fetchone()
|
|
1378
|
+
if last is not None:
|
|
1379
|
+
if float(last["weekly_percent"]) == weekly_percent:
|
|
1380
|
+
last_5h = last["five_hour_percent"]
|
|
1381
|
+
if five_hour_percent is None or (
|
|
1382
|
+
last_5h is not None and float(last_5h) == five_hour_percent
|
|
1383
|
+
):
|
|
1384
|
+
should_insert = False
|
|
1385
|
+
|
|
1386
|
+
# No backfill of 5h data on existing milestones — we don't have
|
|
1387
|
+
# authentic crossing-time values for them. New milestones created
|
|
1388
|
+
# by the status line path will have 5h data set at creation time
|
|
1389
|
+
# via maybe_record_milestone().
|
|
1390
|
+
finally:
|
|
1391
|
+
conn.close()
|
|
1392
|
+
|
|
1393
|
+
if not should_insert:
|
|
1394
|
+
# Self-heal: a prior record-usage invocation may have inserted
|
|
1395
|
+
# the snapshot but been killed (CC self-update, machine sleep,
|
|
1396
|
+
# OOM) before maybe_record_milestone / maybe_update_five_hour_block
|
|
1397
|
+
# could run. Pre-probe both surfaces with cheap indexed SELECTs
|
|
1398
|
+
# and only invoke the helpers when a row is actually missing or
|
|
1399
|
+
# stale. Steady-state cost: 1-3 SELECTs (latest snapshot always;
|
|
1400
|
+
# +max_milestone if floor>=1; +block last_observed if window_key
|
|
1401
|
+
# is set); ZERO JSONL re-ingest on healthy ticks. The helpers themselves are idempotent under
|
|
1402
|
+
# concurrent record-usage instances (INSERT OR IGNORE for
|
|
1403
|
+
# percent_milestones; SQLite write-lock serialization for the
|
|
1404
|
+
# 5h upsert). Without the pre-probe, every dedup tick would
|
|
1405
|
+
# trigger sync_cache + a window walk + replace-all rollups via
|
|
1406
|
+
# maybe_update_five_hour_block's unconditional _compute_block_totals
|
|
1407
|
+
# call. Regression: bin/cctally-record-usage-selfheal-test.
|
|
1408
|
+
try:
|
|
1409
|
+
heal_conn = open_db()
|
|
1410
|
+
try:
|
|
1411
|
+
latest_row = heal_conn.execute(
|
|
1412
|
+
"SELECT * FROM weekly_usage_snapshots "
|
|
1413
|
+
"WHERE week_start_date = ? "
|
|
1414
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
1415
|
+
(week_start_date,),
|
|
1416
|
+
).fetchone()
|
|
1417
|
+
if latest_row is None:
|
|
1418
|
+
return 0
|
|
1419
|
+
|
|
1420
|
+
# Probe 1: do we owe a percent milestone? Snap up before
|
|
1421
|
+
# floor (status-line API returns 0.N*100 which can fall
|
|
1422
|
+
# one ULP short of N — same convention as
|
|
1423
|
+
# maybe_record_milestone).
|
|
1424
|
+
latest_floor = math.floor(
|
|
1425
|
+
float(latest_row["weekly_percent"]) + 1e-9
|
|
1426
|
+
)
|
|
1427
|
+
need_milestone_heal = False
|
|
1428
|
+
if latest_floor >= 1:
|
|
1429
|
+
max_existing = heal_conn.execute(
|
|
1430
|
+
"SELECT MAX(percent_threshold) AS m "
|
|
1431
|
+
"FROM percent_milestones "
|
|
1432
|
+
"WHERE week_start_date = ?",
|
|
1433
|
+
(week_start_date,),
|
|
1434
|
+
).fetchone()
|
|
1435
|
+
if max_existing is None or max_existing["m"] is None:
|
|
1436
|
+
need_milestone_heal = True
|
|
1437
|
+
elif int(max_existing["m"]) < latest_floor:
|
|
1438
|
+
need_milestone_heal = True
|
|
1439
|
+
|
|
1440
|
+
# Probe 2: do we owe a 5h-block update? Either no row
|
|
1441
|
+
# for this canonical window, or the existing row's
|
|
1442
|
+
# last_observed_at_utc is stale relative to the latest
|
|
1443
|
+
# snapshot's captured_at_utc (the kill landed between
|
|
1444
|
+
# insert_usage_snapshot and maybe_update_five_hour_block).
|
|
1445
|
+
need_5h_heal = False
|
|
1446
|
+
window_key = latest_row["five_hour_window_key"]
|
|
1447
|
+
if window_key is not None:
|
|
1448
|
+
block_row = heal_conn.execute(
|
|
1449
|
+
"SELECT last_observed_at_utc "
|
|
1450
|
+
"FROM five_hour_blocks "
|
|
1451
|
+
"WHERE five_hour_window_key = ?",
|
|
1452
|
+
(int(window_key),),
|
|
1453
|
+
).fetchone()
|
|
1454
|
+
if block_row is None:
|
|
1455
|
+
need_5h_heal = True
|
|
1456
|
+
elif (
|
|
1457
|
+
block_row["last_observed_at_utc"]
|
|
1458
|
+
< latest_row["captured_at_utc"]
|
|
1459
|
+
):
|
|
1460
|
+
need_5h_heal = True
|
|
1461
|
+
finally:
|
|
1462
|
+
heal_conn.close()
|
|
1463
|
+
|
|
1464
|
+
if need_milestone_heal or need_5h_heal:
|
|
1465
|
+
latest_saved = _saved_dict_from_usage_row(latest_row)
|
|
1466
|
+
if need_milestone_heal:
|
|
1467
|
+
try:
|
|
1468
|
+
maybe_record_milestone(latest_saved)
|
|
1469
|
+
except Exception as exc:
|
|
1470
|
+
eprint(f"[milestone] self-heal error: {exc}")
|
|
1471
|
+
if need_5h_heal:
|
|
1472
|
+
try:
|
|
1473
|
+
maybe_update_five_hour_block(latest_saved)
|
|
1474
|
+
except Exception as exc:
|
|
1475
|
+
eprint(f"[5h-block] self-heal error: {exc}")
|
|
1476
|
+
except Exception as exc:
|
|
1477
|
+
eprint(f"[record-usage] self-heal lookup failed: {exc}")
|
|
1478
|
+
return 0
|
|
1479
|
+
|
|
1480
|
+
payload = {
|
|
1481
|
+
"source": "statusline",
|
|
1482
|
+
"capturedAt": now_utc_iso(),
|
|
1483
|
+
"weeklyPercent": weekly_percent,
|
|
1484
|
+
"weekStartDate": week_start_date,
|
|
1485
|
+
"weekEndDate": week_end_date,
|
|
1486
|
+
"weekStartAt": week_start_at,
|
|
1487
|
+
"weekEndAt": week_end_at,
|
|
1488
|
+
}
|
|
1489
|
+
if five_hour_percent is not None:
|
|
1490
|
+
payload["fiveHourPercent"] = five_hour_percent
|
|
1491
|
+
if five_hour_resets_at_str is not None:
|
|
1492
|
+
payload["fiveHourResetsAt"] = five_hour_resets_at_str
|
|
1493
|
+
if five_hour_window_key is not None:
|
|
1494
|
+
payload["fiveHourWindowKey"] = five_hour_window_key
|
|
1495
|
+
|
|
1496
|
+
saved = insert_usage_snapshot(payload, week_start_name)
|
|
1497
|
+
try:
|
|
1498
|
+
maybe_record_milestone(saved)
|
|
1499
|
+
except Exception as exc:
|
|
1500
|
+
eprint(f"[milestone] unexpected error: {exc}")
|
|
1501
|
+
|
|
1502
|
+
# NEW: 5h-block rollup (paired with maybe_record_milestone for 7d).
|
|
1503
|
+
# The helper performs an opportunistic JOIN against week_reset_events
|
|
1504
|
+
# every tick to flag any open block whose interval contains a recorded
|
|
1505
|
+
# reset; no per-call plumbing needed (self-healing).
|
|
1506
|
+
try:
|
|
1507
|
+
maybe_update_five_hour_block(saved)
|
|
1508
|
+
except Exception as exc:
|
|
1509
|
+
eprint(f"[5h-block] unexpected error: {exc}")
|
|
1510
|
+
|
|
1511
|
+
# Write high-water mark so the status line never displays a regression.
|
|
1512
|
+
# The file contains "week_start_date weekly_percent" on one line.
|
|
1513
|
+
try:
|
|
1514
|
+
hwm_path = c.APP_DIR / "hwm-7d"
|
|
1515
|
+
existing_hwm = 0.0
|
|
1516
|
+
try:
|
|
1517
|
+
parts = hwm_path.read_text().strip().split()
|
|
1518
|
+
if len(parts) == 2 and parts[0] == week_start_date:
|
|
1519
|
+
existing_hwm = float(parts[1])
|
|
1520
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
1521
|
+
pass
|
|
1522
|
+
if weekly_percent >= existing_hwm:
|
|
1523
|
+
hwm_path.write_text(f"{week_start_date} {weekly_percent}\n")
|
|
1524
|
+
except OSError:
|
|
1525
|
+
pass
|
|
1526
|
+
|
|
1527
|
+
# Symmetric 5h HWM. Keyed by the canonical five_hour_window_key derived
|
|
1528
|
+
# under the prior-anchor logic above (NOT a fresh pure-floor recompute) so
|
|
1529
|
+
# boundary-straddling jitter writes to the same file key as the matching
|
|
1530
|
+
# DB row. File format: "<canonical_5h_window_key> <percent>".
|
|
1531
|
+
if (
|
|
1532
|
+
five_hour_percent is not None
|
|
1533
|
+
and five_hour_window_key is not None
|
|
1534
|
+
):
|
|
1535
|
+
try:
|
|
1536
|
+
five_resets_key = five_hour_window_key
|
|
1537
|
+
hwm5_path = c.APP_DIR / "hwm-5h"
|
|
1538
|
+
existing_hwm5 = 0.0
|
|
1539
|
+
try:
|
|
1540
|
+
parts5 = hwm5_path.read_text().strip().split()
|
|
1541
|
+
if len(parts5) == 2 and parts5[0] == str(five_resets_key):
|
|
1542
|
+
existing_hwm5 = float(parts5[1])
|
|
1543
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
1544
|
+
pass
|
|
1545
|
+
if five_hour_percent >= existing_hwm5:
|
|
1546
|
+
hwm5_path.write_text(f"{five_resets_key} {five_hour_percent}\n")
|
|
1547
|
+
except OSError:
|
|
1548
|
+
pass
|
|
1549
|
+
|
|
1550
|
+
return 0
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _hook_tick_log_line(line: str) -> None:
|
|
1554
|
+
"""Append one line to hook-tick.log; create dir if missing.
|
|
1555
|
+
|
|
1556
|
+
Uses O_APPEND so concurrent writers' sub-PIPE_BUF lines don't interleave.
|
|
1557
|
+
Best-effort: any IO error is silently swallowed (hook discipline).
|
|
1558
|
+
"""
|
|
1559
|
+
c = _cctally()
|
|
1560
|
+
try:
|
|
1561
|
+
c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
1562
|
+
fd = os.open(c.HOOK_TICK_LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
1563
|
+
try:
|
|
1564
|
+
os.write(fd, (line.rstrip("\n") + "\n").encode("utf-8", errors="replace"))
|
|
1565
|
+
finally:
|
|
1566
|
+
os.close(fd)
|
|
1567
|
+
except OSError:
|
|
1568
|
+
pass
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def _hook_tick_log_rotate_if_needed() -> None:
|
|
1572
|
+
"""If hook-tick.log exceeds the size cap, atomic-rename to .1 (overwriting)."""
|
|
1573
|
+
c = _cctally()
|
|
1574
|
+
try:
|
|
1575
|
+
size = c.HOOK_TICK_LOG_PATH.stat().st_size
|
|
1576
|
+
except FileNotFoundError:
|
|
1577
|
+
return
|
|
1578
|
+
except OSError:
|
|
1579
|
+
return
|
|
1580
|
+
if size <= c.HOOK_TICK_LOG_ROTATE_BYTES:
|
|
1581
|
+
return
|
|
1582
|
+
try:
|
|
1583
|
+
os.replace(c.HOOK_TICK_LOG_PATH, c.HOOK_TICK_LOG_ROTATED_PATH)
|
|
1584
|
+
except OSError:
|
|
1585
|
+
pass
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def _hook_tick_throttle_age_seconds() -> float:
|
|
1589
|
+
"""Return seconds since last successful OAuth fetch; +inf if never."""
|
|
1590
|
+
c = _cctally()
|
|
1591
|
+
try:
|
|
1592
|
+
mtime = c.HOOK_TICK_THROTTLE_PATH.stat().st_mtime
|
|
1593
|
+
except FileNotFoundError:
|
|
1594
|
+
return float("inf")
|
|
1595
|
+
except OSError:
|
|
1596
|
+
return float("inf")
|
|
1597
|
+
return max(0.0, time.time() - mtime)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def _hook_tick_throttle_touch() -> None:
|
|
1601
|
+
"""Update mtime to now (creating the file if missing)."""
|
|
1602
|
+
c = _cctally()
|
|
1603
|
+
try:
|
|
1604
|
+
c.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1605
|
+
c.HOOK_TICK_THROTTLE_PATH.touch(exist_ok=True)
|
|
1606
|
+
os.utime(c.HOOK_TICK_THROTTLE_PATH, None)
|
|
1607
|
+
except OSError:
|
|
1608
|
+
pass
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _hook_tick_read_stdin_event(stdin_max_bytes: int = 32 * 1024) -> dict:
|
|
1612
|
+
"""Read CC's hook payload (JSON on stdin). Best-effort.
|
|
1613
|
+
|
|
1614
|
+
Returns dict with keys event, session_id, transcript_path, cwd —
|
|
1615
|
+
every value is a string (or "unknown"). Never raises.
|
|
1616
|
+
"""
|
|
1617
|
+
out = {"event": "unknown", "session_id": "unknown", "transcript_path": "", "cwd": ""}
|
|
1618
|
+
try:
|
|
1619
|
+
data = sys.stdin.buffer.read(stdin_max_bytes)
|
|
1620
|
+
except (OSError, ValueError):
|
|
1621
|
+
return out
|
|
1622
|
+
if not data:
|
|
1623
|
+
return out
|
|
1624
|
+
try:
|
|
1625
|
+
payload = json.loads(data.decode("utf-8", errors="replace"))
|
|
1626
|
+
except (ValueError, UnicodeDecodeError):
|
|
1627
|
+
return out
|
|
1628
|
+
if not isinstance(payload, dict):
|
|
1629
|
+
return out
|
|
1630
|
+
out["event"] = str(payload.get("hook_event_name") or "unknown")
|
|
1631
|
+
sid = payload.get("session_id")
|
|
1632
|
+
out["session_id"] = str(sid) if isinstance(sid, str) else "unknown"
|
|
1633
|
+
tp = payload.get("transcript_path")
|
|
1634
|
+
out["transcript_path"] = str(tp) if isinstance(tp, str) else ""
|
|
1635
|
+
cwd = payload.get("cwd")
|
|
1636
|
+
out["cwd"] = str(cwd) if isinstance(cwd, str) else ""
|
|
1637
|
+
return out
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def _hook_tick_session_short(sid: str) -> str:
|
|
1641
|
+
"""First 8 chars of a session id, sanitized for log lines."""
|
|
1642
|
+
if not sid or sid == "unknown":
|
|
1643
|
+
return "unknown"
|
|
1644
|
+
return "".join(c for c in sid[:8] if c.isalnum() or c in "-_")
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _hook_tick_format_log_line(
|
|
1648
|
+
event: str, session: str, ingested: int, oauth_status: str, dur_ms: int
|
|
1649
|
+
) -> str:
|
|
1650
|
+
ts = now_utc_iso()
|
|
1651
|
+
return (
|
|
1652
|
+
f"{ts} event={event:14s} session={session} "
|
|
1653
|
+
f"ingested={ingested} oauth={oauth_status} dur_ms={dur_ms}"
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
1658
|
+
"""Per-fire hook runtime (Section 3 of onboarding spec).
|
|
1659
|
+
|
|
1660
|
+
Normal mode: reads stdin, detaches stdout/stderr to log file, runs
|
|
1661
|
+
sync_cache + (throttled) OAuth refresh, writes one log line, returns 0
|
|
1662
|
+
UNCONDITIONALLY (even on internal failure — hook discipline).
|
|
1663
|
+
|
|
1664
|
+
--explain mode: synchronous, prints to stdout, returns informative
|
|
1665
|
+
exit code.
|
|
1666
|
+
"""
|
|
1667
|
+
c = _cctally()
|
|
1668
|
+
explain = bool(getattr(args, "explain", False))
|
|
1669
|
+
no_oauth = bool(getattr(args, "no_oauth", False))
|
|
1670
|
+
# Use an explicit `is None` check so `--throttle-seconds 0` survives the
|
|
1671
|
+
# default-fallback (a `0 or DEFAULT` short-circuit would silently drop
|
|
1672
|
+
# the override and reapply the configured window — defeats the purpose
|
|
1673
|
+
# of the zero-second escape hatch).
|
|
1674
|
+
override = getattr(args, "throttle_seconds", None)
|
|
1675
|
+
if override is not None:
|
|
1676
|
+
throttle_seconds = float(override)
|
|
1677
|
+
else:
|
|
1678
|
+
try:
|
|
1679
|
+
_cfg = _get_oauth_usage_config(load_config())
|
|
1680
|
+
throttle_seconds = float(_cfg["throttle_seconds"])
|
|
1681
|
+
except sys.modules["cctally"].OauthUsageConfigError:
|
|
1682
|
+
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
1683
|
+
|
|
1684
|
+
# --- Step 1: read stdin (before detach OR fork) ---
|
|
1685
|
+
# CRITICAL: stdin must be read BEFORE we fork. POSIX (XCU §2.9.3) says
|
|
1686
|
+
# async commands (`cmd &`) in non-interactive shells get stdin redirected
|
|
1687
|
+
# to /dev/null; we previously relied on shell `&` which blanked the
|
|
1688
|
+
# hook payload. Now the settings.json command is bare and we fork here
|
|
1689
|
+
# ourselves — but stdin still has to be drained first.
|
|
1690
|
+
forced_event = getattr(args, "event", None)
|
|
1691
|
+
if explain:
|
|
1692
|
+
meta = {"event": forced_event or "explain", "session_id": "explain",
|
|
1693
|
+
"transcript_path": "", "cwd": ""}
|
|
1694
|
+
else:
|
|
1695
|
+
meta = _hook_tick_read_stdin_event()
|
|
1696
|
+
if forced_event:
|
|
1697
|
+
meta["event"] = forced_event
|
|
1698
|
+
|
|
1699
|
+
# --- Step 1b: fork to background so CC's hook returns immediately ---
|
|
1700
|
+
# Parent returns 0 right away; child carries on with sync_cache + OAuth.
|
|
1701
|
+
# If fork fails (rare: out of pids/memory), fall back to running the
|
|
1702
|
+
# body inline — the parent process must NOT be misclassified as a
|
|
1703
|
+
# forked child, otherwise os.setsid() would detach the parent's
|
|
1704
|
+
# controlling terminal and os._exit(0) at function end would kill it
|
|
1705
|
+
# mid-stack.
|
|
1706
|
+
forked = False
|
|
1707
|
+
pid = 0
|
|
1708
|
+
if not explain:
|
|
1709
|
+
try:
|
|
1710
|
+
pid = os.fork()
|
|
1711
|
+
forked = True
|
|
1712
|
+
except OSError:
|
|
1713
|
+
pass
|
|
1714
|
+
if forked and pid > 0:
|
|
1715
|
+
# Parent of a successful fork: CC unblocks immediately.
|
|
1716
|
+
return 0
|
|
1717
|
+
# Either: child of successful fork, OR inline fallback after fork failure.
|
|
1718
|
+
if forked:
|
|
1719
|
+
# Detach from parent's session so SIGHUP from CC doesn't kill us.
|
|
1720
|
+
try:
|
|
1721
|
+
os.setsid()
|
|
1722
|
+
except OSError:
|
|
1723
|
+
pass
|
|
1724
|
+
|
|
1725
|
+
# --- Step 2: detach stdio (forked child OR inline fallback after fork failure) ---
|
|
1726
|
+
# In the inline-fallback path the parent process re-routes its own stdout/
|
|
1727
|
+
# stderr to the log file for the rest of its short life. Function returns
|
|
1728
|
+
# immediately after Step 7, so the leak is bounded.
|
|
1729
|
+
if not explain:
|
|
1730
|
+
try:
|
|
1731
|
+
c.HOOK_TICK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
1732
|
+
log_fd = os.open(
|
|
1733
|
+
c.HOOK_TICK_LOG_PATH,
|
|
1734
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644,
|
|
1735
|
+
)
|
|
1736
|
+
os.dup2(log_fd, 1) # stdout
|
|
1737
|
+
os.dup2(log_fd, 2) # stderr
|
|
1738
|
+
os.close(log_fd)
|
|
1739
|
+
try:
|
|
1740
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
1741
|
+
os.dup2(devnull, 0)
|
|
1742
|
+
os.close(devnull)
|
|
1743
|
+
except OSError:
|
|
1744
|
+
pass
|
|
1745
|
+
except OSError:
|
|
1746
|
+
pass # log redirect failed; carry on silently
|
|
1747
|
+
|
|
1748
|
+
# --- Steps 3-7: wrap remainder in try/except (always exit 0 in normal mode) ---
|
|
1749
|
+
start = time.monotonic()
|
|
1750
|
+
ingested = 0
|
|
1751
|
+
oauth_status = "skipped-no-oauth" if no_oauth else "throttled(age=?s)"
|
|
1752
|
+
# Pre-fetch throttle state captured for --explain output. The OAuth
|
|
1753
|
+
# block re-touches the throttle marker after a successful fetch, so
|
|
1754
|
+
# re-reading age there would print `mtime: 0s ago → skip` even when
|
|
1755
|
+
# the call we just made was a fetch. Freeze the values at decision
|
|
1756
|
+
# time. `pre_age` is read once now (covers --no-oauth / lock-failure
|
|
1757
|
+
# paths); the throttle block below re-assigns it under flock for the
|
|
1758
|
+
# OAuth-active path so the explain output matches the actual decision.
|
|
1759
|
+
pre_age: float = _hook_tick_throttle_age_seconds()
|
|
1760
|
+
decision: str = "skip"
|
|
1761
|
+
|
|
1762
|
+
try:
|
|
1763
|
+
# Local sync (always)
|
|
1764
|
+
try:
|
|
1765
|
+
cache_conn = open_cache_db()
|
|
1766
|
+
try:
|
|
1767
|
+
stats = sync_cache(cache_conn)
|
|
1768
|
+
ingested = int(stats.rows_inserted)
|
|
1769
|
+
finally:
|
|
1770
|
+
try:
|
|
1771
|
+
cache_conn.close()
|
|
1772
|
+
except Exception:
|
|
1773
|
+
pass
|
|
1774
|
+
except Exception as exc:
|
|
1775
|
+
ingested = -1
|
|
1776
|
+
if explain:
|
|
1777
|
+
eprint(f"[hook-tick] sync_cache failed: {exc}")
|
|
1778
|
+
|
|
1779
|
+
mock = getattr(args, "mock_oauth_response", None)
|
|
1780
|
+
if mock is not None:
|
|
1781
|
+
# Replace the throttle path's fetch fn for this process.
|
|
1782
|
+
sys.modules["cctally"]._hook_tick_oauth_refresh = _hook_tick_make_mock_refresh(mock)
|
|
1783
|
+
|
|
1784
|
+
# Throttle check + OAuth (under flock)
|
|
1785
|
+
if not no_oauth:
|
|
1786
|
+
c.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
1787
|
+
try:
|
|
1788
|
+
lock_fd = os.open(
|
|
1789
|
+
c.HOOK_TICK_THROTTLE_LOCK_PATH,
|
|
1790
|
+
os.O_WRONLY | os.O_CREAT, 0o644,
|
|
1791
|
+
)
|
|
1792
|
+
except OSError:
|
|
1793
|
+
lock_fd = -1
|
|
1794
|
+
try:
|
|
1795
|
+
if lock_fd >= 0:
|
|
1796
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
1797
|
+
pre_age = _hook_tick_throttle_age_seconds()
|
|
1798
|
+
if pre_age >= throttle_seconds:
|
|
1799
|
+
decision = "fetch"
|
|
1800
|
+
oauth_status, _ = _hook_tick_oauth_refresh(throttle_seconds=throttle_seconds)
|
|
1801
|
+
if oauth_status.startswith("ok"):
|
|
1802
|
+
_hook_tick_throttle_touch()
|
|
1803
|
+
else:
|
|
1804
|
+
oauth_status = f"throttled(age={int(pre_age)}s)"
|
|
1805
|
+
finally:
|
|
1806
|
+
if lock_fd >= 0:
|
|
1807
|
+
try:
|
|
1808
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
1809
|
+
except OSError:
|
|
1810
|
+
pass
|
|
1811
|
+
try:
|
|
1812
|
+
os.close(lock_fd)
|
|
1813
|
+
except OSError:
|
|
1814
|
+
pass
|
|
1815
|
+
except Exception as exc:
|
|
1816
|
+
oauth_status = f"err(internal:{type(exc).__name__})"
|
|
1817
|
+
if explain:
|
|
1818
|
+
eprint(f"[hook-tick] internal error: {exc}")
|
|
1819
|
+
|
|
1820
|
+
dur_ms = int((time.monotonic() - start) * 1000)
|
|
1821
|
+
|
|
1822
|
+
# --- Step 7: log line ---
|
|
1823
|
+
line = _hook_tick_format_log_line(
|
|
1824
|
+
event=meta["event"],
|
|
1825
|
+
session=_hook_tick_session_short(meta["session_id"]),
|
|
1826
|
+
ingested=ingested,
|
|
1827
|
+
oauth_status=oauth_status,
|
|
1828
|
+
dur_ms=dur_ms,
|
|
1829
|
+
)
|
|
1830
|
+
_hook_tick_log_line(line)
|
|
1831
|
+
_hook_tick_log_rotate_if_needed()
|
|
1832
|
+
|
|
1833
|
+
# --- Step 9: exit code ---
|
|
1834
|
+
if not explain:
|
|
1835
|
+
# Forked child: skip Python's atexit / argparse / cleanup paths
|
|
1836
|
+
# (they may try to flush already-redirected stdio handles).
|
|
1837
|
+
if forked:
|
|
1838
|
+
os._exit(0)
|
|
1839
|
+
return 0
|
|
1840
|
+
# --explain mapping (Section 3 of spec)
|
|
1841
|
+
if oauth_status == "skipped-no-token":
|
|
1842
|
+
rc = 2
|
|
1843
|
+
elif oauth_status.startswith("err(network") or oauth_status.startswith("err(parse"):
|
|
1844
|
+
rc = 3
|
|
1845
|
+
elif oauth_status.startswith("err(record-usage"):
|
|
1846
|
+
rc = 4
|
|
1847
|
+
elif ingested < 0:
|
|
1848
|
+
rc = 5
|
|
1849
|
+
else:
|
|
1850
|
+
rc = 0
|
|
1851
|
+
# Print --explain decision tree
|
|
1852
|
+
print("[1/4] Local sync (sync_cache)")
|
|
1853
|
+
print(f" → ingested {max(0, ingested)} new entries")
|
|
1854
|
+
print("[2/4] Throttle check")
|
|
1855
|
+
print(f" → throttle file: {c.HOOK_TICK_THROTTLE_PATH}")
|
|
1856
|
+
if pre_age == float("inf"):
|
|
1857
|
+
print(" → mtime: (file absent)")
|
|
1858
|
+
else:
|
|
1859
|
+
print(f" → mtime: {int(pre_age)}s ago")
|
|
1860
|
+
print(f" → threshold: {int(throttle_seconds)}s → {decision}")
|
|
1861
|
+
print("[3/4] OAuth refresh")
|
|
1862
|
+
print(f" → status: {oauth_status}")
|
|
1863
|
+
print(f"[4/4] Log written → {c.HOOK_TICK_LOG_PATH}")
|
|
1864
|
+
print(f"\nDone in {dur_ms} ms.")
|
|
1865
|
+
return rc
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _safe_float(value: Any) -> float:
|
|
1869
|
+
try:
|
|
1870
|
+
num = float(value)
|
|
1871
|
+
except (TypeError, ValueError) as exc:
|
|
1872
|
+
raise ValueError("weeklyPercent must be numeric") from exc
|
|
1873
|
+
if num < 0:
|
|
1874
|
+
raise ValueError("weeklyPercent must be >= 0")
|
|
1875
|
+
if num > 1000:
|
|
1876
|
+
raise ValueError("weeklyPercent is unreasonably large")
|
|
1877
|
+
return num
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _validate_date_optional(value: Any, label: str) -> dt.date | None:
|
|
1881
|
+
if value in (None, ""):
|
|
1882
|
+
return None
|
|
1883
|
+
if not isinstance(value, str):
|
|
1884
|
+
raise ValueError(f"{label} must be a string in YYYY-MM-DD")
|
|
1885
|
+
return parse_date_str(value, label)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
@dataclass(frozen=True)
|
|
1889
|
+
class DerivedWeekWindow:
|
|
1890
|
+
week_start: dt.date
|
|
1891
|
+
week_end: dt.date
|
|
1892
|
+
week_start_at: str | None = None
|
|
1893
|
+
week_end_at: str | None = None
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _coerce_payload_captured_at(payload: dict[str, Any]) -> tuple[str, dt.datetime]:
|
|
1898
|
+
captured_at_raw = payload.get("capturedAt")
|
|
1899
|
+
if isinstance(captured_at_raw, str) and captured_at_raw.strip():
|
|
1900
|
+
try:
|
|
1901
|
+
return captured_at_raw, parse_iso_datetime(captured_at_raw, "capturedAt")
|
|
1902
|
+
except ValueError:
|
|
1903
|
+
pass
|
|
1904
|
+
|
|
1905
|
+
captured_at = now_utc_iso()
|
|
1906
|
+
return captured_at, parse_iso_datetime(captured_at, "capturedAt")
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def _derive_week_from_payload(payload: dict[str, Any], week_start_name: str) -> DerivedWeekWindow:
|
|
1911
|
+
ws_at = payload.get("weekStartAt")
|
|
1912
|
+
we_at = payload.get("weekEndAt")
|
|
1913
|
+
if isinstance(ws_at, str) and ws_at.strip() and isinstance(we_at, str) and we_at.strip():
|
|
1914
|
+
start_iso = _canonicalize_optional_iso(ws_at, "weekStartAt")
|
|
1915
|
+
end_iso = _canonicalize_optional_iso(we_at, "weekEndAt")
|
|
1916
|
+
if not start_iso or not end_iso:
|
|
1917
|
+
raise ValueError("weekStartAt/weekEndAt must be non-empty ISO datetime strings")
|
|
1918
|
+
start_at = parse_iso_datetime(start_iso, "weekStartAt")
|
|
1919
|
+
end_at = parse_iso_datetime(end_iso, "weekEndAt")
|
|
1920
|
+
if end_at <= start_at:
|
|
1921
|
+
raise ValueError("weekEndAt must be after weekStartAt")
|
|
1922
|
+
# Anchor the bucket-key date on the canonical UTC ISO, not on
|
|
1923
|
+
# `.date()` of the parsed datetime — `parse_iso_datetime` ends
|
|
1924
|
+
# with `.astimezone()` which converts to host-local TZ. If the
|
|
1925
|
+
# cctally process inherits a TZ whose offset puts the UTC moment
|
|
1926
|
+
# on a different calendar date, `start_at.date()` silently
|
|
1927
|
+
# forks the `week_start_date` column for the SAME physical
|
|
1928
|
+
# subscription week, producing a ghost row that never gets
|
|
1929
|
+
# updated (regression: Israel host briefly running with
|
|
1930
|
+
# TZ=America/Los_Angeles for 7 minutes during refactor work
|
|
1931
|
+
# spawned 18 ghost usage rows + 2 ghost cost rows under
|
|
1932
|
+
# week_start_date='2026-05-08' while every other row sat at
|
|
1933
|
+
# '2026-05-09'). Re-canonicalize to UTC before `.date()` so the
|
|
1934
|
+
# bucket key matches what `cmd_record_usage` writes (it derives
|
|
1935
|
+
# `week_start_date` directly from `resets_at` in UTC).
|
|
1936
|
+
return DerivedWeekWindow(
|
|
1937
|
+
week_start=start_at.astimezone(dt.timezone.utc).date(),
|
|
1938
|
+
week_end=end_at.astimezone(dt.timezone.utc).date(),
|
|
1939
|
+
week_start_at=start_iso,
|
|
1940
|
+
week_end_at=end_iso,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
ws = _validate_date_optional(payload.get("weekStartDate"), "weekStartDate")
|
|
1944
|
+
we = _validate_date_optional(payload.get("weekEndDate"), "weekEndDate")
|
|
1945
|
+
if ws and we:
|
|
1946
|
+
if we < ws:
|
|
1947
|
+
raise ValueError("weekEndDate must be on or after weekStartDate")
|
|
1948
|
+
return DerivedWeekWindow(week_start=ws, week_end=we)
|
|
1949
|
+
if ws and not we:
|
|
1950
|
+
return DerivedWeekWindow(week_start=ws, week_end=ws + dt.timedelta(days=6))
|
|
1951
|
+
|
|
1952
|
+
captured_raw = payload.get("capturedAt")
|
|
1953
|
+
if isinstance(captured_raw, str) and captured_raw.strip():
|
|
1954
|
+
try:
|
|
1955
|
+
captured_dt = dt.datetime.fromisoformat(captured_raw.replace("Z", "+00:00"))
|
|
1956
|
+
if captured_dt.tzinfo is None:
|
|
1957
|
+
captured_dt = captured_dt.replace(tzinfo=dt.timezone.utc)
|
|
1958
|
+
except ValueError:
|
|
1959
|
+
# internal fallback: host-local intentional
|
|
1960
|
+
captured_dt = dt.datetime.now().astimezone()
|
|
1961
|
+
else:
|
|
1962
|
+
# internal fallback: host-local intentional
|
|
1963
|
+
captured_dt = dt.datetime.now().astimezone()
|
|
1964
|
+
|
|
1965
|
+
start, end = compute_week_bounds(captured_dt, week_start_name)
|
|
1966
|
+
return DerivedWeekWindow(week_start=start, week_end=end)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def insert_usage_snapshot(payload: dict[str, Any], week_start_name: str) -> dict[str, Any]:
|
|
1970
|
+
weekly_percent = _safe_float(payload.get("weeklyPercent"))
|
|
1971
|
+
captured_at, captured_at_dt = _coerce_payload_captured_at(payload)
|
|
1972
|
+
|
|
1973
|
+
page_url = payload.get("pageUrl") if isinstance(payload.get("pageUrl"), str) else None
|
|
1974
|
+
source = payload.get("source") if isinstance(payload.get("source"), str) else "userscript"
|
|
1975
|
+
|
|
1976
|
+
five_hour_percent = payload.get("fiveHourPercent")
|
|
1977
|
+
if five_hour_percent is not None:
|
|
1978
|
+
five_hour_percent = float(five_hour_percent)
|
|
1979
|
+
five_hour_resets_at = payload.get("fiveHourResetsAt")
|
|
1980
|
+
if five_hour_resets_at is not None:
|
|
1981
|
+
five_hour_resets_at = str(five_hour_resets_at)
|
|
1982
|
+
five_hour_window_key = payload.get("fiveHourWindowKey")
|
|
1983
|
+
if five_hour_window_key is not None:
|
|
1984
|
+
try:
|
|
1985
|
+
five_hour_window_key = int(five_hour_window_key)
|
|
1986
|
+
except (TypeError, ValueError) as exc:
|
|
1987
|
+
# Loud-skip on first failure only (module-level guard) so a
|
|
1988
|
+
# misbehaving caller doesn't spam the log on every insert.
|
|
1989
|
+
global _logged_window_key_coerce_failure
|
|
1990
|
+
if not _logged_window_key_coerce_failure:
|
|
1991
|
+
# Use the local (already extracted from payload at line
|
|
1992
|
+
# ~13858) instead of re-reading; payload is mutable and
|
|
1993
|
+
# could in principle change between extraction and the
|
|
1994
|
+
# except branch.
|
|
1995
|
+
eprint(
|
|
1996
|
+
f"[record-usage] fiveHourWindowKey coerce failed "
|
|
1997
|
+
f"(got {type(five_hour_window_key).__name__}: "
|
|
1998
|
+
f"{five_hour_window_key!r}); "
|
|
1999
|
+
f"5h DB clamp will be skipped for this row: {exc}"
|
|
2000
|
+
)
|
|
2001
|
+
_logged_window_key_coerce_failure = True
|
|
2002
|
+
five_hour_window_key = None
|
|
2003
|
+
|
|
2004
|
+
conn = open_db()
|
|
2005
|
+
try:
|
|
2006
|
+
week_window = _derive_week_from_payload(payload, week_start_name)
|
|
2007
|
+
|
|
2008
|
+
# Use the canonical boundary already established for this week_start_date.
|
|
2009
|
+
# This prevents relative-reset drift from creating duplicate weeks.
|
|
2010
|
+
date_str = week_window.week_start.isoformat()
|
|
2011
|
+
canon_start, canon_end = _get_canonical_boundary_for_date(conn, date_str)
|
|
2012
|
+
if canon_start and canon_end:
|
|
2013
|
+
week_window = DerivedWeekWindow(
|
|
2014
|
+
week_start=week_window.week_start,
|
|
2015
|
+
week_end=week_window.week_end,
|
|
2016
|
+
week_start_at=canon_start,
|
|
2017
|
+
week_end_at=canon_end,
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
week_start = week_window.week_start
|
|
2021
|
+
week_end = week_window.week_end
|
|
2022
|
+
|
|
2023
|
+
cur = conn.execute(
|
|
2024
|
+
"""
|
|
2025
|
+
INSERT INTO weekly_usage_snapshots
|
|
2026
|
+
(
|
|
2027
|
+
captured_at_utc,
|
|
2028
|
+
week_start_date,
|
|
2029
|
+
week_end_date,
|
|
2030
|
+
week_start_at,
|
|
2031
|
+
week_end_at,
|
|
2032
|
+
weekly_percent,
|
|
2033
|
+
page_url,
|
|
2034
|
+
source,
|
|
2035
|
+
payload_json,
|
|
2036
|
+
five_hour_percent,
|
|
2037
|
+
five_hour_resets_at,
|
|
2038
|
+
five_hour_window_key
|
|
2039
|
+
)
|
|
2040
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2041
|
+
""",
|
|
2042
|
+
(
|
|
2043
|
+
captured_at,
|
|
2044
|
+
week_start.isoformat(),
|
|
2045
|
+
week_end.isoformat(),
|
|
2046
|
+
week_window.week_start_at,
|
|
2047
|
+
week_window.week_end_at,
|
|
2048
|
+
weekly_percent,
|
|
2049
|
+
page_url,
|
|
2050
|
+
source,
|
|
2051
|
+
json.dumps(payload, separators=(",", ":")),
|
|
2052
|
+
five_hour_percent,
|
|
2053
|
+
five_hour_resets_at,
|
|
2054
|
+
five_hour_window_key,
|
|
2055
|
+
),
|
|
2056
|
+
)
|
|
2057
|
+
conn.commit()
|
|
2058
|
+
snapshot_id = int(cur.lastrowid)
|
|
2059
|
+
finally:
|
|
2060
|
+
conn.close()
|
|
2061
|
+
|
|
2062
|
+
out = {
|
|
2063
|
+
"id": snapshot_id,
|
|
2064
|
+
"capturedAt": captured_at,
|
|
2065
|
+
"weekStartDate": week_start.isoformat(),
|
|
2066
|
+
"weekEndDate": week_end.isoformat(),
|
|
2067
|
+
"weeklyPercent": weekly_percent,
|
|
2068
|
+
}
|
|
2069
|
+
if week_window.week_start_at:
|
|
2070
|
+
out["weekStartAt"] = week_window.week_start_at
|
|
2071
|
+
if week_window.week_end_at:
|
|
2072
|
+
out["weekEndAt"] = week_window.week_end_at
|
|
2073
|
+
if isinstance(payload.get("resetText"), str):
|
|
2074
|
+
out["resetText"] = payload["resetText"]
|
|
2075
|
+
if five_hour_percent is not None:
|
|
2076
|
+
out["fiveHourPercent"] = five_hour_percent
|
|
2077
|
+
if five_hour_resets_at is not None:
|
|
2078
|
+
out["fiveHourResetsAt"] = five_hour_resets_at
|
|
2079
|
+
if five_hour_window_key is not None:
|
|
2080
|
+
out["fiveHourWindowKey"] = five_hour_window_key
|
|
2081
|
+
return out
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def _saved_dict_from_usage_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
2085
|
+
"""Mirror ``insert_usage_snapshot``'s output dict from an existing
|
|
2086
|
+
weekly_usage_snapshots row. Used by ``cmd_record_usage``'s dedup
|
|
2087
|
+
self-heal path so ``maybe_record_milestone`` and
|
|
2088
|
+
``maybe_update_five_hour_block`` can re-run on the latest snapshot
|
|
2089
|
+
when an earlier invocation was killed between snapshot insert and
|
|
2090
|
+
milestone insert (e.g. CC self-update kill window, 2026-05-08).
|
|
2091
|
+
|
|
2092
|
+
Field omissions match ``insert_usage_snapshot``: keys whose values
|
|
2093
|
+
would be ``None`` are not emitted, so downstream ``saved.get(...)``
|
|
2094
|
+
callers see the same shape they'd see on a fresh insert.
|
|
2095
|
+
|
|
2096
|
+
Note: ``resetText`` (the only userscript-payload-only key
|
|
2097
|
+
``insert_usage_snapshot`` re-emits in its output dict) is
|
|
2098
|
+
intentionally omitted — no downstream ``saved``-dict consumer in
|
|
2099
|
+
this codebase reads it. ``pageUrl`` is a column on
|
|
2100
|
+
``weekly_usage_snapshots`` but is never propagated into the output
|
|
2101
|
+
dict either path.
|
|
2102
|
+
"""
|
|
2103
|
+
out: dict[str, Any] = {
|
|
2104
|
+
"id": int(row["id"]),
|
|
2105
|
+
"capturedAt": row["captured_at_utc"],
|
|
2106
|
+
"weekStartDate": row["week_start_date"],
|
|
2107
|
+
"weekEndDate": row["week_end_date"],
|
|
2108
|
+
"weeklyPercent": float(row["weekly_percent"]),
|
|
2109
|
+
}
|
|
2110
|
+
if row["week_start_at"] is not None:
|
|
2111
|
+
out["weekStartAt"] = row["week_start_at"]
|
|
2112
|
+
if row["week_end_at"] is not None:
|
|
2113
|
+
out["weekEndAt"] = row["week_end_at"]
|
|
2114
|
+
if row["five_hour_percent"] is not None:
|
|
2115
|
+
out["fiveHourPercent"] = float(row["five_hour_percent"])
|
|
2116
|
+
if row["five_hour_resets_at"] is not None:
|
|
2117
|
+
out["fiveHourResetsAt"] = row["five_hour_resets_at"]
|
|
2118
|
+
if row["five_hour_window_key"] is not None:
|
|
2119
|
+
out["fiveHourWindowKey"] = int(row["five_hour_window_key"])
|
|
2120
|
+
return out
|