cctally 1.21.3 → 1.22.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.
@@ -171,6 +171,7 @@ from _cctally_core import (
171
171
  make_week_ref,
172
172
  _get_alerts_config,
173
173
  _AlertsConfigError,
174
+ _BudgetConfigError,
174
175
  _command_as_of,
175
176
  )
176
177
  from _lib_five_hour import _canonical_5h_window_key
@@ -258,6 +259,30 @@ def _build_alert_payload_five_hour(*args, **kwargs):
258
259
  return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
259
260
 
260
261
 
262
+ def _build_alert_payload_budget(*args, **kwargs):
263
+ return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
264
+
265
+
266
+ def _get_budget_config(*args, **kwargs):
267
+ return sys.modules["cctally"]._get_budget_config(*args, **kwargs)
268
+
269
+
270
+ def _budget_alerts_active(*args, **kwargs):
271
+ return sys.modules["cctally"]._budget_alerts_active(*args, **kwargs)
272
+
273
+
274
+ def _resolve_current_budget_window(*args, **kwargs):
275
+ return sys.modules["cctally"]._resolve_current_budget_window(*args, **kwargs)
276
+
277
+
278
+ def _sum_cost_for_range(*args, **kwargs):
279
+ return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
280
+
281
+
282
+ def insert_budget_milestone(*args, **kwargs):
283
+ return sys.modules["cctally"].insert_budget_milestone(*args, **kwargs)
284
+
285
+
261
286
  def _dispatch_alert_notification(*args, **kwargs):
262
287
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
263
288
 
@@ -266,6 +291,10 @@ def _warn_alerts_bad_config_once(*args, **kwargs):
266
291
  return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
267
292
 
268
293
 
294
+ def _warn_budget_bad_config_once(*args, **kwargs):
295
+ return sys.modules["cctally"]._warn_budget_bad_config_once(*args, **kwargs)
296
+
297
+
269
298
  def _get_oauth_usage_config(*args, **kwargs):
270
299
  return sys.modules["cctally"]._get_oauth_usage_config(*args, **kwargs)
271
300
 
