cctally 1.21.2 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -268,6 +268,9 @@ from _cctally_core import (
268
268
  make_week_ref,
269
269
  _get_alerts_config,
270
270
  _AlertsConfigError,
271
+ _get_budget_config,
272
+ _budget_alerts_active,
273
+ _BudgetConfigError,
271
274
  )
272
275
  from _lib_display_tz import (
273
276
  format_display_dt,
@@ -329,6 +332,10 @@ def _build_alert_payload_five_hour(*args, **kwargs):
329
332
  return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
330
333
 
331
334
 
335
+ def _build_alert_payload_budget(*args, **kwargs):
336
+ return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
337
+
338
+
332
339
  def _dispatch_alert_notification(*args, **kwargs):
333
340
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
334
341
 
@@ -4064,16 +4071,17 @@ def _build_alerts_envelope_array(
4064
4071
  ) -> list[dict]:
4065
4072
  """Return the ``alerts`` array for the SSE snapshot envelope.
4066
4073
 
4067
- Union of ``percent_milestones`` and ``five_hour_milestones`` rows
4068
- with ``alerted_at IS NOT NULL``, ordered newest-first by
4069
- ``alerted_at``, capped at ``limit`` (default 100). Single source of
4070
- truth for both the dashboard panel (slices to 10 client-side) and
4071
- the modal (renders all 100). Forward-only semantics: only rows the
4072
- alert-dispatch path stamped get included; pre-deploy crossings stay
4073
- NULL and are intentionally invisible (spec §4.3).
4074
+ Union of ``percent_milestones``, ``five_hour_milestones``, and
4075
+ ``budget_milestones`` rows with ``alerted_at IS NOT NULL``, ordered
4076
+ newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4077
+ Single source of truth for both the dashboard panel (slices to 10
4078
+ client-side) and the modal (renders all 100). Forward-only
4079
+ semantics: only rows the alert-dispatch path stamped get included;
4080
+ pre-deploy crossings stay NULL and are intentionally invisible
4081
+ (spec §4.3).
4074
4082
 
4075
- Both axes share the same envelope schema; the ``axis`` field
4076
- discriminates.
4083
+ All three axes share the same envelope schema; the ``axis`` field
4084
+ (``weekly`` / ``five_hour`` / ``budget``) discriminates.
4077
4085
 
4078
4086
  Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4079
4087
  up to ``limit``) and the union is re-sorted + sliced — important for
@@ -4164,11 +4172,45 @@ def _build_alerts_envelope_array(
4164
4172
  },
4165
4173
  })
4166
4174
 
4175
+ # Third axis (issue #19): equiv-$ budget threshold crossings. Budget
4176
+ # alerts are keyed by the effective (post-reset) week_start_at + the
4177
+ # integer threshold; the envelope id mirrors the dispatch payload's
4178
+ # ``budget:<week_start_at>:<threshold>`` shape
4179
+ # (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
4180
+ # a mid-week reset re-anchors ``week_start_at`` so the new window
4181
+ # naturally gets fresh rows under ``UNIQUE(week_start_at, threshold)``.
4182
+ budget_rows = conn.execute(
4183
+ """
4184
+ SELECT week_start_at, threshold, crossed_at_utc, alerted_at,
4185
+ budget_usd, spent_usd, consumption_pct
4186
+ FROM budget_milestones
4187
+ WHERE alerted_at IS NOT NULL
4188
+ ORDER BY alerted_at DESC
4189
+ LIMIT ?
4190
+ """,
4191
+ (limit,),
4192
+ ).fetchall()
4193
+ for r in budget_rows:
4194
+ threshold = int(r["threshold"])
4195
+ out.append({
4196
+ "id": f"budget:{r['week_start_at']}:{threshold}",
4197
+ "axis": "budget",
4198
+ "threshold": threshold,
4199
+ "crossed_at": r["crossed_at_utc"],
4200
+ "alerted_at": r["alerted_at"],
4201
+ "context": {
4202
+ "week_start_at": r["week_start_at"],
4203
+ "budget_usd": float(r["budget_usd"]),
4204
+ "spent_usd": float(r["spent_usd"]),
4205
+ "consumption_pct": float(r["consumption_pct"]),
4206
+ },
4207
+ })
4208
+
4167
4209
  # Python's list.sort is stable. When two alerts share the same
4168
- # `alerted_at` ISO string (rare; both axes firing within the same
4169
- # millisecond), the union order (weekly first, then 5h) determines
4170
- # the tiebreaker — no extra deterministic key is added because the
4171
- # spec doesn't require one.
4210
+ # `alerted_at` ISO string (rare; multiple axes firing within the same
4211
+ # millisecond), the union order (weekly, then 5h, then budget)
4212
+ # determines the tiebreaker — no extra deterministic key is added
4213
+ # because the spec doesn't require one.
4172
4214
  out.sort(key=lambda a: a["alerted_at"], reverse=True)
4173
4215
  return out[:limit]
4174
4216
 
@@ -4532,8 +4574,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4532
4574
  # the entire snapshot — fall back to safe defaults and rely on
4533
4575
  # `_warn_alerts_bad_config_once` for the user-visible signal.
4534
4576
  alerts_array = list(getattr(snap, "alerts", []) or [])
4577
+ _cfg_for_alerts = load_config()
4535
4578
  try:
4536
- _alerts_cfg = _get_alerts_config(load_config())
4579
+ _alerts_cfg = _get_alerts_config(_cfg_for_alerts)
4537
4580
  except _AlertsConfigError as exc:
4538
4581
  _warn_alerts_bad_config_once(exc)
4539
4582
  _alerts_cfg = {
@@ -4541,10 +4584,21 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4541
4584
  "weekly_thresholds": [],
4542
4585
  "five_hour_thresholds": [],
4543
4586
  }
4587
+ # Budget is its OWN config block (issue #19) — source budget fields
4588
+ # from ``_get_budget_config`` (the validated ``budget`` block), NOT
4589
+ # the ``alerts`` block. Defensive: a corrupt budget block must not
4590
+ # 500 the whole snapshot — fall back to "no budget / disabled".
4591
+ try:
4592
+ _budget_cfg = _get_budget_config(_cfg_for_alerts)
4593
+ except _BudgetConfigError:
4594
+ _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
4595
+ "alert_thresholds": []}
4544
4596
  alerts_settings = {
4545
4597
  "enabled": _alerts_cfg["enabled"],
4546
4598
  "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
4547
4599
  "five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
4600
+ "budget_thresholds": list(_budget_cfg["alert_thresholds"]),
4601
+ "budget_enabled": _budget_alerts_active(_budget_cfg),
4548
4602
  }
4549
4603
 
4550
4604
  # Mirror update-state.json + update-suppress.json into the envelope
@@ -5126,9 +5180,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5126
5180
 
5127
5181
  Body shape: ``{"display"?: {"tz": "..."}, "alerts"?: {...},
5128
5182
  "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
5129
- "cache_report"?: {"anomaly_threshold_pp"?: int}}`` — every
5130
- top-level key is optional; any subset may be sent together
5131
- (combined save). Unknown top-level keys are rejected with 400.
5183
+ "cache_report"?: {"anomaly_threshold_pp"?: int},
5184
+ "budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
5185
+ "alert_thresholds"?: int[]}}`` every top-level key is optional;
5186
+ any subset may be sent together (combined save). Unknown
5187
+ top-level keys are rejected with 400.
5132
5188
 
5133
5189
  Per-block validation:
5134
5190
  * ``display.tz`` — "local", "utc", or a valid IANA zone (via
@@ -5147,6 +5203,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5147
5203
  ``{error, field: "anomaly_threshold_pp"}`` on out-of-range
5148
5204
  or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
5149
5205
  F10 tracks lifting that.
5206
+ * ``budget`` — must be a dict; merged onto the persisted
5207
+ ``budget`` block and validated via ``_get_budget_config(merged)``
5208
+ (issue #19); ``_BudgetConfigError`` → 400. Budget is its OWN
5209
+ config block, distinct from ``alerts``.
5150
5210
 
5151
5211
  Atomic merged write: if all touched blocks validate, the merged
5152
5212
  config is persisted in a single ``save_config`` call inside the
@@ -5198,7 +5258,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5198
5258
  return
5199
5259
 
5200
5260
  # Reject unknown top-level keys (forward-compat hygiene).
5201
- allowed_top_keys = {"display", "alerts", "update", "cache_report"}
5261
+ allowed_top_keys = {
5262
+ "display", "alerts", "update", "cache_report", "budget",
5263
+ }
5202
5264
  for k in payload.keys():
5203
5265
  if k not in allowed_top_keys:
5204
5266
  self._respond_json(
@@ -5212,12 +5274,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5212
5274
  and "alerts" not in payload
5213
5275
  and "update" not in payload
5214
5276
  and "cache_report" not in payload
5277
+ and "budget" not in payload
5215
5278
  ):
5216
5279
  self._respond_json(
5217
5280
  400,
5218
5281
  {"error": (
5219
5282
  "body must contain at least one of: "
5220
- "display, alerts, update, cache_report"
5283
+ "display, alerts, update, cache_report, budget"
5221
5284
  )},
5222
5285
  )
5223
5286
  return
@@ -5305,6 +5368,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5305
5368
  )
5306
5369
  return
5307
5370
 
5371
+ # Pre-validate budget shape (the structural type check; full
5372
+ # cross-key validation runs inside the lock via
5373
+ # ``_get_budget_config(merged)``). Budget is its OWN config block
5374
+ # (issue #19), not part of ``alerts``.
5375
+ if "budget" in payload:
5376
+ budget_block = payload["budget"]
5377
+ if not isinstance(budget_block, dict):
5378
+ self._respond_json(
5379
+ 400, {"error": "budget must be an object"}
5380
+ )
5381
+ return
5382
+
5308
5383
  # Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
5309
5384
  # is settable today; any other key under `update` or `update.check`
5310
5385
  # is rejected so adding e.g. `update.banner.*` later is forward
@@ -5413,6 +5488,35 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5413
5488
  self._respond_json(400, {"error": str(exc)})
5414
5489
  return
5415
5490
 
5491
+ if "budget" in payload:
5492
+ # Same hand-edited-junk guard as alerts: a non-dict stored
5493
+ # ``budget`` block in config.json should surface as a
5494
+ # recoverable 400, not a 500. Budget is its OWN config
5495
+ # block (issue #19); the inbound keys are
5496
+ # ``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds``.
5497
+ existing_budget = merged.get("budget")
5498
+ if existing_budget is not None and not isinstance(
5499
+ existing_budget, dict
5500
+ ):
5501
+ self._respond_json(
5502
+ 400, {"error": "budget must be an object"}
5503
+ )
5504
+ return
5505
+ merged_budget = dict(existing_budget or {})
5506
+ budget_in = payload["budget"]
5507
+ for leaf in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
5508
+ if leaf in budget_in:
5509
+ merged_budget[leaf] = budget_in[leaf]
5510
+ merged["budget"] = merged_budget
5511
+ # Final validation against the merged block.
5512
+ # _BudgetConfigError → 400 (no partial write — save_config
5513
+ # has not yet been called).
5514
+ try:
5515
+ _get_budget_config(merged)
5516
+ except _BudgetConfigError as exc:
5517
+ self._respond_json(400, {"error": str(exc)})
5518
+ return
5519
+
5416
5520
  if update_check_validated is not None:
5417
5521
  # Same hand-edited-junk guard as alerts: a non-dict
5418
5522
  # `update` or `update.check` block in config.json should
@@ -5473,6 +5577,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5473
5577
  )
5474
5578
  if "alerts" in payload:
5475
5579
  out["alerts"] = _get_alerts_config(merged)
5580
+ if "budget" in payload:
5581
+ # Echo the full validated budget block (defaults filled) so the
5582
+ # SettingsOverlay can repaint without a follow-up GET.
5583
+ validated_budget = _get_budget_config(merged)
5584
+ out["budget"] = validated_budget
5585
+ # Forward-only reconcile (mirrors `budget set` / `config set
5586
+ # budget.*`): enabling/raising a budget while already past a
5587
+ # threshold records the crossed thresholds as already-alerted so
5588
+ # the next record-usage tick does NOT dispatch retroactive alerts.
5589
+ # Runs AFTER save_config (config persisted first); best-effort —
5590
+ # never breaks the 200 response. Config write already left the
5591
+ # config_writer_lock, so the helper's open_db never nests.
5592
+ _cctally()._reconcile_budget_on_config_write(validated_budget)
5476
5593
  if update_check_validated is not None:
5477
5594
  # Echo the full merged check block (cooked defaults included)
5478
5595
  # so the SettingsOverlay can repaint without a follow-up GET.
@@ -5523,8 +5640,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5523
5640
  ``_dispatch_alert_notification(..., mode="test")``, returns the
5524
5641
  dispatch status string in the JSON response.
5525
5642
 
5526
- Body (all fields optional): ``{"axis": "weekly"|"five_hour",
5527
- "threshold": 1..100}``. Defaults: axis="weekly", threshold=90.
5643
+ Body (all fields optional): ``{"axis":
5644
+ "weekly"|"five_hour"|"budget", "threshold": 1..100}``. Defaults:
5645
+ axis="weekly", threshold=90.
5528
5646
 
5529
5647
  IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
5530
5648
  in the JSON API to match the dispatch payload's internal axis
@@ -5563,11 +5681,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5563
5681
  return
5564
5682
 
5565
5683
  axis = body.get("axis", "weekly")
5566
- if axis not in ("weekly", "five_hour"):
5684
+ if axis not in ("weekly", "five_hour", "budget"):
5567
5685
  self._respond_json(
5568
5686
  400,
5569
5687
  {"error": (
5570
- f"axis must be 'weekly' or 'five_hour', got {axis!r}"
5688
+ "axis must be 'weekly', 'five_hour' or 'budget', "
5689
+ f"got {axis!r}"
5571
5690
  )},
5572
5691
  )
5573
5692
  return
@@ -5592,6 +5711,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5592
5711
  cumulative_cost_usd=1.23,
5593
5712
  dollars_per_percent=0.01,
5594
5713
  )
5714
+ elif axis == "budget":
5715
+ # Synthetic budget payload — mirrors the CLI cmd_alerts_test
5716
+ # budget branch (NO DB writes, test/real divergence contract).
5717
+ # spent scaled to the threshold so the body reads plausibly
5718
+ # (e.g. 100% → $300 of $300).
5719
+ payload = _build_alert_payload_budget(
5720
+ threshold=threshold,
5721
+ crossed_at_utc=now_utc_iso(),
5722
+ week_start_at=dt.date.today().isoformat(),
5723
+ budget_usd=300.0,
5724
+ spent_usd=300.0 * threshold / 100.0,
5725
+ consumption_pct=float(threshold),
5726
+ )
5595
5727
  else:
5596
5728
  payload = _build_alert_payload_five_hour(
5597
5729
  threshold=threshold,
@@ -1023,6 +1023,9 @@ _BANNER_SUPPRESSED_COMMANDS = frozenset({
1023
1023
  "doctor", # consolidates migration + update banner state into its
1024
1024
  # own report; double-printing the banner would duplicate
1025
1025
  # findings doctor already surfaces structurally.
1026
+ "pricing-check", # read-only diagnostic emitting structured (often JSON)
1027
+ # output; banner noise pollutes the report + scripted
1028
+ # `--json` pipelines. Same posture as doctor.
1026
1029
  "repair-symlinks", # invoked by npm postinstall; no banner during install
1027
1030
  "blocks", # stdout-formatted table replacing `ccusage blocks`;
1028
1031
  # stderr noise pollutes the visually-aligned report and
@@ -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: