cctally 1.21.3 → 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 +17 -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/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +74 -0
- package/bin/_lib_pricing.py +32 -5
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1141 -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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.22.0] - 2026-05-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally budget` — a weekly equivalent-$ budget with a live pace projection.** Set a per-subscription-week target in dollars (`cctally budget set 300`, clear with `cctally budget unset`) and `cctally budget` shows spend-so-far, percent-of-budget consumed, remaining dollars, current daily pace, and a low/high end-of-week projection band (week-average pace vs. trailing-24h pace), with an `ok`/`warn`/`over` verdict and a `LOW CONF` note early in the week. Spend is computed live from your session data via the same path `weekly`/`forecast` use (so embedded-pricing edits take effect immediately), scoped to Claude only (the subscription week is an Anthropic concept). Supports `--json` (`schemaVersion: 1`) and the full shareable-output surface (`--format md|html|svg`, `--theme`, `--no-branding`, `--output`, `--copy`, `--open`; `--reveal-projects` is a no-op since budget has no per-project rows). `--config PATH` is read-only — `set`/`unset` always write the default config. See `docs/commands/budget.md`.
|
|
12
|
+
- **Actual-spend budget alerts.** When live spend crosses a configured budget threshold (default 90% and 100% of the weekly target), cctally fires a desktop notification — recorded fire-once per `(week, threshold)` in a new `budget_milestones` table and dispatched set-then-dispatch like the existing weekly/5h percent alerts. Alerts are forward-only from the moment you set a budget (a back-set target reconciles existing crossings as already-alerted so it never floods you with retroactive popups), and a mid-week quota reset re-anchors the budget window so the new week's crossings fire fresh. `cctally alerts test --axis budget` previews the notification without writing to the DB.
|
|
13
|
+
- **`budget` config block** (`budget.weekly_usd` / `budget.alerts_enabled` / `budget.alert_thresholds`) — its own validated config block (distinct from the `alerts` block), settable via `cctally config set budget.weekly_usd 300` / `config set budget.alert_thresholds 90,100` / `config set budget.alerts_enabled false` and readable via `config get`, with thresholds constrained to `[1, 100]` and a positive-finite weekly target (or `null` to clear). Alerts are "on when a budget is set" by default.
|
|
14
|
+
- **Dashboard Recent-alerts gains a third `budget` axis.** Budget threshold crossings now surface in the existing Recent-alerts panel, modal, and toast alongside the weekly and 5h-block axes — a distinct `BUDGET` chip (green accent), an "$X of $Y budget" body line, a "Week of …" + "% of budget" context cell, and the actual-spend figure in the Cost column; the dashboard `alerts_settings` mirror now carries `budget_thresholds` + `budget_enabled` (sourced from the `budget` config block, not `alerts`), the Settings overlay shows the active budget thresholds, and `POST /api/settings` validates and persists an inbound `budget` block.
|
|
15
|
+
- **`cctally pricing-check` — proactive detection of stale or missing embedded model pricing.** cctally prices every session locally from the embedded `CLAUDE_MODEL_PRICING` / `CODEX_MODEL_PRICING` tables, so an unrecognized Claude model silently contributes $0 (a silent undercount) and an unrecognized Codex model is only approximated via the `gpt-5` fallback; this command surfaces those gaps before anyone has to reconcile by hand. It runs three independently-degrading legs: **coverage** (offline, all-history — models in your cached session data cctally can't price exactly), **drift** (network, LiteLLM — embedded price values vs the LiteLLM snapshot, direction-aware so a model we price but LiteLLM lacks is never flagged, and suppressible via a non-vacuity-guarded `PRICING_DRIFT_ALLOWLIST`), and **existence** (network, Anthropic `/v1/models` — vendor models the API offers that our table lacks; Anthropic-only and maintainer-local since it needs an OAuth bearer). `--json` emits a `schemaVersion: 1` payload; `--offline` runs coverage only. Exit codes follow a strict precedence — any actionable finding exits 1 even when a network leg degraded (findings always win over degradation), a clean-or-degraded-but-no-findings run exits 0, and the orthogonal `"status"` field reports whether the check was complete. See `docs/commands/pricing-check.md`.
|
|
16
|
+
- **`cctally doctor` now has a `pricing.coverage` check** that WARNs when your recent (trailing 30-day) session data contains a model cctally cannot price exactly — a Claude model resolving to $0 (`unpriced`) or a Codex model approximated via the `gpt-5` fallback (`fallback`) — listing each offending model ID with its entry count and token volume, with a remediation hint pointing at `cctally pricing-check` and the embedded pricing tables. It is OK when every observed model is priced (or when the cache is absent), the scan is strictly read-only (it never creates the data dir on a fresh HOME), and it rolls into the dashboard health chip/modal and the TUI for free as an ordinary doctor check. This is the offline, zero-network counterpart to `pricing-check`'s coverage leg.
|
|
17
|
+
- **`PRICING_SNAPSHOT_DATE` constant** (alongside `PRICING_STALENESS_DAYS`, default 60) replaces the bare `# Captured:` provenance comments beside the pricing tables, so the staleness heuristic and the release pre-flight read structured data; bump it whenever the embedded pricing tables are synced against the vendor.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **A weekly `pricing-freshness` GitHub Action** now runs `cctally pricing-check --json` on a schedule and maintains exactly one auto-managed `pricing-drift`-labeled tracking issue — it opens or updates the issue (with a value-drift table, a missing-from-us list, and a remediation checklist) when the embedded pricing diverges from LiteLLM, and auto-closes it with a "resolved as of" note when the drift clears. The workflow runs only in the source repo (never on the public mirror), uses least-privilege permissions, pins Python 3.13, and captures the check's exit code so a drift run (exit 1) proceeds to issue management instead of failing the job; the create/update/close/noop decision is a pure unit-tested function. The maintainer's release pre-flight additionally surfaces a soft, overridable advisory at cut time if there is drift, a coverage gap, or a stale pricing snapshot. The zero-lag `/v1/models` existence signal is maintainer-local only (the cron has no OAuth and auto-degrades to LiteLLM drift), so the local coverage guard remains the day-0 backstop for new-model existence.
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
- Clarified why `cctally codex <cmd>` (`daily`/`monthly`/`weekly`/`session`) reports lower token totals and cost than upstream `ccusage-codex` on older sessions — and that **cctally, not `ccusage-codex`, is the accurate one.** Older Codex CLI versions re-emit duplicate `event_msg.token_count` records (identical `last_token_usage` while the cumulative `info.total_token_usage.total_tokens` ledger stays flat); `ccusage-codex` sums every emission (up to ~2× overcount on the oldest sessions) while cctally dedups by only counting events whose cumulative advances, exactly reconstructing the Codex CLI's own authoritative total. Recent sessions don't re-emit and match upstream byte-for-byte, so a per-month comparison trends from ~0.5× on old months toward ~1.0× on recent ones. The canonical divergence note in `docs/commands/codex-daily.md` now states which tool is correct and how to verify it per session, with a discoverability pointer added to the `codex` subgroup overview (`docs/commands/codex.md`). This reconciliation is now locked by a new `bin/cctally-reconcile-test` invariant `codex_dedup_eq_codex_ground_truth`: it asserts `codex-daily`'s reported `totalTokens` equals the sum of each fixture session's final `total_token_usage.total_tokens` (an independent raw-JSONL scan that shares no aggregation code with the CLI) and carries a non-vacuity guard requiring the naive all-events sum to strictly exceed the deduped ground truth, so a regression that silently disabled the dedup guard would fail the suite.
|
|
24
|
+
|
|
8
25
|
## [1.21.3] - 2026-05-29
|
|
9
26
|
|
|
10
27
|
### Fixed
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -68,9 +68,11 @@ def _load_lib(name: str):
|
|
|
68
68
|
_lib_alerts_payload = _load_lib("_lib_alerts_payload")
|
|
69
69
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
70
70
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
71
|
+
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
71
72
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
72
73
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
73
74
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
75
|
+
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
# === Honest imports from extracted homes ===================================
|
|
@@ -134,6 +136,8 @@ def _dispatch_alert_notification(
|
|
|
134
136
|
title, subtitle, body = _alert_text_weekly(payload, tz)
|
|
135
137
|
elif axis == "five_hour":
|
|
136
138
|
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
139
|
+
elif axis == "budget":
|
|
140
|
+
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
137
141
|
else:
|
|
138
142
|
title, subtitle, body = (
|
|
139
143
|
"cctally - alert",
|
|
@@ -168,6 +172,7 @@ def _dispatch_alert_notification(
|
|
|
168
172
|
window_key = (
|
|
169
173
|
ctx.get("week_start_date")
|
|
170
174
|
or ctx.get("five_hour_window_key")
|
|
175
|
+
or ctx.get("week_start_at")
|
|
171
176
|
or ""
|
|
172
177
|
)
|
|
173
178
|
line = (
|
|
@@ -198,8 +203,16 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
198
203
|
2 --threshold out of [1, 100] range
|
|
199
204
|
3 other spawn error (PermissionError, OSError, ...)
|
|
200
205
|
"""
|
|
201
|
-
|
|
206
|
+
if args.axis == "weekly":
|
|
207
|
+
axis = "weekly"
|
|
208
|
+
elif args.axis == "budget":
|
|
209
|
+
axis = "budget"
|
|
210
|
+
else:
|
|
211
|
+
axis = "five_hour"
|
|
202
212
|
threshold = int(args.threshold)
|
|
213
|
+
# --threshold range stays [1, 100] (F5): the cap is axis-uniform with the
|
|
214
|
+
# existing weekly/5h thresholds. Over-budget tiers (>100%) are a v2
|
|
215
|
+
# deferral, not an oversight — see spec §2 (F5).
|
|
203
216
|
if not (1 <= threshold <= 100):
|
|
204
217
|
print(
|
|
205
218
|
f"cctally: --threshold must be in [1, 100], got {threshold}",
|
|
@@ -214,6 +227,18 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
214
227
|
cumulative_cost_usd=1.23,
|
|
215
228
|
dollars_per_percent=0.01,
|
|
216
229
|
)
|
|
230
|
+
elif axis == "budget":
|
|
231
|
+
# Synthetic budget payload — NO DB writes (test/real divergence
|
|
232
|
+
# contract). spent scaled to the threshold so the body line reads
|
|
233
|
+
# plausibly (e.g. 100% → $300 of $300).
|
|
234
|
+
payload = _build_alert_payload_budget(
|
|
235
|
+
threshold=threshold,
|
|
236
|
+
crossed_at_utc=now_utc_iso(),
|
|
237
|
+
week_start_at=dt.date.today().isoformat(),
|
|
238
|
+
budget_usd=300.0,
|
|
239
|
+
spent_usd=300.0 * threshold / 100.0,
|
|
240
|
+
consumption_pct=float(threshold),
|
|
241
|
+
)
|
|
217
242
|
else:
|
|
218
243
|
payload = _build_alert_payload_five_hour(
|
|
219
244
|
threshold=threshold,
|
package/bin/_cctally_config.py
CHANGED
|
@@ -73,6 +73,8 @@ from _cctally_core import (
|
|
|
73
73
|
DEFAULT_WEEK_START,
|
|
74
74
|
_get_alerts_config,
|
|
75
75
|
_AlertsConfigError,
|
|
76
|
+
_get_budget_config,
|
|
77
|
+
_BudgetConfigError,
|
|
76
78
|
)
|
|
77
79
|
from _lib_display_tz import normalize_display_tz_value
|
|
78
80
|
|
|
@@ -301,6 +303,9 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
301
303
|
"statusline.visual_burn_rate",
|
|
302
304
|
"statusline.cost_source",
|
|
303
305
|
"statusline.cctally_extensions",
|
|
306
|
+
"budget.weekly_usd",
|
|
307
|
+
"budget.alerts_enabled",
|
|
308
|
+
"budget.alert_thresholds",
|
|
304
309
|
)
|
|
305
310
|
|
|
306
311
|
|
|
@@ -463,6 +468,25 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
463
468
|
except ValueError:
|
|
464
469
|
# Hand-edited junk: surface the default — mirrors dashboard.bind.
|
|
465
470
|
return defaults[inner]
|
|
471
|
+
if key in (
|
|
472
|
+
"budget.weekly_usd",
|
|
473
|
+
"budget.alerts_enabled",
|
|
474
|
+
"budget.alert_thresholds",
|
|
475
|
+
):
|
|
476
|
+
inner = key.split(".", 1)[1]
|
|
477
|
+
# Read the validated, defaults-filled block. A corrupt block falls
|
|
478
|
+
# back to the canonical default leaf (mirrors alerts.enabled /
|
|
479
|
+
# dashboard.bind, which surface the default on a hand-edited junk
|
|
480
|
+
# block rather than erroring out of a plain `config get`).
|
|
481
|
+
try:
|
|
482
|
+
return _get_budget_config(config)[inner]
|
|
483
|
+
except _BudgetConfigError:
|
|
484
|
+
from _cctally_core import _BUDGET_DEFAULTS
|
|
485
|
+
|
|
486
|
+
default = _BUDGET_DEFAULTS[inner]
|
|
487
|
+
if isinstance(default, list):
|
|
488
|
+
return list(default)
|
|
489
|
+
return default
|
|
466
490
|
return None
|
|
467
491
|
|
|
468
492
|
|
|
@@ -500,6 +524,10 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
500
524
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
501
525
|
if isinstance(v, bool):
|
|
502
526
|
rendered = "true" if v else "false"
|
|
527
|
+
elif isinstance(v, list):
|
|
528
|
+
# Comma-joined so `config get budget.alert_thresholds` output
|
|
529
|
+
# round-trips through `config set budget.alert_thresholds`.
|
|
530
|
+
rendered = ",".join(str(x) for x in v)
|
|
503
531
|
else:
|
|
504
532
|
rendered = str(v)
|
|
505
533
|
print(f"{k}={rendered}")
|
|
@@ -683,6 +711,93 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
683
711
|
rendered = str(normalized)
|
|
684
712
|
print(f"{key}={rendered}")
|
|
685
713
|
return 0
|
|
714
|
+
if key in (
|
|
715
|
+
"budget.weekly_usd",
|
|
716
|
+
"budget.alerts_enabled",
|
|
717
|
+
"budget.alert_thresholds",
|
|
718
|
+
):
|
|
719
|
+
inner_key = key.split(".", 1)[1]
|
|
720
|
+
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
721
|
+
# rejection short-circuits. The whole merged block is re-validated via
|
|
722
|
+
# _get_budget_config under the lock so we never persist a config that
|
|
723
|
+
# fails subsequent reads. _load_config_unlocked is mandatory inside the
|
|
724
|
+
# writer lock (load_config would self-deadlock on the same fcntl fd).
|
|
725
|
+
if inner_key == "weekly_usd":
|
|
726
|
+
if raw.strip().lower() in {"null", "none", ""}:
|
|
727
|
+
new_val: object = None
|
|
728
|
+
else:
|
|
729
|
+
try:
|
|
730
|
+
new_val = float(raw)
|
|
731
|
+
except ValueError:
|
|
732
|
+
eprint(
|
|
733
|
+
"cctally config: budget.weekly_usd must be a number or "
|
|
734
|
+
f"null, got {raw!r}"
|
|
735
|
+
)
|
|
736
|
+
return 2
|
|
737
|
+
elif inner_key == "alerts_enabled":
|
|
738
|
+
lo = raw.strip().lower()
|
|
739
|
+
if lo in ("true", "yes", "on", "1"):
|
|
740
|
+
new_val = True
|
|
741
|
+
elif lo in ("false", "no", "off", "0"):
|
|
742
|
+
new_val = False
|
|
743
|
+
else:
|
|
744
|
+
eprint(
|
|
745
|
+
"cctally config: budget.alerts_enabled must be a boolean, "
|
|
746
|
+
f"got {raw!r}"
|
|
747
|
+
)
|
|
748
|
+
return 2
|
|
749
|
+
else: # alert_thresholds — comma-separated int list (empty = silenced)
|
|
750
|
+
stripped = raw.strip()
|
|
751
|
+
parsed: "list[int]" = []
|
|
752
|
+
if stripped:
|
|
753
|
+
for part in stripped.split(","):
|
|
754
|
+
tok = part.strip()
|
|
755
|
+
try:
|
|
756
|
+
parsed.append(int(tok))
|
|
757
|
+
except ValueError:
|
|
758
|
+
eprint(
|
|
759
|
+
"cctally config: budget.alert_thresholds must be a "
|
|
760
|
+
f"comma-separated list of integers, got {raw!r}"
|
|
761
|
+
)
|
|
762
|
+
return 2
|
|
763
|
+
new_val = parsed
|
|
764
|
+
with config_writer_lock():
|
|
765
|
+
config = _load_config_unlocked()
|
|
766
|
+
existing = config.get("budget")
|
|
767
|
+
if existing is not None and not isinstance(existing, dict):
|
|
768
|
+
eprint("cctally config: budget must be an object")
|
|
769
|
+
return 2
|
|
770
|
+
block = dict(existing or {})
|
|
771
|
+
block[inner_key] = new_val
|
|
772
|
+
config["budget"] = block
|
|
773
|
+
try:
|
|
774
|
+
validated = _get_budget_config(config)
|
|
775
|
+
except _BudgetConfigError as exc:
|
|
776
|
+
eprint(f"cctally config: {exc}")
|
|
777
|
+
return 2
|
|
778
|
+
# Persist the canonicalized leaf (e.g. sorted/deduped thresholds,
|
|
779
|
+
# float-coerced weekly_usd) so config.json matches what reads apply.
|
|
780
|
+
block[inner_key] = validated[inner_key]
|
|
781
|
+
config["budget"] = block
|
|
782
|
+
save_config(config)
|
|
783
|
+
# Forward-only reconcile (mirrors `budget set`): enabling/raising a
|
|
784
|
+
# budget while already past a threshold must record the crossed
|
|
785
|
+
# thresholds as already-alerted so the next record-usage tick does NOT
|
|
786
|
+
# dispatch retroactive alerts. Runs OUTSIDE config_writer_lock — the
|
|
787
|
+
# helper opens stats.db and must not nest under the config lock
|
|
788
|
+
# (fcntl.flock is per-fd; the helper has its own open_db locking).
|
|
789
|
+
c = _cctally()
|
|
790
|
+
c._reconcile_budget_on_config_write(validated)
|
|
791
|
+
out_val = validated[inner_key]
|
|
792
|
+
if getattr(args, "emit_json", False):
|
|
793
|
+
print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
|
|
794
|
+
else:
|
|
795
|
+
if isinstance(out_val, bool):
|
|
796
|
+
rendered = "true" if out_val else "false"
|
|
797
|
+
else:
|
|
798
|
+
rendered = str(out_val)
|
|
799
|
+
print(f"{key}={rendered}")
|
|
800
|
+
return 0
|
|
686
801
|
return 2 # unreachable given the gate above
|
|
687
802
|
|
|
688
803
|
|
|
@@ -770,4 +885,24 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
770
885
|
save_config(config)
|
|
771
886
|
# idempotent: silent on missing key
|
|
772
887
|
return 0
|
|
888
|
+
if key in (
|
|
889
|
+
"budget.weekly_usd",
|
|
890
|
+
"budget.alerts_enabled",
|
|
891
|
+
"budget.alert_thresholds",
|
|
892
|
+
):
|
|
893
|
+
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
894
|
+
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|
|
895
|
+
# `budget` block ends up empty, drop the parent so config.json stays
|
|
896
|
+
# tidy. Mirrors the alerts.enabled / dashboard.bind unset branches.
|
|
897
|
+
inner_key = key.split(".", 1)[1]
|
|
898
|
+
with config_writer_lock():
|
|
899
|
+
config = _load_config_unlocked()
|
|
900
|
+
block = config.get("budget")
|
|
901
|
+
if isinstance(block, dict) and inner_key in block:
|
|
902
|
+
del block[inner_key]
|
|
903
|
+
if not block:
|
|
904
|
+
config.pop("budget", None)
|
|
905
|
+
save_config(config)
|
|
906
|
+
# idempotent: silent on missing key
|
|
907
|
+
return 0
|
|
773
908
|
return 2 # unreachable given the gate above
|
package/bin/_cctally_core.py
CHANGED
|
@@ -12,6 +12,7 @@ below. See docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
|
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
import datetime as dt
|
|
15
|
+
import math
|
|
15
16
|
import os
|
|
16
17
|
import pathlib
|
|
17
18
|
import re
|
|
@@ -505,6 +506,89 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
505
506
|
}
|
|
506
507
|
|
|
507
508
|
|
|
509
|
+
# === Budget validation cluster ======================================
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class _BudgetConfigError(ValueError):
|
|
513
|
+
"""Raised by _get_budget_config on an invalid budget block."""
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
_BUDGET_DEFAULTS = {
|
|
517
|
+
"weekly_usd": None, # None = no budget (default)
|
|
518
|
+
"alerts_enabled": True, # "on when set"
|
|
519
|
+
"alert_thresholds": [90, 100],
|
|
520
|
+
}
|
|
521
|
+
_BUDGET_CONFIG_VALID_KEYS = {"weekly_usd", "alerts_enabled", "alert_thresholds"}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _get_budget_config(cfg: dict) -> dict:
|
|
525
|
+
"""Return the validated, defaults-filled budget block.
|
|
526
|
+
|
|
527
|
+
Raises _BudgetConfigError on invalid values. Unknown sub-keys emit a
|
|
528
|
+
one-line warn-and-ignore (mirrors _get_alerts_config / the display.tz
|
|
529
|
+
posture for forward compatibility).
|
|
530
|
+
"""
|
|
531
|
+
out = dict(_BUDGET_DEFAULTS)
|
|
532
|
+
out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
|
|
533
|
+
block = cfg.get("budget") if isinstance(cfg, dict) else None
|
|
534
|
+
if block is None:
|
|
535
|
+
return out
|
|
536
|
+
if not isinstance(block, dict):
|
|
537
|
+
raise _BudgetConfigError(
|
|
538
|
+
f"budget must be an object, got {type(block).__name__}"
|
|
539
|
+
)
|
|
540
|
+
# warn-and-ignore unknown keys (forward compat; matches _get_alerts_config)
|
|
541
|
+
for k in block.keys():
|
|
542
|
+
if k not in _BUDGET_CONFIG_VALID_KEYS:
|
|
543
|
+
print(
|
|
544
|
+
f"warning: ignoring unknown budget config key: {k}",
|
|
545
|
+
file=sys.stderr,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if "weekly_usd" in block:
|
|
549
|
+
v = block["weekly_usd"]
|
|
550
|
+
if v is None:
|
|
551
|
+
out["weekly_usd"] = None
|
|
552
|
+
elif isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
553
|
+
raise _BudgetConfigError("budget.weekly_usd must be a number or null")
|
|
554
|
+
elif not math.isfinite(float(v)) or float(v) <= 0:
|
|
555
|
+
raise _BudgetConfigError("budget.weekly_usd must be a finite number > 0")
|
|
556
|
+
else:
|
|
557
|
+
out["weekly_usd"] = float(v)
|
|
558
|
+
|
|
559
|
+
if "alerts_enabled" in block:
|
|
560
|
+
v = block["alerts_enabled"]
|
|
561
|
+
if not isinstance(v, bool):
|
|
562
|
+
raise _BudgetConfigError("budget.alerts_enabled must be a boolean")
|
|
563
|
+
out["alerts_enabled"] = v
|
|
564
|
+
|
|
565
|
+
if "alert_thresholds" in block:
|
|
566
|
+
v = block["alert_thresholds"]
|
|
567
|
+
if not isinstance(v, list):
|
|
568
|
+
raise _BudgetConfigError("budget.alert_thresholds must be a list of ints")
|
|
569
|
+
cleaned = []
|
|
570
|
+
for t in v:
|
|
571
|
+
if isinstance(t, bool) or not isinstance(t, int):
|
|
572
|
+
raise _BudgetConfigError(
|
|
573
|
+
"budget.alert_thresholds entries must be integers"
|
|
574
|
+
)
|
|
575
|
+
if t < 1 or t > 100:
|
|
576
|
+
raise _BudgetConfigError(
|
|
577
|
+
"budget.alert_thresholds entries must be in [1, 100]"
|
|
578
|
+
)
|
|
579
|
+
cleaned.append(t)
|
|
580
|
+
out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
|
|
581
|
+
|
|
582
|
+
return out
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _budget_alerts_active(budget_cfg: dict) -> bool:
|
|
586
|
+
"""True iff a budget is set AND alerts are enabled."""
|
|
587
|
+
return budget_cfg.get("weekly_usd") is not None and bool(
|
|
588
|
+
budget_cfg.get("alerts_enabled")
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
508
592
|
# === DB primitive ===================================================
|
|
509
593
|
|
|
510
594
|
|
|
@@ -907,6 +991,42 @@ def open_db() -> sqlite3.Connection:
|
|
|
907
991
|
"""
|
|
908
992
|
)
|
|
909
993
|
|
|
994
|
+
# ── budget_milestones (equiv-$ budget threshold crossings — issue #19) ──
|
|
995
|
+
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
|
|
996
|
+
# exact posture of `five_hour_milestones` (write-once, forward-only). A
|
|
997
|
+
# mid-week quota reset re-anchors `week_start_at` (see
|
|
998
|
+
# `_resolve_current_budget_window`), so the new window naturally gets
|
|
999
|
+
# fresh rows under UNIQUE(week_start_at, threshold) — no `reset_event_id`
|
|
1000
|
+
# segment column needed (unlike the percent/5h tables). `week_start_at`
|
|
1001
|
+
# stores the effective/re-anchored ISO string from the resolver
|
|
1002
|
+
# (`isoformat(timespec="seconds")`); the resolver's `parse_iso_datetime`
|
|
1003
|
+
# returns a HOST-LOCAL tz-aware datetime, so this dedup key carries the
|
|
1004
|
+
# host's UTC offset (e.g. `…T07:00:00-07:00`) — host-consistent, NOT
|
|
1005
|
+
# portable across hosts, same posture as `five_hour_blocks.block_start_at`.
|
|
1006
|
+
# Firing + reconcile + the dashboard envelope all read/write the identical
|
|
1007
|
+
# string on a given host, so the UNIQUE dedup is exact. `alerted_at` is stamped BEFORE the
|
|
1008
|
+
# osascript Popen (set-then-dispatch invariant); NULL = "recorded without
|
|
1009
|
+
# dispatch" (the forward-only-from-set reconcile path) OR "not yet
|
|
1010
|
+
# dispatched", never "delivery failed". Lives BEFORE the migration
|
|
1011
|
+
# dispatcher: a plain CREATE on a framework-untracked table never touches
|
|
1012
|
+
# `schema_migrations`, so the dispatcher's fresh-install snapshot is
|
|
1013
|
+
# unaffected.
|
|
1014
|
+
conn.execute(
|
|
1015
|
+
"""
|
|
1016
|
+
CREATE TABLE IF NOT EXISTS budget_milestones (
|
|
1017
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1018
|
+
week_start_at TEXT NOT NULL,
|
|
1019
|
+
threshold INTEGER NOT NULL,
|
|
1020
|
+
budget_usd REAL NOT NULL,
|
|
1021
|
+
spent_usd REAL NOT NULL,
|
|
1022
|
+
consumption_pct REAL NOT NULL,
|
|
1023
|
+
crossed_at_utc TEXT NOT NULL,
|
|
1024
|
+
alerted_at TEXT,
|
|
1025
|
+
UNIQUE(week_start_at, threshold)
|
|
1026
|
+
)
|
|
1027
|
+
"""
|
|
1028
|
+
)
|
|
1029
|
+
|
|
910
1030
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
911
1031
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
912
1032
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|