@@ -644,6 +673,118 @@ def maybe_record_milestone(
644
673
  conn.close()
645
674
 
646
675
 
676
+ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
677
+ """Fire equiv-$ budget alerts on ACTUAL-spend threshold crossings
678
+ (Approach A — called from ``cmd_record_usage`` alongside the weekly-% /
679
+ 5h-% milestone helpers). Gated, hot-path-cheap, set-then-dispatch,
680
+ fire-once. Errors are logged, not raised (the caller also wraps).
681
+
682
+ ``saved`` is accepted for call-site symmetry with
683
+ ``maybe_record_milestone`` / ``maybe_update_five_hour_block`` but is
684
+ unused: the budget window + live spend are resolved from the DB +
685
+ ``session_entries`` independently (a budget crossing depends on
686
+ cumulative equiv-$ spend, not on the just-recorded 7d-% snapshot).
687
+ """
688
+ # Gate FIRST (hot-path discipline): no budget or alerts off → zero
689
+ # overhead for non-budget users. `load_config()` is safe outside any
690
+ # writer lock — atomic-rename guarantees whole-byte reads. A malformed
691
+ # budget block is a quiet warn-once no-op (mirrors weekly/5h), NOT an
692
+ # unthrottled per-tick stderr via the caller's wrapper.
693
+ try:
694
+ budget_cfg = _get_budget_config(load_config())
695
+ except _BudgetConfigError as exc:
696
+ _warn_budget_bad_config_once(exc)
697
+ return
698
+ if not _budget_alerts_active(budget_cfg):
699
+ return
700
+ target = budget_cfg["weekly_usd"]
701
+ thresholds = budget_cfg["alert_thresholds"]
702
+ if not thresholds:
703
+ return
704
+
705
+ now_utc = _command_as_of()
706
+ pending_alerts: list[dict[str, Any]] = []
707
+ conn = open_db()
708
+ try:
709
+ window = _resolve_current_budget_window(conn, now_utc)
710
+ if window is None:
711
+ return # no resolvable week window yet (spec §6 worst case)
712
+ week_start_at, _week_end_at = window
713
+ week_key = week_start_at.isoformat(timespec="seconds")
714
+
715
+ # Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
716
+ # which configured thresholds are STILL un-recorded for this week?
717
+ # The cost SUM is skipped ONLY when every threshold already has a row
718
+ # — so a partial prior run that recorded some-but-not-all thresholds
719
+ # still gets the remaining ones a SUM + crossing-check. The skip never
720
+ # owes a crossing: an un-recorded threshold always forces the SUM.
721
+ present = {
722
+ int(r[0]) for r in conn.execute(
723
+ "SELECT threshold FROM budget_milestones WHERE week_start_at = ?",
724
+ (week_key,),
725
+ )
726
+ }
727
+ pending = [t for t in sorted(thresholds) if t not in present]
728
+ if not pending:
729
+ return # nothing left to cross this week → skip the cost SUM
730
+
731
+ spent = _sum_cost_for_range(week_start_at, now_utc, mode="auto")
732
+ # target > 0 is guaranteed by _get_budget_config (weekly_usd None is
733
+ # excluded by _budget_alerts_active above); the else is belt-and-suspenders.
734
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
735
+ for t in pending:
736
+ # +1e-9 snap-up: spent/target*100 can land one ULP below an
737
+ # integer threshold (CLAUDE.md float-floor gotcha).
738
+ if consumption_pct + 1e-9 >= t:
739
+ inserted = insert_budget_milestone(
740
+ conn,
741
+ week_start_at=week_key,
742
+ threshold=t,
743
+ budget_usd=target,
744
+ spent_usd=spent,
745
+ consumption_pct=consumption_pct,
746
+ commit=False,
747
+ )
748
+ # Only the genuine-new-crossing winner (rowcount==1) dispatches;
749
+ # a racing record-usage instance gets rowcount==0 and skips.
750
+ if inserted == 1:
751
+ crossed_at = now_utc_iso()
752
+ # set-then-dispatch: alerted_at lands on the row BEFORE
753
+ # the osascript Popen, sharing this transaction with the
754
+ # INSERT (commit=False) so a crash between them is
755
+ # impossible. `alerted_at IS NULL` guard is write-once
756
+ # defense-in-depth.
757
+ conn.execute(
758
+ "UPDATE budget_milestones SET alerted_at = ? "
759
+ "WHERE week_start_at = ? AND threshold = ? "
760
+ " AND alerted_at IS NULL",
761
+ (crossed_at, week_key, t),
762
+ )
763
+ pending_alerts.append(_build_alert_payload_budget(
764
+ threshold=t,
765
+ crossed_at_utc=crossed_at,
766
+ week_start_at=week_key,
767
+ budget_usd=target,
768
+ spent_usd=spent,
769
+ consumption_pct=consumption_pct,
770
+ ))
771
+ # Single commit: every INSERT + its alerted_at marker durable together.
772
+ conn.commit()
773
+ except Exception as exc:
774
+ eprint(f"[budget-milestone] error recording budget milestone: {exc}")
775
+ finally:
776
+ conn.close()
777
+
778
+ # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
779
+ # (set-then-dispatch invariant — one queue attempt per crossing, deduped
780
+ # on the alerted_at column).
781
+ for payload in pending_alerts:
782
+ try:
783
+ _dispatch_alert_notification(payload, mode="real")
784
+ except Exception as dispatch_exc:
785
+ eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
786
+
787
+
647
788
  def _compute_block_totals(
648
789
  block_start_at: dt.datetime,
649
790
  range_end: dt.datetime,
@@ -2178,6 +2319,13 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2178
2319
  except Exception as exc:
2179
2320
  eprint(f"[5h-block] unexpected error: {exc}")
2180
2321
 
2322
+ # NEW: equiv-$ budget alert firing (Approach A, issue #19). Gated on a
2323
+ # set budget + alerts_enabled FIRST — non-budget users pay zero overhead.
2324
+ try:
2325
+ maybe_record_budget_milestone(saved)
2326
+ except Exception as exc:
2327
+ eprint(f"[budget-milestone] unexpected error: {exc}")
2328
+
2181
2329
  # Write high-water mark so the status line never displays a regression.
2182
2330
  # The file contains "week_start_date weekly_percent" on one line.
2183
2331
  try: