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.
- package/CHANGELOG.md +24 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_config.py +135 -0
- package/bin/_cctally_core.py +120 -0
- package/bin/_cctally_dashboard.py +155 -23
- package/bin/_cctally_db.py +3 -0
- package/bin/_cctally_record.py +148 -0
- package/bin/_cctally_setup.py +215 -91
- package/bin/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +174 -14
- package/bin/_lib_pricing.py +32 -5
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1180 -10
- package/bin/cctally-budget +4 -0
- package/dashboard/static/assets/index-BxmaYT1y.css +1 -0
- package/dashboard/static/assets/index-CLcd-Tnm.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BJ16SzRL.js +0 -18
- package/dashboard/static/assets/index-C1xH9GBW.css +0 -1
|
@@ -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
|
|
4068
|
-
with ``alerted_at IS NOT NULL``, ordered
|
|
4069
|
-
``alerted_at``, capped at ``limit`` (default 100).
|
|
4070
|
-
truth for both the dashboard panel (slices to 10
|
|
4071
|
-
the modal (renders all 100). Forward-only
|
|
4072
|
-
alert-dispatch path stamped get included;
|
|
4073
|
-
NULL and are intentionally invisible
|
|
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
|
-
|
|
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;
|
|
4169
|
-
# millisecond), the union order (weekly
|
|
4170
|
-
# the tiebreaker — no extra deterministic key is added
|
|
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(
|
|
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}
|
|
5130
|
-
|
|
5131
|
-
|
|
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 = {
|
|
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":
|
|
5527
|
-
"threshold": 1..100}``. Defaults:
|
|
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
|
-
|
|
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,
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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
|
package/bin/_cctally_record.py
CHANGED
|
@@ -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:
|