cctally 1.102.0 → 1.104.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 +1274 -1389
- package/README.md +3 -3
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_cache.py +31 -0
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_jsonl.py +102 -5
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_pricing.py +97 -21
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
package/README.md
CHANGED
|
@@ -34,9 +34,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
|
|
|
34
34
|
|
|
35
35
|
Highlights from the `v1.95.5` to `v1.101.0` stable upgrade:
|
|
36
36
|
|
|
37
|
-
- Dashboard refreshes
|
|
38
|
-
- Every dashboard warning
|
|
39
|
-
- The dashboard's Projects table
|
|
37
|
+
- Dashboard refreshes release their read lock on `cache.db` sooner, cutting the measured median hold from 3.40 seconds to 1.04. The published data is unchanged.
|
|
38
|
+
- Every dashboard warning carries a button that opens the surface explaining it, the week, the five-hour block, the month, the project, or the forecast. A warning whose window has closed says so and opens nothing.
|
|
39
|
+
- The dashboard's Projects table writes out what its two percentages mean under the week selector, where a phone can read it. `Used pp` is relabelled `Used pp (sum)`, and the caption names the denominator of `Cost share`.
|
|
40
40
|
|
|
41
41
|
[See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.101.0)
|
|
42
42
|
<!-- cctally:latest-stable:end -->
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -140,6 +140,56 @@ def _alert_text_quota(payload: dict, _tz) -> tuple[str, str, str]:
|
|
|
140
140
|
# dashboard alert envelope, so the CLI line is the only routing it gets.
|
|
141
141
|
return title, subtitle, _with_next_step(body, payload, _tz)
|
|
142
142
|
|
|
143
|
+
|
|
144
|
+
def _alert_text_meter_rate_change(payload: dict, tz) -> tuple[str, str, str]:
|
|
145
|
+
"""#661 S2 §6.1: the non-threshold family's own copy.
|
|
146
|
+
|
|
147
|
+
It states the direction and the size of the change, and it states them as
|
|
148
|
+
a rate the provider sets rather than as anything cctally can act on. There
|
|
149
|
+
is no threshold to name, which is the whole reason this family is not an
|
|
150
|
+
`AXIS_REGISTRY` member.
|
|
151
|
+
|
|
152
|
+
Two DELIBERATE divergences from the threshold builders, both recorded in
|
|
153
|
+
`docs/alerts-gotchas.md` (#661 S2 Stage C review, F7b):
|
|
154
|
+
|
|
155
|
+
* The body does not end with `_with_next_step`. That helper derives its
|
|
156
|
+
affordance through `alert_next_step_command`, which branches on
|
|
157
|
+
`AXIS_REGISTRY` axes; this family is deliberately not one, so it has no
|
|
158
|
+
window to scope and no branch to reach. The affordance is stated
|
|
159
|
+
directly instead.
|
|
160
|
+
* This builder and `_alert_text_quota` above it live in this glue module
|
|
161
|
+
rather than in the pure `_lib_alerts_payload` kernel, which holds the
|
|
162
|
+
other five. That is the placement both non-registry families use.
|
|
163
|
+
|
|
164
|
+
`effective_from` IS routed through `format_display_dt`. It is a clock
|
|
165
|
+
instant, not a calendar day, so the carve-out `_alert_text_weekly`
|
|
166
|
+
documents for `week_start_date` does not apply: slicing its first ten
|
|
167
|
+
characters showed a user at UTC+13 or UTC-8 a date one day away from
|
|
168
|
+
their own.
|
|
169
|
+
"""
|
|
170
|
+
previous = float(payload.get("previous_units_per_point") or 0.0)
|
|
171
|
+
new = float(payload.get("new_units_per_point") or 0.0)
|
|
172
|
+
provider = str(payload.get("provider") or "claude").capitalize()
|
|
173
|
+
if previous > 0.0 and new > 0.0:
|
|
174
|
+
change = (new - previous) / previous * 100.0
|
|
175
|
+
direction = "less" if change < 0 else "more"
|
|
176
|
+
subtitle = (
|
|
177
|
+
f"each meter point now covers {abs(change):.0f}% {direction} usage")
|
|
178
|
+
else:
|
|
179
|
+
subtitle = "the rate this account is metered at changed"
|
|
180
|
+
raw = str(payload.get("effective_from") or "")
|
|
181
|
+
try:
|
|
182
|
+
effective = _lib_alerts_payload.format_display_dt(
|
|
183
|
+
raw, tz, fmt="%Y-%m-%d", suffix=False) if raw else ""
|
|
184
|
+
except ValueError:
|
|
185
|
+
effective = ""
|
|
186
|
+
body = (
|
|
187
|
+
f"Effective {effective}. Run `cctally quota` for the fitted budget "
|
|
188
|
+
f"and its evidence." if effective else
|
|
189
|
+
"Run `cctally quota` for the fitted budget and its evidence.")
|
|
190
|
+
return f"cctally - {provider} metering rate changed", subtitle, body
|
|
191
|
+
|
|
192
|
+
|
|
143
193
|
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
144
194
|
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
145
195
|
# that injects the real sys.platform / shutil.which and spawns with shell=False.
|
|
@@ -293,6 +343,8 @@ def _dispatch_alert_notification(
|
|
|
293
343
|
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
294
344
|
elif axis == "quota":
|
|
295
345
|
title, subtitle, body = _alert_text_quota(payload, tz)
|
|
346
|
+
elif axis == "meter_rate_change":
|
|
347
|
+
title, subtitle, body = _alert_text_meter_rate_change(payload, tz)
|
|
296
348
|
else:
|
|
297
349
|
title, subtitle, body = (
|
|
298
350
|
"cctally - alert",
|
|
@@ -310,10 +362,19 @@ def _dispatch_alert_notification(
|
|
|
310
362
|
# trailing log column. A missing threshold (defensive — shouldn't happen for
|
|
311
363
|
# a real crossing) floors at "info".
|
|
312
364
|
threshold = payload.get("threshold")
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
365
|
+
if axis == "meter_rate_change":
|
|
366
|
+
# #661 S2 §6.1: this family carries an EXPLICIT severity, because a
|
|
367
|
+
# rate transition has no percentage threshold for `severity_for` to
|
|
368
|
+
# map. Reading it verbatim is what keeps the three tiers meaningful
|
|
369
|
+
# here; falling through would floor every transition at `info`.
|
|
370
|
+
severity = str(payload.get("severity") or "info")
|
|
371
|
+
if severity not in ("info", "warn", "alarm"):
|
|
372
|
+
severity = "info"
|
|
373
|
+
else:
|
|
374
|
+
try:
|
|
375
|
+
severity = severity_for(int(threshold)) if threshold is not None else "info"
|
|
376
|
+
except (TypeError, ValueError):
|
|
377
|
+
severity = "info"
|
|
317
378
|
urgency = severity_to_urgency(severity)
|
|
318
379
|
|
|
319
380
|
if platform is None:
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -7327,6 +7327,20 @@ def sync_codex_cache(
|
|
|
7327
7327
|
model=initial_model,
|
|
7328
7328
|
total_tokens=initial_total_tokens,
|
|
7329
7329
|
)
|
|
7330
|
+
if start_offset > 0 and not truncated and not requalified:
|
|
7331
|
+
prior_accounting = conn.execute(
|
|
7332
|
+
"SELECT MAX(timestamp_utc),MAX(line_offset) "
|
|
7333
|
+
"FROM codex_session_entries "
|
|
7334
|
+
"WHERE source_path=? AND line_offset<? ",
|
|
7335
|
+
(path_str, start_offset),
|
|
7336
|
+
).fetchone()
|
|
7337
|
+
if prior_accounting is not None and prior_accounting[0] is not None:
|
|
7338
|
+
iter_state.last_accounting_timestamp = _parse_anchor_iso(
|
|
7339
|
+
prior_accounting[0])
|
|
7340
|
+
if prior_accounting is not None and prior_accounting[1] is not None:
|
|
7341
|
+
iter_state.generation_transition_pending = (
|
|
7342
|
+
_lib_jsonl._codex_generation_transition_between(
|
|
7343
|
+
path_str, int(prior_accounting[1]), start_offset))
|
|
7330
7344
|
if (
|
|
7331
7345
|
prev is not None and not truncated and not requalified
|
|
7332
7346
|
and prev_native_thread_id is not None
|
|
@@ -11223,6 +11237,23 @@ def sync_codex_conversations(
|
|
|
11223
11237
|
model=initial_model,
|
|
11224
11238
|
total_tokens=initial_total_tokens,
|
|
11225
11239
|
)
|
|
11240
|
+
if start_offset > 0 and not reset_file:
|
|
11241
|
+
try:
|
|
11242
|
+
prior_accounting = conn.execute(
|
|
11243
|
+
"SELECT MAX(timestamp_utc),MAX(line_offset) FROM "
|
|
11244
|
+
"cache_db.codex_session_entries "
|
|
11245
|
+
"WHERE source_path=? AND line_offset<? ",
|
|
11246
|
+
(path_str, start_offset),
|
|
11247
|
+
).fetchone()
|
|
11248
|
+
except sqlite3.OperationalError:
|
|
11249
|
+
prior_accounting = None
|
|
11250
|
+
if prior_accounting is not None and prior_accounting[0] is not None:
|
|
11251
|
+
state.last_accounting_timestamp = _parse_anchor_iso(
|
|
11252
|
+
prior_accounting[0])
|
|
11253
|
+
if prior_accounting is not None and prior_accounting[1] is not None:
|
|
11254
|
+
state.generation_transition_pending = (
|
|
11255
|
+
_lib_jsonl._codex_generation_transition_between(
|
|
11256
|
+
path_str, int(prior_accounting[1]), start_offset))
|
|
11226
11257
|
if initial_native and initial_root:
|
|
11227
11258
|
state.thread = _lib_jsonl.CodexThreadMetadata(
|
|
11228
11259
|
source_root_key=discovered.source_root_key,
|
package/bin/_cctally_config.py
CHANGED
|
@@ -300,6 +300,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
300
300
|
"display.tz",
|
|
301
301
|
"alerts.enabled",
|
|
302
302
|
"alerts.projected_enabled",
|
|
303
|
+
"alerts.rate_change_enabled",
|
|
303
304
|
"alerts.notifier",
|
|
304
305
|
"alerts.command_template",
|
|
305
306
|
"alerts.quota",
|
|
@@ -1006,6 +1007,16 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
1006
1007
|
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
1007
1008
|
except c._AlertsConfigError:
|
|
1008
1009
|
return False
|
|
1010
|
+
if key == "alerts.rate_change_enabled":
|
|
1011
|
+
# #661 S2 section 6.2's PUSH gate. Recording a metering-rate change is
|
|
1012
|
+
# unconditional; this key gates only the notification. Same posture as
|
|
1013
|
+
# alerts.projected_enabled — validated boolean, default False, and a
|
|
1014
|
+
# corrupt alerts block surfaces the default rather than erroring out of
|
|
1015
|
+
# a plain `config get`.
|
|
1016
|
+
try:
|
|
1017
|
+
return bool(_get_alerts_config(config)["rate_change_enabled"])
|
|
1018
|
+
except c._AlertsConfigError:
|
|
1019
|
+
return False
|
|
1009
1020
|
if key == "alerts.notifier":
|
|
1010
1021
|
# Validated dispatch backend (defaults to 'auto' when unset). A corrupt
|
|
1011
1022
|
# alerts block surfaces the default — mirrors alerts.enabled.
|
|
@@ -1468,6 +1479,53 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1468
1479
|
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
1469
1480
|
)
|
|
1470
1481
|
return 0
|
|
1482
|
+
if key == "alerts.rate_change_enabled":
|
|
1483
|
+
# #661 S2 section 6.2. Identical posture to alerts.projected_enabled
|
|
1484
|
+
# above, including the re-message: _normalize_alerts_enabled_value
|
|
1485
|
+
# hardcodes "alerts.enabled" in its ValueError text, so the branch
|
|
1486
|
+
# names the key the user actually typed.
|
|
1487
|
+
try:
|
|
1488
|
+
normalized = c._normalize_alerts_enabled_value(raw)
|
|
1489
|
+
except ValueError:
|
|
1490
|
+
print(
|
|
1491
|
+
f"cctally: invalid boolean value for "
|
|
1492
|
+
f"alerts.rate_change_enabled: "
|
|
1493
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
1494
|
+
file=sys.stderr,
|
|
1495
|
+
)
|
|
1496
|
+
return 2
|
|
1497
|
+
with config_writer_lock():
|
|
1498
|
+
config = _load_config_unlocked()
|
|
1499
|
+
existing_alerts = config.get("alerts")
|
|
1500
|
+
if existing_alerts is not None and not isinstance(
|
|
1501
|
+
existing_alerts, dict
|
|
1502
|
+
):
|
|
1503
|
+
print(
|
|
1504
|
+
"cctally: alerts config error: alerts must be an object",
|
|
1505
|
+
file=sys.stderr,
|
|
1506
|
+
)
|
|
1507
|
+
return 2
|
|
1508
|
+
alerts_block = dict(existing_alerts or {})
|
|
1509
|
+
alerts_block["rate_change_enabled"] = normalized
|
|
1510
|
+
try:
|
|
1511
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
1512
|
+
except _AlertsConfigError as exc:
|
|
1513
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
1514
|
+
return 2
|
|
1515
|
+
config["alerts"] = alerts_block
|
|
1516
|
+
save_config(config)
|
|
1517
|
+
if getattr(args, "emit_json", False):
|
|
1518
|
+
print(
|
|
1519
|
+
json.dumps(
|
|
1520
|
+
{"alerts": {"rate_change_enabled": normalized}}, indent=2
|
|
1521
|
+
)
|
|
1522
|
+
)
|
|
1523
|
+
else:
|
|
1524
|
+
print(
|
|
1525
|
+
f"alerts.rate_change_enabled="
|
|
1526
|
+
f"{'true' if normalized else 'false'}"
|
|
1527
|
+
)
|
|
1528
|
+
return 0
|
|
1471
1529
|
if key == "alerts.notifier":
|
|
1472
1530
|
# Dispatch backend (Phase B). Plain string; the enum constraint is
|
|
1473
1531
|
# enforced by the pre-persist _get_alerts_config validation (so we never
|
|
@@ -2296,6 +2354,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2296
2354
|
if key in (
|
|
2297
2355
|
"alerts.enabled",
|
|
2298
2356
|
"alerts.projected_enabled",
|
|
2357
|
+
"alerts.rate_change_enabled",
|
|
2299
2358
|
"alerts.notifier",
|
|
2300
2359
|
"alerts.command_template",
|
|
2301
2360
|
"alerts.quota",
|
|
@@ -2305,8 +2364,9 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2305
2364
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
2306
2365
|
# named key preserves any user-customized threshold lists
|
|
2307
2366
|
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
2308
|
-
# enabled/projected_enabled/notifier/
|
|
2309
|
-
# enabled/projected_enabled/
|
|
2367
|
+
# enabled/projected_enabled/rate_change_enabled/notifier/
|
|
2368
|
+
# command_template keys. For enabled/projected_enabled/
|
|
2369
|
+
# rate_change_enabled/notifier the read-time validator
|
|
2310
2370
|
# (`_get_alerts_config`) re-applies the canonical default
|
|
2311
2371
|
# (`False` / `"auto"`) for the missing key on next get. NOT so for
|
|
2312
2372
|
# command_template when notifier == "command": the cross-field
|
package/bin/_cctally_core.py
CHANGED
|
@@ -400,7 +400,17 @@ _init_paths_from_env()
|
|
|
400
400
|
# removing the shared-memory WAL page map implicated by the retained corruption
|
|
401
401
|
# bundles. The one-time rebuild is the mode transition; no stats migration is
|
|
402
402
|
# added and cache/conversations remain WAL/NORMAL.
|
|
403
|
-
|
|
403
|
+
# 1010 -> 1011 (#661 S2 §6.4): the meter-rate-change event family. Adds
|
|
404
|
+
# `meter_rate_change_events`, a durable forward-only record that one provider
|
|
405
|
+
# metering-rate transition was observed, keyed
|
|
406
|
+
# `(provider, account_key, effective_from)`. It is an epoch bump and NOT a
|
|
407
|
+
# stats migration for the two reasons every bump since 1005 carries: the
|
|
408
|
+
# 13-entry registry is frozen, AND an epoch-current open returns before any
|
|
409
|
+
# schema work, so a `@stats_migration` handler would never run on an upgraded
|
|
410
|
+
# install and the table would simply never appear. `quota_alert_arming` is the
|
|
411
|
+
# precedent the family follows — a journaled STATE record whose boundary
|
|
412
|
+
# survives a rebuild, so history cannot re-fire.
|
|
413
|
+
STATS_INDEX_EPOCH = 1011
|
|
404
414
|
LEGACY_STATS_HEAD = 13
|
|
405
415
|
|
|
406
416
|
#: #496 S1 F1. A NEW branch, for a state that cannot occur before the
|
|
@@ -965,6 +975,12 @@ _ALERTS_CONFIG_VALID_KEYS = {
|
|
|
965
975
|
"weekly_thresholds",
|
|
966
976
|
"five_hour_thresholds",
|
|
967
977
|
"projected_enabled",
|
|
978
|
+
# #661 S2 §6.2. The metering-rate-change family's PUSH toggle, and only
|
|
979
|
+
# its push toggle: events are recorded from the first upgrade whatever
|
|
980
|
+
# this says, so every pull surface shows the state with no configuration.
|
|
981
|
+
# It defaults OFF like every other alert toggle, so an upgrade never
|
|
982
|
+
# produces a surprise notification.
|
|
983
|
+
"rate_change_enabled",
|
|
968
984
|
"notifier",
|
|
969
985
|
"command_template",
|
|
970
986
|
}
|
|
@@ -1062,6 +1078,17 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
1062
1078
|
f"{type(projected_enabled).__name__}: {projected_enabled!r}",
|
|
1063
1079
|
field="alerts.projected_enabled",
|
|
1064
1080
|
)
|
|
1081
|
+
# #661 S2 §6.2: the rate-change PUSH toggle, default OFF. Recording is
|
|
1082
|
+
# unconditional and does not consult this — the split is what lets a user
|
|
1083
|
+
# learn that the rate changed without opting in while nothing arrives
|
|
1084
|
+
# unbidden. Bool-validated rather than coerced, like `projected_enabled`.
|
|
1085
|
+
rate_change_enabled = block.get("rate_change_enabled", False)
|
|
1086
|
+
if not isinstance(rate_change_enabled, bool):
|
|
1087
|
+
raise _AlertsConfigError(
|
|
1088
|
+
f"alerts.rate_change_enabled must be a JSON boolean, got "
|
|
1089
|
+
f"{type(rate_change_enabled).__name__}: {rate_change_enabled!r}",
|
|
1090
|
+
field="alerts.rate_change_enabled",
|
|
1091
|
+
)
|
|
1065
1092
|
# Dispatch-global keys (Phase B). `notifier` selects the backend;
|
|
1066
1093
|
# `command_template` is an argv list for the `command` backend (and may be
|
|
1067
1094
|
# set ahead of switching the backend). The cross-field constraint
|
|
@@ -1109,6 +1136,7 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
1109
1136
|
"weekly_thresholds": weekly,
|
|
1110
1137
|
"five_hour_thresholds": five_hour,
|
|
1111
1138
|
"projected_enabled": projected_enabled,
|
|
1139
|
+
"rate_change_enabled": rate_change_enabled,
|
|
1112
1140
|
"notifier": notifier,
|
|
1113
1141
|
"command_template": command_template,
|
|
1114
1142
|
}
|
|
@@ -1637,6 +1665,39 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1637
1665
|
PRIMARY KEY(source)
|
|
1638
1666
|
);
|
|
1639
1667
|
|
|
1668
|
+
-- #661 S2 §6: a provider metering-rate transition, recorded from the
|
|
1669
|
+
-- first upgrade whether or not notification is enabled (§6.2). The
|
|
1670
|
+
-- row is the durable forward-only latch: its presence is what stops
|
|
1671
|
+
-- the same transition alerting twice, and it survives a stats.db
|
|
1672
|
+
-- rebuild because the journal event that created it carries a
|
|
1673
|
+
-- self-sufficient payload (§6.5).
|
|
1674
|
+
--
|
|
1675
|
+
-- The identity is `(provider, account_key, effective_from)` and the
|
|
1676
|
+
-- promise is stated at exactly that precision — once per exact key,
|
|
1677
|
+
-- not "once per provider regime". `reduce_state` closes and
|
|
1678
|
+
-- re-creates regimes on a fingerprint change and a revised detector
|
|
1679
|
+
-- can select a different `effective_from` for the same underlying
|
|
1680
|
+
-- transition; separately, R8 can turn a formerly merged identity into
|
|
1681
|
+
-- a real account key. Both produce a new key for a transition a human
|
|
1682
|
+
-- would call the same one. The FINGERPRINT stays out of the key
|
|
1683
|
+
-- deliberately: including it would re-alert on our own algorithm
|
|
1684
|
+
-- revisions.
|
|
1685
|
+
CREATE TABLE IF NOT EXISTS meter_rate_change_events (
|
|
1686
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1687
|
+
provider TEXT NOT NULL,
|
|
1688
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1689
|
+
effective_from TEXT NOT NULL,
|
|
1690
|
+
previous_units_per_point REAL NOT NULL,
|
|
1691
|
+
new_units_per_point REAL NOT NULL,
|
|
1692
|
+
severity TEXT NOT NULL,
|
|
1693
|
+
detected_at_utc TEXT NOT NULL,
|
|
1694
|
+
created_at_utc TEXT NOT NULL,
|
|
1695
|
+
notified_at TEXT,
|
|
1696
|
+
UNIQUE(provider, account_key, effective_from)
|
|
1697
|
+
);
|
|
1698
|
+
CREATE INDEX IF NOT EXISTS idx_meter_rate_change_events_key
|
|
1699
|
+
ON meter_rate_change_events(provider, account_key, effective_from);
|
|
1700
|
+
|
|
1640
1701
|
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
1641
1702
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1642
1703
|
source TEXT NOT NULL,
|
|
@@ -581,6 +581,7 @@ from _cctally_dashboard_envelope import (
|
|
|
581
581
|
_envelope_rows_project_budget,
|
|
582
582
|
_ENVELOPE_AXIS_MAPPERS,
|
|
583
583
|
_build_alerts_envelope_array,
|
|
584
|
+
_build_meter_rate_change_array,
|
|
584
585
|
_model_breakdowns_to_models,
|
|
585
586
|
)
|
|
586
587
|
|
|
@@ -8282,6 +8283,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8282
8283
|
merged_alerts["projected_enabled"] = (
|
|
8283
8284
|
alerts_in["projected_enabled"]
|
|
8284
8285
|
)
|
|
8286
|
+
# #661 S2 section 6.2's PUSH gate. Assigned VERBATIM, like
|
|
8287
|
+
# every sibling here: the boolean rule lives in
|
|
8288
|
+
# `_get_alerts_config`, which runs against the merged block
|
|
8289
|
+
# below, so a `bool()` here would coerce "yes" to True and
|
|
8290
|
+
# destroy the evidence before the canonical validator could
|
|
8291
|
+
# refuse it.
|
|
8292
|
+
if "rate_change_enabled" in alerts_in:
|
|
8293
|
+
merged_alerts["rate_change_enabled"] = (
|
|
8294
|
+
alerts_in["rate_change_enabled"]
|
|
8295
|
+
)
|
|
8285
8296
|
if "notifier" in alerts_in:
|
|
8286
8297
|
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
8287
8298
|
merged["alerts"] = merged_alerts
|