cctally 1.23.0 → 1.24.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 +9 -0
- package/bin/_cctally_alerts.py +106 -24
- package/bin/_cctally_config.py +131 -9
- package/bin/_cctally_core.py +42 -0
- package/bin/_cctally_dashboard.py +52 -2
- package/bin/_lib_alert_axes.py +15 -6
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/cctally +5 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-CsqqtRBB.css} +1 -1
- package/dashboard/static/assets/index-DwuW39Tv.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.24.0] - 2026-06-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Threshold alerts now dispatch cross-platform, not just on macOS.** Alongside the existing macOS `osascript` Notification Center popup, alerts can now fire via Linux `notify-send` (with the severity mapped to a `-u low|normal|critical` urgency token and a `--` end-of-options guard against option-injection) or via a fully custom command you configure — so Windows / WSL / headless setups can finally wire up a real popup. The backend is picked automatically per host (`auto`: a custom command if you've set one, else `osascript` on macOS, else `notify-send` on Linux, else no popup), and `cctally alerts test` now prints a `notifier: <resolved>` line so you can see exactly which backend will fire before relying on a real crossing. The dispatch decision lives in a new pure, fully-unit-tested kernel (`bin/_lib_alert_dispatch.py`) and every spawn is `shell=False` with the arg-list form, so alert text containing `$(...)`, `;`, or `&&` is passed as one literal argument and can never inject a shell command.
|
|
12
|
+
- **A new `alerts.command_template` config key lets you run any command on an alert.** Set it to a JSON argv list — e.g. `cctally config set alerts.command_template '["notify-send","-u","{urgency}","{title}","{body}"]'` — and cctally spawns that command (shell-free) on every crossing, substituting the documented `{title}`/`{subtitle}`/`{body}`/`{severity}`/`{urgency}`/`{axis}`/`{threshold}`/`{metric}` tokens (unmatched braces stay literal; a missing value substitutes as empty). This is trusted local execution (you own your config), and under the default `auto` notifier a set template takes over dispatch on every OS, so you can route alerts to a webhook, a logger, a phone push, or any tool you like. The companion `alerts.notifier` key (`auto` / `osascript` / `notify-send` / `command` / `none`) pins the backend explicitly when you don't want auto-detection, and both keys are editable from the dashboard Settings overlay (notifier dropdown) as well as `cctally config set`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Alert severity is now a 3-tier model (info / warn / critical) instead of the previous two-color split.** A crossed threshold below 90% is `info` (indigo), 90–99% is `warn` (amber), and 100%+ is `critical` (red); this single mapping drives the dashboard toast color, the "Recent alerts" panel chip, and the Linux `notify-send` urgency token, and it is kept byte-identical between the Python authority (`bin/_lib_alert_axes.py::severity_for`) and the dashboard (`alertSeverity` in `dashboard/web/src/lib/alertAxis.ts`), with legacy `amber`/`red` tokens from an older backend normalizing to `warn`/`critical` on read. The `alerts.log` audit file gains a 7th tab-delimited column carrying this severity on every dispatch line. No action needed on upgrade; existing alert config and thresholds are unchanged.
|
|
16
|
+
|
|
8
17
|
## [1.23.0] - 2026-06-02
|
|
9
18
|
|
|
10
19
|
### Added
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -47,6 +47,7 @@ import datetime as dt
|
|
|
47
47
|
import importlib.util as _ilu
|
|
48
48
|
import os
|
|
49
49
|
import pathlib
|
|
50
|
+
import shutil
|
|
50
51
|
import subprocess
|
|
51
52
|
import sys
|
|
52
53
|
|
|
@@ -76,6 +77,24 @@ _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_h
|
|
|
76
77
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
77
78
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
78
79
|
|
|
80
|
+
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
81
|
+
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
82
|
+
# that injects the real sys.platform / shutil.which and spawns with shell=False.
|
|
83
|
+
_lib_alert_axes = _load_lib("_lib_alert_axes")
|
|
84
|
+
severity_for = _lib_alert_axes.severity_for
|
|
85
|
+
_lib_alert_dispatch = _load_lib("_lib_alert_dispatch")
|
|
86
|
+
resolve_notifier = _lib_alert_dispatch.resolve_notifier
|
|
87
|
+
build_command = _lib_alert_dispatch.build_command
|
|
88
|
+
severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# `load_config` STAYS a shim that bounces through cctally's namespace (mirrors
|
|
92
|
+
# bin/_cctally_record.py): production monkeypatches `cctally.load_config`, and
|
|
93
|
+
# the dispatch tests patch this module-level name directly. Its natural home is
|
|
94
|
+
# _cctally_config; a direct import would silently bypass those patches.
|
|
95
|
+
def load_config(*args, **kwargs):
|
|
96
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
97
|
+
|
|
79
98
|
|
|
80
99
|
# === Honest imports from extracted homes ===================================
|
|
81
100
|
# Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
|
|
@@ -103,14 +122,26 @@ def _dispatch_alert_notification(
|
|
|
103
122
|
popen_factory=subprocess.Popen,
|
|
104
123
|
mode: str = "real",
|
|
105
124
|
tz: "object | None" = None,
|
|
125
|
+
platform: "str | None" = None,
|
|
126
|
+
which_on_path=None,
|
|
106
127
|
) -> str:
|
|
107
|
-
"""
|
|
128
|
+
"""Dispatch a notification for a crossed threshold (non-blocking, best-effort).
|
|
129
|
+
|
|
130
|
+
Picks the active notifier (osascript / notify-send / a config-driven
|
|
131
|
+
command_template / none) via the pure ``_lib_alert_dispatch`` kernel, builds
|
|
132
|
+
its exact arg-list, and spawns it with ``shell=False``. Returns one of:
|
|
133
|
+
``"queued"`` Popen succeeded
|
|
134
|
+
``"no_notifier:none"`` auto/none resolved to no popup on this host
|
|
135
|
+
``"no_notifier:unavailable"`` an explicit osascript/notify-send is missing
|
|
136
|
+
``"spawn_error: <ExcType>: <msg>"`` Popen raised
|
|
137
|
+
Writes EXACTLY ONE line to ``alerts.log`` with the terminal status PLUS the
|
|
138
|
+
crossing's 3-tier severity as a trailing column. Never raises: the config
|
|
139
|
+
read, Popen-spawn failures, and log-write failures are all swallowed so the
|
|
140
|
+
dispatch contract stays independent of the OS / FS / user-config state.
|
|
108
141
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Popen-spawn failures and log-write failures are both swallowed so the
|
|
113
|
-
dispatch contract stays independent of the OS / FS state.
|
|
142
|
+
``platform`` (sys.platform-style) and ``which_on_path`` (name -> bool) are
|
|
143
|
+
injectable so every OS branch + the no-notifier paths are testable from any
|
|
144
|
+
host; both default to the real ``sys.platform`` / ``shutil.which``.
|
|
114
145
|
|
|
115
146
|
Production callers ignore the return value (fire-and-forget); test
|
|
116
147
|
callers assert on it via an injected ``popen_factory``.
|
|
@@ -149,27 +180,64 @@ def _dispatch_alert_notification(
|
|
|
149
180
|
f"axis={axis} threshold={payload.get('threshold')}",
|
|
150
181
|
)
|
|
151
182
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
183
|
+
# Severity (3-tier) drives both the notify-send urgency token and the
|
|
184
|
+
# trailing log column. A missing threshold (defensive — shouldn't happen for
|
|
185
|
+
# a real crossing) floors at "info".
|
|
186
|
+
threshold = payload.get("threshold")
|
|
187
|
+
try:
|
|
188
|
+
severity = severity_for(int(threshold)) if threshold is not None else "info"
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
severity = "info"
|
|
191
|
+
urgency = severity_to_urgency(severity)
|
|
192
|
+
|
|
193
|
+
if platform is None:
|
|
194
|
+
platform = sys.platform
|
|
195
|
+
if which_on_path is None:
|
|
196
|
+
which_on_path = lambda name: shutil.which(name) is not None
|
|
197
|
+
|
|
198
|
+
# Guarded so a malformed user config (or a load_config raise) never breaks
|
|
199
|
+
# the never-raise contract: fall back to auto-detect / no custom command.
|
|
200
|
+
try:
|
|
201
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
202
|
+
except Exception:
|
|
203
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
204
|
+
|
|
205
|
+
notifier = resolve_notifier(
|
|
206
|
+
alerts_cfg, platform=platform, which_on_path=which_on_path
|
|
207
|
+
)
|
|
208
|
+
args = build_command(
|
|
209
|
+
notifier,
|
|
210
|
+
title=title,
|
|
211
|
+
subtitle=subtitle,
|
|
212
|
+
body=body,
|
|
213
|
+
severity=severity,
|
|
214
|
+
urgency=urgency,
|
|
215
|
+
payload=payload,
|
|
216
|
+
command_template=alerts_cfg.get("command_template"),
|
|
156
217
|
)
|
|
157
218
|
|
|
158
219
|
status: str
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
220
|
+
if args is None:
|
|
221
|
+
# 'none' (auto resolved to no popup, or notifier='none') vs an
|
|
222
|
+
# explicitly-selected native backend that is unavailable on this host.
|
|
223
|
+
selector = alerts_cfg.get("notifier", "auto")
|
|
224
|
+
reason = "unavailable" if selector in ("osascript", "notify-send") else "none"
|
|
225
|
+
status = f"no_notifier:{reason}"
|
|
226
|
+
else:
|
|
227
|
+
try:
|
|
228
|
+
popen_factory(
|
|
229
|
+
args,
|
|
230
|
+
stdout=subprocess.DEVNULL,
|
|
231
|
+
stderr=subprocess.DEVNULL,
|
|
232
|
+
close_fds=True,
|
|
233
|
+
start_new_session=True,
|
|
234
|
+
)
|
|
235
|
+
status = "queued"
|
|
236
|
+
except (FileNotFoundError, PermissionError, OSError) as exc:
|
|
237
|
+
status = f"spawn_error: {exc.__class__.__name__}: {exc}"
|
|
170
238
|
|
|
171
|
-
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no
|
|
172
|
-
#
|
|
239
|
+
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no contradictory
|
|
240
|
+
# "queued" + "spawn_error" pair). Severity is appended as the 7th column.
|
|
173
241
|
try:
|
|
174
242
|
log_path = _alerts_log_path()
|
|
175
243
|
ctx = payload.get("context") or {}
|
|
@@ -181,7 +249,7 @@ def _dispatch_alert_notification(
|
|
|
181
249
|
)
|
|
182
250
|
line = (
|
|
183
251
|
f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
|
|
184
|
-
f"\t{mode}\t{status}\n"
|
|
252
|
+
f"\t{mode}\t{status}\t{severity}\n"
|
|
185
253
|
)
|
|
186
254
|
with open(log_path, "a") as f:
|
|
187
255
|
f.write(line)
|
|
@@ -275,6 +343,20 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
275
343
|
block_cost_usd=1.23,
|
|
276
344
|
primary_model="claude-sonnet-4-6",
|
|
277
345
|
)
|
|
346
|
+
# Resolve and report the active notifier for display BEFORE dispatch — the
|
|
347
|
+
# config read is guarded the same way `_dispatch_alert_notification` guards
|
|
348
|
+
# its own (so a malformed config never crashes `alerts test`). This is
|
|
349
|
+
# purely informational; the dispatch path re-resolves independently.
|
|
350
|
+
try:
|
|
351
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
352
|
+
except Exception:
|
|
353
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
354
|
+
notifier = resolve_notifier(
|
|
355
|
+
alerts_cfg,
|
|
356
|
+
platform=sys.platform,
|
|
357
|
+
which_on_path=lambda name: shutil.which(name) is not None,
|
|
358
|
+
)
|
|
359
|
+
print(f"notifier: {notifier}")
|
|
278
360
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
279
361
|
if status == "queued":
|
|
280
362
|
print("Test alert dispatched (mode=test). Check Notification Center.")
|
package/bin/_cctally_config.py
CHANGED
|
@@ -298,6 +298,8 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
298
298
|
"display.tz",
|
|
299
299
|
"alerts.enabled",
|
|
300
300
|
"alerts.projected_enabled",
|
|
301
|
+
"alerts.notifier",
|
|
302
|
+
"alerts.command_template",
|
|
301
303
|
"dashboard.bind",
|
|
302
304
|
"update.check.enabled",
|
|
303
305
|
"update.check.ttl_hours",
|
|
@@ -414,6 +416,21 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
414
416
|
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
415
417
|
except c._AlertsConfigError:
|
|
416
418
|
return False
|
|
419
|
+
if key == "alerts.notifier":
|
|
420
|
+
# Validated dispatch backend (defaults to 'auto' when unset). A corrupt
|
|
421
|
+
# alerts block surfaces the default — mirrors alerts.enabled.
|
|
422
|
+
try:
|
|
423
|
+
return _get_alerts_config(config)["notifier"]
|
|
424
|
+
except c._AlertsConfigError:
|
|
425
|
+
return "auto"
|
|
426
|
+
if key == "alerts.command_template":
|
|
427
|
+
# Validated argv list or None (defaults to None when unset). A corrupt
|
|
428
|
+
# alerts block surfaces the default. The plain-text render path JSON-
|
|
429
|
+
# encodes this so `config get` round-trips through `config set`.
|
|
430
|
+
try:
|
|
431
|
+
return _get_alerts_config(config)["command_template"]
|
|
432
|
+
except c._AlertsConfigError:
|
|
433
|
+
return None
|
|
417
434
|
if key == "dashboard.bind":
|
|
418
435
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
419
436
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -505,14 +522,20 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
505
522
|
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
506
523
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
507
524
|
return 2
|
|
525
|
+
# `alerts.command_template` is JSON-shaped (a list of strings or null), so
|
|
526
|
+
# its real value (including None) must survive into the render layer — the
|
|
527
|
+
# generic None->"" coercion below would break the JSON shape / round-trip.
|
|
528
|
+
def _coerce(k: str, v: "object") -> "object":
|
|
529
|
+
if k == "alerts.command_template":
|
|
530
|
+
return v
|
|
531
|
+
return v if v is not None else ""
|
|
532
|
+
|
|
508
533
|
pairs: "list[tuple[str, object]]" = []
|
|
509
534
|
if key is None:
|
|
510
535
|
for k in ALLOWED_CONFIG_KEYS:
|
|
511
|
-
|
|
512
|
-
pairs.append((k, v if v is not None else ""))
|
|
536
|
+
pairs.append((k, _coerce(k, _config_known_value(config, k))))
|
|
513
537
|
else:
|
|
514
|
-
|
|
515
|
-
pairs.append((key, v if v is not None else ""))
|
|
538
|
+
pairs.append((key, _coerce(key, _config_known_value(config, key))))
|
|
516
539
|
|
|
517
540
|
if getattr(args, "emit_json", False):
|
|
518
541
|
# Walk every dot-delimited segment so keys deeper than two
|
|
@@ -532,7 +555,12 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
532
555
|
for k, v in pairs:
|
|
533
556
|
# Preserve canonical bool stringification (true/false) so
|
|
534
557
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
535
|
-
if
|
|
558
|
+
if k == "alerts.command_template":
|
|
559
|
+
# JSON-encoded (list of strings or null) so `config get` output
|
|
560
|
+
# round-trips through `config set alerts.command_template`
|
|
561
|
+
# (which JSON-parses its value).
|
|
562
|
+
rendered = json.dumps(v)
|
|
563
|
+
elif isinstance(v, bool):
|
|
536
564
|
rendered = "true" if v else "false"
|
|
537
565
|
elif isinstance(v, list):
|
|
538
566
|
# Comma-joined so `config get budget.alert_thresholds` output
|
|
@@ -653,6 +681,76 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
653
681
|
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
654
682
|
)
|
|
655
683
|
return 0
|
|
684
|
+
if key == "alerts.notifier":
|
|
685
|
+
# Dispatch backend (Phase B). Plain string; the enum constraint is
|
|
686
|
+
# enforced by the pre-persist _get_alerts_config validation (so we never
|
|
687
|
+
# write a config that fails subsequent reads). Same read-modify-write
|
|
688
|
+
# posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
689
|
+
normalized = raw.strip()
|
|
690
|
+
with config_writer_lock():
|
|
691
|
+
config = _load_config_unlocked()
|
|
692
|
+
existing_alerts = config.get("alerts")
|
|
693
|
+
if existing_alerts is not None and not isinstance(
|
|
694
|
+
existing_alerts, dict
|
|
695
|
+
):
|
|
696
|
+
print(
|
|
697
|
+
"cctally: alerts config error: alerts must be an object",
|
|
698
|
+
file=sys.stderr,
|
|
699
|
+
)
|
|
700
|
+
return 2
|
|
701
|
+
alerts_block = dict(existing_alerts or {})
|
|
702
|
+
alerts_block["notifier"] = normalized
|
|
703
|
+
try:
|
|
704
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
705
|
+
except _AlertsConfigError as exc:
|
|
706
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
707
|
+
return 2
|
|
708
|
+
config["alerts"] = alerts_block
|
|
709
|
+
save_config(config)
|
|
710
|
+
if getattr(args, "emit_json", False):
|
|
711
|
+
print(json.dumps({"alerts": {"notifier": normalized}}, indent=2))
|
|
712
|
+
else:
|
|
713
|
+
print(f"alerts.notifier={normalized}")
|
|
714
|
+
return 0
|
|
715
|
+
if key == "alerts.command_template":
|
|
716
|
+
# Dispatch argv template (Phase B). JSON-parsed value (a list of strings
|
|
717
|
+
# or null to clear it); the shape + cross-field constraints are enforced
|
|
718
|
+
# by the pre-persist _get_alerts_config validation. Same read-modify-
|
|
719
|
+
# write posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
720
|
+
try:
|
|
721
|
+
parsed = json.loads(raw)
|
|
722
|
+
except (ValueError, TypeError) as exc:
|
|
723
|
+
print(
|
|
724
|
+
f"cctally: alerts.command_template must be JSON (a list of "
|
|
725
|
+
f"strings or null): {exc}",
|
|
726
|
+
file=sys.stderr,
|
|
727
|
+
)
|
|
728
|
+
return 2
|
|
729
|
+
with config_writer_lock():
|
|
730
|
+
config = _load_config_unlocked()
|
|
731
|
+
existing_alerts = config.get("alerts")
|
|
732
|
+
if existing_alerts is not None and not isinstance(
|
|
733
|
+
existing_alerts, dict
|
|
734
|
+
):
|
|
735
|
+
print(
|
|
736
|
+
"cctally: alerts config error: alerts must be an object",
|
|
737
|
+
file=sys.stderr,
|
|
738
|
+
)
|
|
739
|
+
return 2
|
|
740
|
+
alerts_block = dict(existing_alerts or {})
|
|
741
|
+
alerts_block["command_template"] = parsed
|
|
742
|
+
try:
|
|
743
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
744
|
+
except _AlertsConfigError as exc:
|
|
745
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
746
|
+
return 2
|
|
747
|
+
config["alerts"] = alerts_block
|
|
748
|
+
save_config(config)
|
|
749
|
+
if getattr(args, "emit_json", False):
|
|
750
|
+
print(json.dumps({"alerts": {"command_template": parsed}}, indent=2))
|
|
751
|
+
else:
|
|
752
|
+
print(f"alerts.command_template={json.dumps(parsed)}")
|
|
753
|
+
return 0
|
|
656
754
|
if key == "dashboard.bind":
|
|
657
755
|
# Validation rejects whitespace / empty / non-string up front;
|
|
658
756
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -873,15 +971,25 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
873
971
|
save_config(config)
|
|
874
972
|
# idempotent: silent on missing key
|
|
875
973
|
return 0
|
|
876
|
-
if key in (
|
|
974
|
+
if key in (
|
|
975
|
+
"alerts.enabled",
|
|
976
|
+
"alerts.projected_enabled",
|
|
977
|
+
"alerts.notifier",
|
|
978
|
+
"alerts.command_template",
|
|
979
|
+
):
|
|
877
980
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
878
981
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
|
879
982
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
880
983
|
# named key preserves any user-customized threshold lists
|
|
881
984
|
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
882
|
-
# enabled/projected_enabled
|
|
883
|
-
#
|
|
884
|
-
#
|
|
985
|
+
# enabled/projected_enabled/notifier/command_template keys. For
|
|
986
|
+
# enabled/projected_enabled/notifier the read-time validator
|
|
987
|
+
# (`_get_alerts_config`) re-applies the canonical default
|
|
988
|
+
# (`False` / `"auto"`) for the missing key on next get. NOT so for
|
|
989
|
+
# command_template when notifier == "command": the cross-field
|
|
990
|
+
# constraint makes notifier="command" REQUIRE a template, so dropping
|
|
991
|
+
# the template would leave a config that _get_alerts_config REJECTS on
|
|
992
|
+
# the next read. The pre-persist guard below catches exactly that case.
|
|
885
993
|
inner_key = key.split(".", 1)[1]
|
|
886
994
|
with config_writer_lock():
|
|
887
995
|
config = _load_config_unlocked()
|
|
@@ -890,6 +998,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
890
998
|
del block[inner_key]
|
|
891
999
|
if not block:
|
|
892
1000
|
config.pop("alerts", None)
|
|
1001
|
+
# Pre-persist guard (mirrors the set branches): unsetting a key
|
|
1002
|
+
# that participates in a cross-field constraint
|
|
1003
|
+
# (alerts.command_template while alerts.notifier == "command")
|
|
1004
|
+
# would leave a config that _get_alerts_config rejects on the
|
|
1005
|
+
# next read. Validate the TOP-LEVEL config (so a pruned/empty
|
|
1006
|
+
# alerts block correctly validates to defaults) and refuse
|
|
1007
|
+
# rather than persist an unreadable config.
|
|
1008
|
+
try:
|
|
1009
|
+
_get_alerts_config(config)
|
|
1010
|
+
except _AlertsConfigError as exc:
|
|
1011
|
+
print(
|
|
1012
|
+
f"cctally: alerts config error: {exc}", file=sys.stderr
|
|
1013
|
+
)
|
|
1014
|
+
return 2
|
|
893
1015
|
save_config(config)
|
|
894
1016
|
# idempotent: silent on missing key
|
|
895
1017
|
return 0
|
package/bin/_cctally_core.py
CHANGED
|
@@ -432,8 +432,14 @@ _ALERTS_CONFIG_VALID_KEYS = {
|
|
|
432
432
|
"weekly_thresholds",
|
|
433
433
|
"five_hour_thresholds",
|
|
434
434
|
"projected_enabled",
|
|
435
|
+
"notifier",
|
|
436
|
+
"command_template",
|
|
435
437
|
}
|
|
436
438
|
|
|
439
|
+
# Dispatch backends (Phase B). "auto" picks a platform default; "command"
|
|
440
|
+
# routes through alerts.command_template (which it then requires).
|
|
441
|
+
_ALERTS_VALID_NOTIFIERS = ("auto", "osascript", "notify-send", "command", "none")
|
|
442
|
+
|
|
437
443
|
|
|
438
444
|
def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
439
445
|
"""Validate one of the alerts threshold lists.
|
|
@@ -513,11 +519,47 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
513
519
|
f"alerts.projected_enabled must be a JSON boolean, got "
|
|
514
520
|
f"{type(projected_enabled).__name__}: {projected_enabled!r}"
|
|
515
521
|
)
|
|
522
|
+
# Dispatch-global keys (Phase B). `notifier` selects the backend;
|
|
523
|
+
# `command_template` is an argv list for the `command` backend (and may be
|
|
524
|
+
# set ahead of switching the backend). The cross-field constraint
|
|
525
|
+
# (notifier='command' requires a template) is enforced last.
|
|
526
|
+
notifier = block.get("notifier", "auto")
|
|
527
|
+
if notifier not in _ALERTS_VALID_NOTIFIERS:
|
|
528
|
+
raise _AlertsConfigError(
|
|
529
|
+
f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
|
|
530
|
+
f"got {notifier!r}"
|
|
531
|
+
)
|
|
532
|
+
command_template = block.get("command_template", None)
|
|
533
|
+
if command_template is not None:
|
|
534
|
+
if not isinstance(command_template, list) or not command_template:
|
|
535
|
+
raise _AlertsConfigError(
|
|
536
|
+
"alerts.command_template must be null or a non-empty list of strings"
|
|
537
|
+
)
|
|
538
|
+
for el in command_template:
|
|
539
|
+
if not isinstance(el, str):
|
|
540
|
+
raise _AlertsConfigError(
|
|
541
|
+
f"alerts.command_template elements must be strings, "
|
|
542
|
+
f"got {type(el).__name__}: {el!r}"
|
|
543
|
+
)
|
|
544
|
+
if "\x00" in el:
|
|
545
|
+
raise _AlertsConfigError(
|
|
546
|
+
"alerts.command_template elements must not contain a NUL byte"
|
|
547
|
+
)
|
|
548
|
+
if not command_template[0].strip():
|
|
549
|
+
raise _AlertsConfigError(
|
|
550
|
+
"alerts.command_template[0] (the program) must not be empty/whitespace"
|
|
551
|
+
)
|
|
552
|
+
if notifier == "command" and command_template is None:
|
|
553
|
+
raise _AlertsConfigError(
|
|
554
|
+
"alerts.notifier='command' requires alerts.command_template to be set"
|
|
555
|
+
)
|
|
516
556
|
return {
|
|
517
557
|
"enabled": enabled,
|
|
518
558
|
"weekly_thresholds": weekly,
|
|
519
559
|
"five_hour_thresholds": five_hour,
|
|
520
560
|
"projected_enabled": projected_enabled,
|
|
561
|
+
"notifier": notifier,
|
|
562
|
+
"command_template": command_template,
|
|
521
563
|
}
|
|
522
564
|
|
|
523
565
|
|
|
@@ -4687,6 +4687,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4687
4687
|
"weekly_thresholds": [],
|
|
4688
4688
|
"five_hour_thresholds": [],
|
|
4689
4689
|
"projected_enabled": False,
|
|
4690
|
+
# Mirror the dispatch keys so the new alerts_settings lines
|
|
4691
|
+
# (`notifier` / `command_configured`) don't KeyError on a
|
|
4692
|
+
# corrupt config. Safe defaults: no notifier override, no
|
|
4693
|
+
# configured command.
|
|
4694
|
+
"notifier": "auto",
|
|
4695
|
+
"command_template": None,
|
|
4690
4696
|
}
|
|
4691
4697
|
# Budget is its OWN config block (issue #19) — source budget fields
|
|
4692
4698
|
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
@@ -4708,6 +4714,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4708
4714
|
# from the validated getters' ``projected_enabled`` (default False).
|
|
4709
4715
|
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
4710
4716
|
"projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
|
|
4717
|
+
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
4718
|
+
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
4719
|
+
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
4720
|
+
# (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
|
|
4721
|
+
# every connected client. We expose only a boolean: is a custom
|
|
4722
|
+
# command configured? (the CLI/config remains the sole writer of the
|
|
4723
|
+
# template itself).
|
|
4724
|
+
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
4725
|
+
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
4711
4726
|
}
|
|
4712
4727
|
|
|
4713
4728
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -5347,7 +5362,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5347
5362
|
sent) is the full computed block from ``_compute_display_block``
|
|
5348
5363
|
(preserves ``tz`` / ``resolved_tz`` / ``offset_label`` /
|
|
5349
5364
|
``offset_seconds`` shape consumers rely on). ``alerts`` (when
|
|
5350
|
-
sent) is the full validated block from ``_get_alerts_config
|
|
5365
|
+
sent) is the full validated block from ``_get_alerts_config``,
|
|
5366
|
+
except the raw ``command_template`` is redacted to the boolean
|
|
5367
|
+
``command_configured`` (it routinely holds secrets — webhook URLs
|
|
5368
|
+
/ bearer tokens — and the echo is returned to the client; the
|
|
5369
|
+
SSE ``alerts_settings`` mirror redacts identically). Do NOT
|
|
5370
|
+
re-add the raw template to the echo.
|
|
5351
5371
|
``saved_at`` is included for backward compat.
|
|
5352
5372
|
"""
|
|
5353
5373
|
if not self._check_origin_csrf():
|
|
@@ -5487,6 +5507,27 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5487
5507
|
{"error": "alerts.projected_enabled must be a JSON boolean"},
|
|
5488
5508
|
)
|
|
5489
5509
|
return
|
|
5510
|
+
# The dispatch command template is CLI/config-only — never
|
|
5511
|
+
# settable via the dashboard (it routinely holds secrets and the
|
|
5512
|
+
# dashboard echoes settings to the client). Reject it explicitly
|
|
5513
|
+
# rather than silently dropping it.
|
|
5514
|
+
if "command_template" in alerts_block:
|
|
5515
|
+
self._respond_json(
|
|
5516
|
+
400,
|
|
5517
|
+
{"error": "alerts.command_template is CLI/config-only "
|
|
5518
|
+
"(not settable via the dashboard)"},
|
|
5519
|
+
)
|
|
5520
|
+
return
|
|
5521
|
+
# `notifier` is settable (the backend selector). Structural type
|
|
5522
|
+
# check only; the enum + cross-field rule (command needs a stored
|
|
5523
|
+
# template) is enforced free by `_get_alerts_config(merged)` below.
|
|
5524
|
+
if "notifier" in alerts_block and not isinstance(
|
|
5525
|
+
alerts_block["notifier"], str
|
|
5526
|
+
):
|
|
5527
|
+
self._respond_json(
|
|
5528
|
+
400, {"error": "alerts.notifier must be a string"}
|
|
5529
|
+
)
|
|
5530
|
+
return
|
|
5490
5531
|
|
|
5491
5532
|
# Pre-validate budget shape (the structural type check; full
|
|
5492
5533
|
# cross-key validation runs inside the lock via
|
|
@@ -5602,6 +5643,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5602
5643
|
merged_alerts["projected_enabled"] = (
|
|
5603
5644
|
alerts_in["projected_enabled"]
|
|
5604
5645
|
)
|
|
5646
|
+
if "notifier" in alerts_in:
|
|
5647
|
+
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
5605
5648
|
merged["alerts"] = merged_alerts
|
|
5606
5649
|
# Final cross-field validation against the merged block.
|
|
5607
5650
|
# _AlertsConfigError → 400 (no partial write since
|
|
@@ -5703,7 +5746,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5703
5746
|
merged, dt.datetime.now(dt.timezone.utc)
|
|
5704
5747
|
)
|
|
5705
5748
|
if "alerts" in payload:
|
|
5706
|
-
|
|
5749
|
+
# Echo the full validated alerts block (defaults filled) so the
|
|
5750
|
+
# SettingsOverlay can repaint without a follow-up GET — but
|
|
5751
|
+
# redact the raw `command_template` (secrets) the same way the
|
|
5752
|
+
# SSE snapshot mirror does: replace it with a boolean
|
|
5753
|
+
# `command_configured`.
|
|
5754
|
+
_a = dict(_get_alerts_config(merged))
|
|
5755
|
+
_a["command_configured"] = _a.pop("command_template", None) is not None
|
|
5756
|
+
out["alerts"] = _a
|
|
5707
5757
|
if "budget" in payload:
|
|
5708
5758
|
# Echo the full validated budget block (defaults filled) so the
|
|
5709
5759
|
# SettingsOverlay can repaint without a follow-up GET.
|
package/bin/_lib_alert_axes.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Single source of truth for axis *metadata* — id, chip/title labels (kept
|
|
4
4
|
byte-identical with dashboard/web/src/lib/alertAxis.ts), the milestone-table
|
|
5
5
|
name used by the dashboard envelope, and the axis-uniform severity policy
|
|
6
|
-
(
|
|
6
|
+
(info <90 / warn 90-99 / critical >=100). This kernel does NOT own the write/transaction path:
|
|
7
7
|
each axis keeps its own detect-and-arm code in bin/_cctally_record.py. The
|
|
8
8
|
descriptor is the metadata/render contract, not the write engine.
|
|
9
9
|
|
|
@@ -14,14 +14,23 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
|
|
17
|
-
# Severity
|
|
18
|
-
#
|
|
19
|
-
|
|
17
|
+
# Severity bands (Phase B): info < warn floor <= warn < critical floor <= critical.
|
|
18
|
+
# The top tier means "hit the ceiling" (100% weekly = rate-limited, budget 100% =
|
|
19
|
+
# over). Maps onto notify-send's low/normal/critical urgency levels.
|
|
20
|
+
_SEVERITY_WARN_FLOOR = 90
|
|
21
|
+
_SEVERITY_CRITICAL_FLOOR = 100
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def severity_for(threshold: int) -> str:
|
|
23
|
-
"""Map a crossed integer threshold to a severity
|
|
24
|
-
|
|
25
|
+
"""Map a crossed integer threshold to a 3-tier severity
|
|
26
|
+
('info' | 'warn' | 'critical'). Axis-uniform; the single authority kept
|
|
27
|
+
byte-identical with dashboard/web/src/lib/alertAxis.ts::alertSeverity."""
|
|
28
|
+
t = int(threshold)
|
|
29
|
+
if t >= _SEVERITY_CRITICAL_FLOOR:
|
|
30
|
+
return "critical"
|
|
31
|
+
if t >= _SEVERITY_WARN_FLOOR:
|
|
32
|
+
return "warn"
|
|
33
|
+
return "info"
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
@dataclass(frozen=True)
|