cctally 1.22.4 → 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 +20 -0
- package/bin/_cctally_alerts.py +133 -24
- package/bin/_cctally_config.py +195 -14
- package/bin/_cctally_core.py +102 -2
- package/bin/_cctally_dashboard.py +277 -62
- package/bin/_cctally_forecast.py +25 -3
- package/bin/_cctally_milestones.py +68 -0
- package/bin/_cctally_parser.py +10 -2
- package/bin/_cctally_record.py +470 -137
- package/bin/_cctally_tui.py +1 -0
- package/bin/_lib_alert_axes.py +53 -0
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/cctally +17 -0
- package/dashboard/static/assets/{index-BxmaYT1y.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 +3 -1
- package/dashboard/static/assets/index-CLcd-Tnm.js +0 -18
|
@@ -336,6 +336,10 @@ def _build_alert_payload_budget(*args, **kwargs):
|
|
|
336
336
|
return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
|
|
337
337
|
|
|
338
338
|
|
|
339
|
+
def _build_alert_payload_projected(*args, **kwargs):
|
|
340
|
+
return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
|
|
341
|
+
|
|
342
|
+
|
|
339
343
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
340
344
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
341
345
|
|
|
@@ -4065,31 +4069,16 @@ def _select_current_block_for_envelope(
|
|
|
4065
4069
|
}
|
|
4066
4070
|
|
|
4067
4071
|
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
)
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
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).
|
|
4072
|
+
# === Alerts-envelope per-axis row-mappers (Task F) =========================
|
|
4073
|
+
# Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
|
|
4074
|
+
# the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
|
|
4075
|
+
# (Codex P0-3: distinct columns, JOINs, id shapes), so the registry unifies the
|
|
4076
|
+
# *set* of axes + their ``milestone_table`` + the shared ``severity_for``
|
|
4077
|
+
# authority, not the query itself. ``descriptor.milestone_table`` drives each
|
|
4078
|
+
# ``FROM`` clause so the table name lives in the registry, not inlined here.
|
|
4082
4079
|
|
|
4083
|
-
All three axes share the same envelope schema; the ``axis`` field
|
|
4084
|
-
(``weekly`` / ``five_hour`` / ``budget``) discriminates.
|
|
4085
4080
|
|
|
4086
|
-
|
|
4087
|
-
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
4088
|
-
the boundary case where one axis has ``limit`` rows and the other
|
|
4089
|
-
has more recent ones that would otherwise be dropped before the
|
|
4090
|
-
final sort.
|
|
4091
|
-
"""
|
|
4092
|
-
out: list[dict] = []
|
|
4081
|
+
def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4093
4082
|
# ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
|
|
4094
4083
|
# across pre-credit (0) and post-credit (event.id) cohorts, both
|
|
4095
4084
|
# of which can be alerted. The envelope id must include the
|
|
@@ -4097,25 +4086,27 @@ def _build_alerts_envelope_array(
|
|
|
4097
4086
|
# collide on the duplicate (week, threshold) pair. Older clients
|
|
4098
4087
|
# tolerate longer ids — the id is opaque to them; only the React
|
|
4099
4088
|
# key uniqueness invariant matters.
|
|
4100
|
-
|
|
4101
|
-
"""
|
|
4089
|
+
rows = conn.execute(
|
|
4090
|
+
f"""
|
|
4102
4091
|
SELECT week_start_date, percent_threshold, captured_at_utc,
|
|
4103
4092
|
alerted_at, cumulative_cost_usd, reset_event_id
|
|
4104
|
-
FROM
|
|
4093
|
+
FROM {descriptor.milestone_table}
|
|
4105
4094
|
WHERE alerted_at IS NOT NULL
|
|
4106
4095
|
ORDER BY alerted_at DESC
|
|
4107
4096
|
LIMIT ?
|
|
4108
4097
|
""",
|
|
4109
4098
|
(limit,),
|
|
4110
4099
|
).fetchall()
|
|
4111
|
-
|
|
4100
|
+
out: list[dict] = []
|
|
4101
|
+
for r in rows:
|
|
4112
4102
|
threshold = int(r["percent_threshold"])
|
|
4113
4103
|
cumulative = float(r["cumulative_cost_usd"])
|
|
4114
4104
|
dpp = (cumulative / threshold) if threshold else None
|
|
4115
4105
|
out.append({
|
|
4116
4106
|
"id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
|
|
4117
|
-
"axis":
|
|
4107
|
+
"axis": descriptor.id,
|
|
4118
4108
|
"threshold": threshold,
|
|
4109
|
+
"severity": severity_for(threshold),
|
|
4119
4110
|
"crossed_at": r["captured_at_utc"],
|
|
4120
4111
|
"alerted_at": r["alerted_at"],
|
|
4121
4112
|
"context": {
|
|
@@ -4132,20 +4123,22 @@ def _build_alerts_envelope_array(
|
|
|
4132
4123
|
"reset_event_id": int(r["reset_event_id"]),
|
|
4133
4124
|
},
|
|
4134
4125
|
})
|
|
4126
|
+
return out
|
|
4127
|
+
|
|
4135
4128
|
|
|
4129
|
+
def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4136
4130
|
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
4137
4131
|
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
4138
4132
|
# of the same (window_key, threshold) don't collide with pre-credit
|
|
4139
4133
|
# (seg=0) crossings on the React row key. Older clients tolerate
|
|
4140
4134
|
# longer ids — the id is opaque to them; only the React key
|
|
4141
|
-
# uniqueness invariant matters. Mirrors the weekly precedent
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
"""
|
|
4135
|
+
# uniqueness invariant matters. Mirrors the weekly precedent.
|
|
4136
|
+
rows = conn.execute(
|
|
4137
|
+
f"""
|
|
4145
4138
|
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
4146
4139
|
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
4147
4140
|
b.block_start_at
|
|
4148
|
-
FROM
|
|
4141
|
+
FROM {descriptor.milestone_table} m
|
|
4149
4142
|
LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
|
|
4150
4143
|
WHERE m.alerted_at IS NOT NULL
|
|
4151
4144
|
ORDER BY m.alerted_at DESC
|
|
@@ -4153,15 +4146,17 @@ def _build_alerts_envelope_array(
|
|
|
4153
4146
|
""",
|
|
4154
4147
|
(limit,),
|
|
4155
4148
|
).fetchall()
|
|
4156
|
-
|
|
4149
|
+
out: list[dict] = []
|
|
4150
|
+
for r in rows:
|
|
4157
4151
|
threshold = int(r["percent_threshold"])
|
|
4158
4152
|
out.append({
|
|
4159
4153
|
"id": (
|
|
4160
4154
|
f"five_hour:{int(r['five_hour_window_key'])}:"
|
|
4161
4155
|
f"{threshold}:{int(r['reset_event_id'])}"
|
|
4162
4156
|
),
|
|
4163
|
-
"axis":
|
|
4157
|
+
"axis": descriptor.id,
|
|
4164
4158
|
"threshold": threshold,
|
|
4159
|
+
"severity": severity_for(threshold),
|
|
4165
4160
|
"crossed_at": r["captured_at_utc"],
|
|
4166
4161
|
"alerted_at": r["alerted_at"],
|
|
4167
4162
|
"context": {
|
|
@@ -4171,7 +4166,10 @@ def _build_alerts_envelope_array(
|
|
|
4171
4166
|
"reset_event_id": int(r["reset_event_id"]),
|
|
4172
4167
|
},
|
|
4173
4168
|
})
|
|
4169
|
+
return out
|
|
4174
4170
|
|
|
4171
|
+
|
|
4172
|
+
def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4175
4173
|
# Third axis (issue #19): equiv-$ budget threshold crossings. Budget
|
|
4176
4174
|
# alerts are keyed by the effective (post-reset) week_start_at + the
|
|
4177
4175
|
# integer threshold; the envelope id mirrors the dispatch payload's
|
|
@@ -4179,23 +4177,25 @@ def _build_alerts_envelope_array(
|
|
|
4179
4177
|
# (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
|
|
4180
4178
|
# a mid-week reset re-anchors ``week_start_at`` so the new window
|
|
4181
4179
|
# naturally gets fresh rows under ``UNIQUE(week_start_at, threshold)``.
|
|
4182
|
-
|
|
4183
|
-
"""
|
|
4180
|
+
rows = conn.execute(
|
|
4181
|
+
f"""
|
|
4184
4182
|
SELECT week_start_at, threshold, crossed_at_utc, alerted_at,
|
|
4185
4183
|
budget_usd, spent_usd, consumption_pct
|
|
4186
|
-
FROM
|
|
4184
|
+
FROM {descriptor.milestone_table}
|
|
4187
4185
|
WHERE alerted_at IS NOT NULL
|
|
4188
4186
|
ORDER BY alerted_at DESC
|
|
4189
4187
|
LIMIT ?
|
|
4190
4188
|
""",
|
|
4191
4189
|
(limit,),
|
|
4192
4190
|
).fetchall()
|
|
4193
|
-
|
|
4191
|
+
out: list[dict] = []
|
|
4192
|
+
for r in rows:
|
|
4194
4193
|
threshold = int(r["threshold"])
|
|
4195
4194
|
out.append({
|
|
4196
4195
|
"id": f"budget:{r['week_start_at']}:{threshold}",
|
|
4197
|
-
"axis":
|
|
4196
|
+
"axis": descriptor.id,
|
|
4198
4197
|
"threshold": threshold,
|
|
4198
|
+
"severity": severity_for(threshold),
|
|
4199
4199
|
"crossed_at": r["crossed_at_utc"],
|
|
4200
4200
|
"alerted_at": r["alerted_at"],
|
|
4201
4201
|
"context": {
|
|
@@ -4205,12 +4205,115 @@ def _build_alerts_envelope_array(
|
|
|
4205
4205
|
"consumption_pct": float(r["consumption_pct"]),
|
|
4206
4206
|
},
|
|
4207
4207
|
})
|
|
4208
|
+
return out
|
|
4209
|
+
|
|
4210
|
+
|
|
4211
|
+
def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4212
|
+
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
4213
|
+
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
4214
|
+
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
4215
|
+
# fresh rows under ``UNIQUE(week_start_at, metric, threshold)``. The
|
|
4216
|
+
# ``metric`` discriminator (``weekly_pct`` | ``budget_usd``) drives the
|
|
4217
|
+
# frontend's metric-aware context renderer; ``denominator`` +
|
|
4218
|
+
# ``projected_value`` are rendered FROM THE ROW (the values snapshotted at
|
|
4219
|
+
# crossing), never live config that may have changed since (Codex P0-4).
|
|
4220
|
+
# The envelope id mirrors the dispatch payload's
|
|
4221
|
+
# ``projected:<week_start_at>:<metric>:<threshold>`` shape.
|
|
4222
|
+
rows = conn.execute(
|
|
4223
|
+
f"""
|
|
4224
|
+
SELECT week_start_at, metric, threshold, projected_value,
|
|
4225
|
+
denominator, crossed_at_utc, alerted_at
|
|
4226
|
+
FROM {descriptor.milestone_table}
|
|
4227
|
+
WHERE alerted_at IS NOT NULL
|
|
4228
|
+
ORDER BY alerted_at DESC
|
|
4229
|
+
LIMIT ?
|
|
4230
|
+
""",
|
|
4231
|
+
(limit,),
|
|
4232
|
+
).fetchall()
|
|
4233
|
+
out: list[dict] = []
|
|
4234
|
+
for r in rows:
|
|
4235
|
+
threshold = int(r["threshold"])
|
|
4236
|
+
metric = str(r["metric"])
|
|
4237
|
+
out.append({
|
|
4238
|
+
"id": f"projected:{r['week_start_at']}:{metric}:{threshold}",
|
|
4239
|
+
"axis": descriptor.id,
|
|
4240
|
+
"metric": metric,
|
|
4241
|
+
"threshold": threshold,
|
|
4242
|
+
"severity": severity_for(threshold),
|
|
4243
|
+
"crossed_at": r["crossed_at_utc"],
|
|
4244
|
+
"alerted_at": r["alerted_at"],
|
|
4245
|
+
"context": {
|
|
4246
|
+
"week_start_at": r["week_start_at"],
|
|
4247
|
+
"metric": metric,
|
|
4248
|
+
"projected_value": float(r["projected_value"]),
|
|
4249
|
+
"denominator": float(r["denominator"]),
|
|
4250
|
+
},
|
|
4251
|
+
})
|
|
4252
|
+
return out
|
|
4253
|
+
|
|
4254
|
+
|
|
4255
|
+
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4256
|
+
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
4257
|
+
_ENVELOPE_AXIS_MAPPERS = {
|
|
4258
|
+
"weekly": _envelope_rows_weekly,
|
|
4259
|
+
"five_hour": _envelope_rows_five_hour,
|
|
4260
|
+
"budget": _envelope_rows_budget,
|
|
4261
|
+
"projected": _envelope_rows_projected,
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
|
|
4265
|
+
def _build_alerts_envelope_array(
|
|
4266
|
+
conn: sqlite3.Connection,
|
|
4267
|
+
limit: int = 100,
|
|
4268
|
+
) -> list[dict]:
|
|
4269
|
+
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4270
|
+
|
|
4271
|
+
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4272
|
+
``budget_milestones``, and ``projected_milestones`` rows with
|
|
4273
|
+
``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``,
|
|
4274
|
+
capped at ``limit`` (default 100). Single source of truth for both the
|
|
4275
|
+
dashboard panel (slices to 10 client-side) and the modal (renders all
|
|
4276
|
+
100). Forward-only semantics: only rows the alert-dispatch path stamped
|
|
4277
|
+
get included; pre-deploy crossings stay NULL and are intentionally
|
|
4278
|
+
invisible (spec §4.3).
|
|
4279
|
+
|
|
4280
|
+
All four axes share the same envelope schema; the ``axis`` field
|
|
4281
|
+
(``weekly`` / ``five_hour`` / ``budget`` / ``projected``) discriminates.
|
|
4282
|
+
The ``projected`` axis additionally carries a top-level ``metric``
|
|
4283
|
+
(``weekly_pct`` | ``budget_usd``) so the frontend can pick its
|
|
4284
|
+
metric-aware context renderer.
|
|
4285
|
+
|
|
4286
|
+
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4287
|
+
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
4288
|
+
the boundary case where one axis has ``limit`` rows and the other
|
|
4289
|
+
has more recent ones that would otherwise be dropped before the
|
|
4290
|
+
final sort.
|
|
4291
|
+
|
|
4292
|
+
**Registry-driven (Task F).** The *set* of axes, their union *order*,
|
|
4293
|
+
and each axis's ``milestone_table`` come from
|
|
4294
|
+
``_lib_alert_axes.AXIS_REGISTRY`` — adding a future axis is "register a
|
|
4295
|
+
descriptor + add a row-mapper", not "hand-roll a parallel branch". The
|
|
4296
|
+
SQL stays genuinely heterogeneous per axis (Codex P0-3: different
|
|
4297
|
+
columns, JOINs, id shapes), so each descriptor pairs with a bespoke
|
|
4298
|
+
row-mapper keyed by ``descriptor.id`` in ``_ENVELOPE_AXIS_MAPPERS``. The
|
|
4299
|
+
shared ``severity_for`` kernel stamps the additive ``severity`` field on
|
|
4300
|
+
every item (single severity authority, consumed by the frontend too).
|
|
4301
|
+
"""
|
|
4302
|
+
c = _cctally()
|
|
4303
|
+
registry = c.AXIS_REGISTRY
|
|
4304
|
+
severity_for = c.severity_for
|
|
4305
|
+
out: list[dict] = []
|
|
4306
|
+
for descriptor in registry:
|
|
4307
|
+
mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
|
|
4308
|
+
if mapper is None: # pragma: no cover - registry/mapper drift guard
|
|
4309
|
+
continue
|
|
4310
|
+
out.extend(mapper(conn, descriptor, limit, severity_for))
|
|
4208
4311
|
|
|
4209
4312
|
# Python's list.sort is stable. When two alerts share the same
|
|
4210
4313
|
# `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
|
|
4213
|
-
# because the spec doesn't require one.
|
|
4314
|
+
# millisecond), the union order (weekly, then 5h, then budget, then
|
|
4315
|
+
# projected) determines the tiebreaker — no extra deterministic key is
|
|
4316
|
+
# added because the spec doesn't require one.
|
|
4214
4317
|
out.sort(key=lambda a: a["alerted_at"], reverse=True)
|
|
4215
4318
|
return out[:limit]
|
|
4216
4319
|
|
|
@@ -4583,6 +4686,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4583
4686
|
"enabled": False,
|
|
4584
4687
|
"weekly_thresholds": [],
|
|
4585
4688
|
"five_hour_thresholds": [],
|
|
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,
|
|
4586
4696
|
}
|
|
4587
4697
|
# Budget is its OWN config block (issue #19) — source budget fields
|
|
4588
4698
|
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
@@ -4592,13 +4702,27 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4592
4702
|
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
4593
4703
|
except _BudgetConfigError:
|
|
4594
4704
|
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
4595
|
-
"alert_thresholds": []}
|
|
4705
|
+
"alert_thresholds": [], "projected_enabled": False}
|
|
4596
4706
|
alerts_settings = {
|
|
4597
4707
|
"enabled": _alerts_cfg["enabled"],
|
|
4598
4708
|
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
4599
4709
|
"five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
|
|
4600
4710
|
"budget_thresholds": list(_budget_cfg["alert_thresholds"]),
|
|
4601
4711
|
"budget_enabled": _budget_alerts_active(_budget_cfg),
|
|
4712
|
+
# Projected-pace opt-in mirrors (#121). Two flags, one per parent
|
|
4713
|
+
# axis — the frontend SettingsOverlay seeds two toggles. Sourced
|
|
4714
|
+
# from the validated getters' ``projected_enabled`` (default False).
|
|
4715
|
+
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
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,
|
|
4602
4726
|
}
|
|
4603
4727
|
|
|
4604
4728
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -5182,16 +5306,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5182
5306
|
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5183
5307
|
"cache_report"?: {"anomaly_threshold_pp"?: int},
|
|
5184
5308
|
"budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
|
|
5185
|
-
"alert_thresholds"?: int[]}}`` — every
|
|
5186
|
-
any subset may be sent together
|
|
5187
|
-
top-level keys are rejected with 400.
|
|
5309
|
+
"alert_thresholds"?: int[], "projected_enabled"?: bool}}`` — every
|
|
5310
|
+
top-level key is optional; any subset may be sent together
|
|
5311
|
+
(combined save). Unknown top-level keys are rejected with 400.
|
|
5188
5312
|
|
|
5189
5313
|
Per-block validation:
|
|
5190
5314
|
* ``display.tz`` — "local", "utc", or a valid IANA zone (via
|
|
5191
5315
|
``normalize_display_tz_value``); 400 on invalid.
|
|
5192
|
-
* ``alerts`` — must be a dict; ``alerts.enabled``
|
|
5193
|
-
|
|
5194
|
-
|
|
5316
|
+
* ``alerts`` — must be a dict; ``alerts.enabled`` and
|
|
5317
|
+
``alerts.projected_enabled`` must each be a JSON boolean
|
|
5318
|
+
(string "yes"/"true" rejected, per spec). Merged block is
|
|
5319
|
+
validated via ``_get_alerts_config(merged)``;
|
|
5195
5320
|
``_AlertsConfigError`` → 400.
|
|
5196
5321
|
* ``update.check.enabled`` — JSON bool; 400 on type mismatch.
|
|
5197
5322
|
* ``update.check.ttl_hours`` — JSON int (NOT string), in
|
|
@@ -5203,10 +5328,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5203
5328
|
``{error, field: "anomaly_threshold_pp"}`` on out-of-range
|
|
5204
5329
|
or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
|
|
5205
5330
|
F10 tracks lifting that.
|
|
5206
|
-
* ``budget`` — must be a dict;
|
|
5207
|
-
``
|
|
5208
|
-
|
|
5209
|
-
|
|
5331
|
+
* ``budget`` — must be a dict; the inbound leaves
|
|
5332
|
+
(``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds`` /
|
|
5333
|
+
``projected_enabled``) are merged onto the persisted ``budget``
|
|
5334
|
+
block and validated via ``_get_budget_config(merged)`` (issue
|
|
5335
|
+
#19, projected toggle #121); ``_BudgetConfigError`` → 400.
|
|
5336
|
+
Budget is its OWN config block, distinct from ``alerts``.
|
|
5210
5337
|
|
|
5211
5338
|
Atomic merged write: if all touched blocks validate, the merged
|
|
5212
5339
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -5235,7 +5362,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5235
5362
|
sent) is the full computed block from ``_compute_display_block``
|
|
5236
5363
|
(preserves ``tz`` / ``resolved_tz`` / ``offset_label`` /
|
|
5237
5364
|
``offset_seconds`` shape consumers rely on). ``alerts`` (when
|
|
5238
|
-
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.
|
|
5239
5371
|
``saved_at`` is included for backward compat.
|
|
5240
5372
|
"""
|
|
5241
5373
|
if not self._check_origin_csrf():
|
|
@@ -5367,6 +5499,35 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5367
5499
|
{"error": "alerts.enabled must be a JSON boolean"},
|
|
5368
5500
|
)
|
|
5369
5501
|
return
|
|
5502
|
+
if "projected_enabled" in alerts_block and not isinstance(
|
|
5503
|
+
alerts_block["projected_enabled"], bool
|
|
5504
|
+
):
|
|
5505
|
+
self._respond_json(
|
|
5506
|
+
400,
|
|
5507
|
+
{"error": "alerts.projected_enabled must be a JSON boolean"},
|
|
5508
|
+
)
|
|
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
|
|
5370
5531
|
|
|
5371
5532
|
# Pre-validate budget shape (the structural type check; full
|
|
5372
5533
|
# cross-key validation runs inside the lock via
|
|
@@ -5478,6 +5639,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5478
5639
|
alerts_in = payload["alerts"]
|
|
5479
5640
|
if "enabled" in alerts_in:
|
|
5480
5641
|
merged_alerts["enabled"] = alerts_in["enabled"]
|
|
5642
|
+
if "projected_enabled" in alerts_in:
|
|
5643
|
+
merged_alerts["projected_enabled"] = (
|
|
5644
|
+
alerts_in["projected_enabled"]
|
|
5645
|
+
)
|
|
5646
|
+
if "notifier" in alerts_in:
|
|
5647
|
+
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
5481
5648
|
merged["alerts"] = merged_alerts
|
|
5482
5649
|
# Final cross-field validation against the merged block.
|
|
5483
5650
|
# _AlertsConfigError → 400 (no partial write since
|
|
@@ -5504,7 +5671,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5504
5671
|
return
|
|
5505
5672
|
merged_budget = dict(existing_budget or {})
|
|
5506
5673
|
budget_in = payload["budget"]
|
|
5507
|
-
for leaf in (
|
|
5674
|
+
for leaf in (
|
|
5675
|
+
"weekly_usd", "alerts_enabled", "alert_thresholds",
|
|
5676
|
+
"projected_enabled",
|
|
5677
|
+
):
|
|
5508
5678
|
if leaf in budget_in:
|
|
5509
5679
|
merged_budget[leaf] = budget_in[leaf]
|
|
5510
5680
|
merged["budget"] = merged_budget
|
|
@@ -5576,7 +5746,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5576
5746
|
merged, dt.datetime.now(dt.timezone.utc)
|
|
5577
5747
|
)
|
|
5578
5748
|
if "alerts" in payload:
|
|
5579
|
-
|
|
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
|
|
5580
5757
|
if "budget" in payload:
|
|
5581
5758
|
# Echo the full validated budget block (defaults filled) so the
|
|
5582
5759
|
# SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5641,8 +5818,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5641
5818
|
dispatch status string in the JSON response.
|
|
5642
5819
|
|
|
5643
5820
|
Body (all fields optional): ``{"axis":
|
|
5644
|
-
"weekly"|"five_hour"|"budget", "threshold": 1..100
|
|
5645
|
-
axis="weekly",
|
|
5821
|
+
"weekly"|"five_hour"|"budget"|"projected", "threshold": 1..100,
|
|
5822
|
+
"metric": "weekly_pct"|"budget_usd"}``. Defaults: axis="weekly",
|
|
5823
|
+
threshold=90, metric="weekly_pct". ``metric`` is only consulted for
|
|
5824
|
+
the ``projected`` axis (mirrors the CLI ``alerts test --axis
|
|
5825
|
+
projected --metric`` surface); it is ignored for the other axes.
|
|
5646
5826
|
|
|
5647
5827
|
IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
|
|
5648
5828
|
in the JSON API to match the dispatch payload's internal axis
|
|
@@ -5681,12 +5861,25 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5681
5861
|
return
|
|
5682
5862
|
|
|
5683
5863
|
axis = body.get("axis", "weekly")
|
|
5684
|
-
if axis not in ("weekly", "five_hour", "budget"):
|
|
5864
|
+
if axis not in ("weekly", "five_hour", "budget", "projected"):
|
|
5685
5865
|
self._respond_json(
|
|
5686
5866
|
400,
|
|
5687
5867
|
{"error": (
|
|
5688
|
-
"axis must be 'weekly', 'five_hour'
|
|
5689
|
-
f"got {axis!r}"
|
|
5868
|
+
"axis must be 'weekly', 'five_hour', 'budget' or "
|
|
5869
|
+
f"'projected', got {axis!r}"
|
|
5870
|
+
)},
|
|
5871
|
+
)
|
|
5872
|
+
return
|
|
5873
|
+
# ``metric`` discriminates the projected axis (weekly_pct vs
|
|
5874
|
+
# budget_usd); the other axes ignore it. Validate only when it
|
|
5875
|
+
# matters so a stray metric on a weekly/budget test isn't a 400.
|
|
5876
|
+
metric = body.get("metric", "weekly_pct")
|
|
5877
|
+
if axis == "projected" and metric not in ("weekly_pct", "budget_usd"):
|
|
5878
|
+
self._respond_json(
|
|
5879
|
+
400,
|
|
5880
|
+
{"error": (
|
|
5881
|
+
"metric must be 'weekly_pct' or 'budget_usd', "
|
|
5882
|
+
f"got {metric!r}"
|
|
5690
5883
|
)},
|
|
5691
5884
|
)
|
|
5692
5885
|
return
|
|
@@ -5724,6 +5917,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5724
5917
|
spent_usd=300.0 * threshold / 100.0,
|
|
5725
5918
|
consumption_pct=float(threshold),
|
|
5726
5919
|
)
|
|
5920
|
+
elif axis == "projected":
|
|
5921
|
+
# Synthetic projected-pace payload — mirrors the CLI
|
|
5922
|
+
# cmd_alerts_test projected branch (NO DB writes, test/real
|
|
5923
|
+
# divergence contract). The metric discriminator picks the
|
|
5924
|
+
# wiring; projected_value is the threshold's denominator-relative
|
|
5925
|
+
# value (so the body reads plausibly, e.g. weekly 100% → "~100% of
|
|
5926
|
+
# cap", budget 100% → "$300 of $300"). denominator is the
|
|
5927
|
+
# at-crossing target the row would carry (Codex P0-4): 100.0 for
|
|
5928
|
+
# weekly_pct, $300 for budget_usd.
|
|
5929
|
+
if metric == "budget_usd":
|
|
5930
|
+
denominator = 300.0
|
|
5931
|
+
projected_value = 300.0 * threshold / 100.0
|
|
5932
|
+
else: # weekly_pct
|
|
5933
|
+
denominator = 100.0
|
|
5934
|
+
projected_value = float(threshold)
|
|
5935
|
+
payload = _build_alert_payload_projected(
|
|
5936
|
+
metric=metric,
|
|
5937
|
+
threshold=threshold,
|
|
5938
|
+
projected_value=projected_value,
|
|
5939
|
+
denominator=denominator,
|
|
5940
|
+
week_start_at=dt.date.today().isoformat(),
|
|
5941
|
+
)
|
|
5727
5942
|
else:
|
|
5728
5943
|
payload = _build_alert_payload_five_hour(
|
|
5729
5944
|
threshold=threshold,
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -266,7 +266,9 @@ def _resolve_current_budget_window(conn, now_utc):
|
|
|
266
266
|
return (week_start_at, week_end_at)
|
|
267
267
|
|
|
268
268
|
|
|
269
|
-
def _build_budget_status_inputs(
|
|
269
|
+
def _build_budget_status_inputs(
|
|
270
|
+
conn, *, target_usd, now_utc, alert_thresholds, skip_sync=False
|
|
271
|
+
):
|
|
270
272
|
"""Gather live spend over the current subscription week and build a
|
|
271
273
|
:class:`BudgetInputs`. Returns ``None`` when no week window resolves.
|
|
272
274
|
|
|
@@ -280,19 +282,29 @@ def _build_budget_status_inputs(conn, *, target_usd, now_utc, alert_thresholds):
|
|
|
280
282
|
(``max(week_start_at, now - 24h)``) so a heavy spend just before reset
|
|
281
283
|
can't leak last week's dollars into a fresh week's verdict (false
|
|
282
284
|
WARN/OVER).
|
|
285
|
+
|
|
286
|
+
``skip_sync`` skips the JSONL ingest pass inside ``get_entries`` for BOTH
|
|
287
|
+
cost SUMs — used by the projected-pace record path, where the actual-budget
|
|
288
|
+
axis already ran ``_sum_cost_for_range`` (warming the cache) earlier in the
|
|
289
|
+
same ``cmd_record_usage`` tick. The default ``False`` preserves the
|
|
290
|
+
standalone ``budget`` command's sync-on-read behavior unchanged.
|
|
283
291
|
"""
|
|
284
292
|
c = _cctally()
|
|
285
293
|
window = _resolve_current_budget_window(conn, now_utc)
|
|
286
294
|
if window is None:
|
|
287
295
|
return None
|
|
288
296
|
week_start_at, week_end_at = window
|
|
289
|
-
spent = c._sum_cost_for_range(
|
|
297
|
+
spent = c._sum_cost_for_range(
|
|
298
|
+
week_start_at, now_utc, mode="auto", skip_sync=skip_sync
|
|
299
|
+
)
|
|
290
300
|
# Clamp the recent-rate window at the week start: both bounds are tz-aware
|
|
291
301
|
# so max() is well-defined. Without this, a brand-new week (now < week
|
|
292
302
|
# start + 24h) would pull pre-reset spend into rate_recent and flip a
|
|
293
303
|
# fresh verdict to warn/over.
|
|
294
304
|
recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
|
|
295
|
-
recent_24h = c._sum_cost_for_range(
|
|
305
|
+
recent_24h = c._sum_cost_for_range(
|
|
306
|
+
recent_start, now_utc, mode="auto", skip_sync=skip_sync
|
|
307
|
+
)
|
|
296
308
|
return c.BudgetInputs(
|
|
297
309
|
target_usd=float(target_usd),
|
|
298
310
|
spent_usd=float(spent),
|
|
@@ -497,6 +509,7 @@ class ForecastOutput:
|
|
|
497
509
|
r_recent: float | None # pct per hour, 24h recent; None if no prior sample
|
|
498
510
|
final_percent_low: float
|
|
499
511
|
final_percent_high: float
|
|
512
|
+
week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
|
|
500
513
|
projected_cap: bool
|
|
501
514
|
already_capped: bool
|
|
502
515
|
cap_at: dt.datetime | None
|
|
@@ -527,6 +540,12 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
|
|
|
527
540
|
)
|
|
528
541
|
final_low, final_high = min(a, b), max(a, b)
|
|
529
542
|
|
|
543
|
+
# Smooth week-average projection (additive surface field). Distinct from
|
|
544
|
+
# the displayed band (which keys off final_high): this is the conservative
|
|
545
|
+
# week-average value the projected-pace alert axis fires on.
|
|
546
|
+
# p_now + r_avg*remaining (== project_linear collapsed to the single rate).
|
|
547
|
+
week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
|
|
548
|
+
|
|
530
549
|
already_capped = inputs.p_now >= 100.0
|
|
531
550
|
projected_cap = already_capped or final_high >= 100.0
|
|
532
551
|
|
|
@@ -562,6 +581,7 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
|
|
|
562
581
|
r_recent=r_recent,
|
|
563
582
|
final_percent_low=final_low,
|
|
564
583
|
final_percent_high=final_high,
|
|
584
|
+
week_avg_projection_pct=week_avg_projection_pct,
|
|
565
585
|
projected_cap=projected_cap,
|
|
566
586
|
already_capped=already_capped,
|
|
567
587
|
cap_at=cap_at,
|
|
@@ -628,6 +648,7 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
628
648
|
"forecast": {
|
|
629
649
|
"final_percent_low": round(out.final_percent_low, 3),
|
|
630
650
|
"final_percent_high": round(out.final_percent_high, 3),
|
|
651
|
+
"week_avg_projection_pct": round(out.week_avg_projection_pct, 3),
|
|
631
652
|
"projected_cap": out.projected_cap,
|
|
632
653
|
"cap_at": (None if out.cap_at is None else _iso_z(out.cap_at)),
|
|
633
654
|
"already_capped": out.already_capped,
|
|
@@ -1818,6 +1839,7 @@ def _budget_emit_json(budget_cfg, inputs, status) -> int:
|
|
|
1818
1839
|
"elapsed_fraction": status.elapsed_fraction,
|
|
1819
1840
|
"projected_eow_low_usd": status.projected_eow_low_usd,
|
|
1820
1841
|
"projected_eow_high_usd": status.projected_eow_high_usd,
|
|
1842
|
+
"week_avg_projection_usd": status.week_avg_projection_usd,
|
|
1821
1843
|
"daily_pace_usd": status.daily_pace_usd,
|
|
1822
1844
|
"daily_budget_remaining_usd": status.daily_budget_remaining_usd,
|
|
1823
1845
|
"verdict": status.verdict,
|