cctally 1.22.3 → 1.23.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 +19 -0
- package/bin/_cctally_alerts.py +27 -0
- package/bin/_cctally_config.py +68 -9
- package/bin/_cctally_core.py +60 -2
- package/bin/_cctally_dashboard.py +225 -60
- 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 +472 -138
- package/bin/_cctally_tui.py +1 -0
- package/bin/_cctally_weekrefs.py +36 -2
- package/bin/_lib_alert_axes.py +44 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/_lib_diff_kernel.py +5 -8
- package/bin/_lib_fmt.py +325 -0
- package/bin/_lib_render.py +9 -24
- package/bin/cctally +33 -273
- package/dashboard/static/assets/index-CXZDQrV3.js +18 -0
- package/dashboard/static/assets/{index-BxmaYT1y.css → index-ZHOC14y-.css} +1 -1
- 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
|
|
4170
|
+
|
|
4174
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,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4583
4686
|
"enabled": False,
|
|
4584
4687
|
"weekly_thresholds": [],
|
|
4585
4688
|
"five_hour_thresholds": [],
|
|
4689
|
+
"projected_enabled": False,
|
|
4586
4690
|
}
|
|
4587
4691
|
# Budget is its OWN config block (issue #19) — source budget fields
|
|
4588
4692
|
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
@@ -4592,13 +4696,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4592
4696
|
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
4593
4697
|
except _BudgetConfigError:
|
|
4594
4698
|
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
4595
|
-
"alert_thresholds": []}
|
|
4699
|
+
"alert_thresholds": [], "projected_enabled": False}
|
|
4596
4700
|
alerts_settings = {
|
|
4597
4701
|
"enabled": _alerts_cfg["enabled"],
|
|
4598
4702
|
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
4599
4703
|
"five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
|
|
4600
4704
|
"budget_thresholds": list(_budget_cfg["alert_thresholds"]),
|
|
4601
4705
|
"budget_enabled": _budget_alerts_active(_budget_cfg),
|
|
4706
|
+
# Projected-pace opt-in mirrors (#121). Two flags, one per parent
|
|
4707
|
+
# axis — the frontend SettingsOverlay seeds two toggles. Sourced
|
|
4708
|
+
# from the validated getters' ``projected_enabled`` (default False).
|
|
4709
|
+
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
4710
|
+
"projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
|
|
4602
4711
|
}
|
|
4603
4712
|
|
|
4604
4713
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -5182,16 +5291,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5182
5291
|
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5183
5292
|
"cache_report"?: {"anomaly_threshold_pp"?: int},
|
|
5184
5293
|
"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.
|
|
5294
|
+
"alert_thresholds"?: int[], "projected_enabled"?: bool}}`` — every
|
|
5295
|
+
top-level key is optional; any subset may be sent together
|
|
5296
|
+
(combined save). Unknown top-level keys are rejected with 400.
|
|
5188
5297
|
|
|
5189
5298
|
Per-block validation:
|
|
5190
5299
|
* ``display.tz`` — "local", "utc", or a valid IANA zone (via
|
|
5191
5300
|
``normalize_display_tz_value``); 400 on invalid.
|
|
5192
|
-
* ``alerts`` — must be a dict; ``alerts.enabled``
|
|
5193
|
-
|
|
5194
|
-
|
|
5301
|
+
* ``alerts`` — must be a dict; ``alerts.enabled`` and
|
|
5302
|
+
``alerts.projected_enabled`` must each be a JSON boolean
|
|
5303
|
+
(string "yes"/"true" rejected, per spec). Merged block is
|
|
5304
|
+
validated via ``_get_alerts_config(merged)``;
|
|
5195
5305
|
``_AlertsConfigError`` → 400.
|
|
5196
5306
|
* ``update.check.enabled`` — JSON bool; 400 on type mismatch.
|
|
5197
5307
|
* ``update.check.ttl_hours`` — JSON int (NOT string), in
|
|
@@ -5203,10 +5313,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5203
5313
|
``{error, field: "anomaly_threshold_pp"}`` on out-of-range
|
|
5204
5314
|
or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
|
|
5205
5315
|
F10 tracks lifting that.
|
|
5206
|
-
* ``budget`` — must be a dict;
|
|
5207
|
-
``
|
|
5208
|
-
|
|
5209
|
-
|
|
5316
|
+
* ``budget`` — must be a dict; the inbound leaves
|
|
5317
|
+
(``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds`` /
|
|
5318
|
+
``projected_enabled``) are merged onto the persisted ``budget``
|
|
5319
|
+
block and validated via ``_get_budget_config(merged)`` (issue
|
|
5320
|
+
#19, projected toggle #121); ``_BudgetConfigError`` → 400.
|
|
5321
|
+
Budget is its OWN config block, distinct from ``alerts``.
|
|
5210
5322
|
|
|
5211
5323
|
Atomic merged write: if all touched blocks validate, the merged
|
|
5212
5324
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -5367,6 +5479,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5367
5479
|
{"error": "alerts.enabled must be a JSON boolean"},
|
|
5368
5480
|
)
|
|
5369
5481
|
return
|
|
5482
|
+
if "projected_enabled" in alerts_block and not isinstance(
|
|
5483
|
+
alerts_block["projected_enabled"], bool
|
|
5484
|
+
):
|
|
5485
|
+
self._respond_json(
|
|
5486
|
+
400,
|
|
5487
|
+
{"error": "alerts.projected_enabled must be a JSON boolean"},
|
|
5488
|
+
)
|
|
5489
|
+
return
|
|
5370
5490
|
|
|
5371
5491
|
# Pre-validate budget shape (the structural type check; full
|
|
5372
5492
|
# cross-key validation runs inside the lock via
|
|
@@ -5478,6 +5598,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5478
5598
|
alerts_in = payload["alerts"]
|
|
5479
5599
|
if "enabled" in alerts_in:
|
|
5480
5600
|
merged_alerts["enabled"] = alerts_in["enabled"]
|
|
5601
|
+
if "projected_enabled" in alerts_in:
|
|
5602
|
+
merged_alerts["projected_enabled"] = (
|
|
5603
|
+
alerts_in["projected_enabled"]
|
|
5604
|
+
)
|
|
5481
5605
|
merged["alerts"] = merged_alerts
|
|
5482
5606
|
# Final cross-field validation against the merged block.
|
|
5483
5607
|
# _AlertsConfigError → 400 (no partial write since
|
|
@@ -5504,7 +5628,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5504
5628
|
return
|
|
5505
5629
|
merged_budget = dict(existing_budget or {})
|
|
5506
5630
|
budget_in = payload["budget"]
|
|
5507
|
-
for leaf in (
|
|
5631
|
+
for leaf in (
|
|
5632
|
+
"weekly_usd", "alerts_enabled", "alert_thresholds",
|
|
5633
|
+
"projected_enabled",
|
|
5634
|
+
):
|
|
5508
5635
|
if leaf in budget_in:
|
|
5509
5636
|
merged_budget[leaf] = budget_in[leaf]
|
|
5510
5637
|
merged["budget"] = merged_budget
|
|
@@ -5641,8 +5768,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5641
5768
|
dispatch status string in the JSON response.
|
|
5642
5769
|
|
|
5643
5770
|
Body (all fields optional): ``{"axis":
|
|
5644
|
-
"weekly"|"five_hour"|"budget", "threshold": 1..100
|
|
5645
|
-
axis="weekly",
|
|
5771
|
+
"weekly"|"five_hour"|"budget"|"projected", "threshold": 1..100,
|
|
5772
|
+
"metric": "weekly_pct"|"budget_usd"}``. Defaults: axis="weekly",
|
|
5773
|
+
threshold=90, metric="weekly_pct". ``metric`` is only consulted for
|
|
5774
|
+
the ``projected`` axis (mirrors the CLI ``alerts test --axis
|
|
5775
|
+
projected --metric`` surface); it is ignored for the other axes.
|
|
5646
5776
|
|
|
5647
5777
|
IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
|
|
5648
5778
|
in the JSON API to match the dispatch payload's internal axis
|
|
@@ -5681,12 +5811,25 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5681
5811
|
return
|
|
5682
5812
|
|
|
5683
5813
|
axis = body.get("axis", "weekly")
|
|
5684
|
-
if axis not in ("weekly", "five_hour", "budget"):
|
|
5814
|
+
if axis not in ("weekly", "five_hour", "budget", "projected"):
|
|
5815
|
+
self._respond_json(
|
|
5816
|
+
400,
|
|
5817
|
+
{"error": (
|
|
5818
|
+
"axis must be 'weekly', 'five_hour', 'budget' or "
|
|
5819
|
+
f"'projected', got {axis!r}"
|
|
5820
|
+
)},
|
|
5821
|
+
)
|
|
5822
|
+
return
|
|
5823
|
+
# ``metric`` discriminates the projected axis (weekly_pct vs
|
|
5824
|
+
# budget_usd); the other axes ignore it. Validate only when it
|
|
5825
|
+
# matters so a stray metric on a weekly/budget test isn't a 400.
|
|
5826
|
+
metric = body.get("metric", "weekly_pct")
|
|
5827
|
+
if axis == "projected" and metric not in ("weekly_pct", "budget_usd"):
|
|
5685
5828
|
self._respond_json(
|
|
5686
5829
|
400,
|
|
5687
5830
|
{"error": (
|
|
5688
|
-
"
|
|
5689
|
-
f"got {
|
|
5831
|
+
"metric must be 'weekly_pct' or 'budget_usd', "
|
|
5832
|
+
f"got {metric!r}"
|
|
5690
5833
|
)},
|
|
5691
5834
|
)
|
|
5692
5835
|
return
|
|
@@ -5724,6 +5867,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5724
5867
|
spent_usd=300.0 * threshold / 100.0,
|
|
5725
5868
|
consumption_pct=float(threshold),
|
|
5726
5869
|
)
|
|
5870
|
+
elif axis == "projected":
|
|
5871
|
+
# Synthetic projected-pace payload — mirrors the CLI
|
|
5872
|
+
# cmd_alerts_test projected branch (NO DB writes, test/real
|
|
5873
|
+
# divergence contract). The metric discriminator picks the
|
|
5874
|
+
# wiring; projected_value is the threshold's denominator-relative
|
|
5875
|
+
# value (so the body reads plausibly, e.g. weekly 100% → "~100% of
|
|
5876
|
+
# cap", budget 100% → "$300 of $300"). denominator is the
|
|
5877
|
+
# at-crossing target the row would carry (Codex P0-4): 100.0 for
|
|
5878
|
+
# weekly_pct, $300 for budget_usd.
|
|
5879
|
+
if metric == "budget_usd":
|
|
5880
|
+
denominator = 300.0
|
|
5881
|
+
projected_value = 300.0 * threshold / 100.0
|
|
5882
|
+
else: # weekly_pct
|
|
5883
|
+
denominator = 100.0
|
|
5884
|
+
projected_value = float(threshold)
|
|
5885
|
+
payload = _build_alert_payload_projected(
|
|
5886
|
+
metric=metric,
|
|
5887
|
+
threshold=threshold,
|
|
5888
|
+
projected_value=projected_value,
|
|
5889
|
+
denominator=denominator,
|
|
5890
|
+
week_start_at=dt.date.today().isoformat(),
|
|
5891
|
+
)
|
|
5727
5892
|
else:
|
|
5728
5893
|
payload = _build_alert_payload_five_hour(
|
|
5729
5894
|
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,
|
|
@@ -369,6 +369,74 @@ def insert_budget_milestone(
|
|
|
369
369
|
return int(cur.rowcount)
|
|
370
370
|
|
|
371
371
|
|
|
372
|
+
def insert_projected_milestone(
|
|
373
|
+
conn: sqlite3.Connection,
|
|
374
|
+
*,
|
|
375
|
+
week_start_at: str,
|
|
376
|
+
metric: str,
|
|
377
|
+
threshold: int,
|
|
378
|
+
projected_value: float,
|
|
379
|
+
denominator: float,
|
|
380
|
+
commit: bool = True,
|
|
381
|
+
) -> int:
|
|
382
|
+
"""INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
|
|
383
|
+
(1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
|
|
384
|
+
``(week_start_at, metric, threshold)`` row).
|
|
385
|
+
|
|
386
|
+
Mirrors :func:`insert_budget_milestone`'s rowcount contract so the
|
|
387
|
+
alert-fire predicate (`if inserted == 1`) is race-safe without a follow-up
|
|
388
|
+
SELECT. ``alerted_at`` is left NULL — the caller stamps it in the SAME
|
|
389
|
+
transaction BEFORE dispatching (set-then-dispatch invariant, CLAUDE.md
|
|
390
|
+
Alerts gotcha). ``commit=False`` lets the caller bundle the INSERT with the
|
|
391
|
+
follow-up ``alerted_at`` UPDATE in one transaction so a crash between them
|
|
392
|
+
can't strand ``alerted_at`` NULL forever.
|
|
393
|
+
"""
|
|
394
|
+
cur = conn.execute(
|
|
395
|
+
"INSERT OR IGNORE INTO projected_milestones "
|
|
396
|
+
"(week_start_at, metric, threshold, projected_value, denominator, "
|
|
397
|
+
" crossed_at_utc) "
|
|
398
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
399
|
+
(
|
|
400
|
+
week_start_at,
|
|
401
|
+
str(metric),
|
|
402
|
+
int(threshold),
|
|
403
|
+
float(projected_value),
|
|
404
|
+
float(denominator),
|
|
405
|
+
now_utc_iso(),
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
if commit:
|
|
409
|
+
conn.commit()
|
|
410
|
+
return int(cur.rowcount)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _projected_levels_already_latched(
|
|
414
|
+
conn: sqlite3.Connection,
|
|
415
|
+
*,
|
|
416
|
+
week_start_at: str,
|
|
417
|
+
metric: str,
|
|
418
|
+
levels: "tuple[int, ...]",
|
|
419
|
+
) -> bool:
|
|
420
|
+
"""True iff EVERY level in ``levels`` already has a row for
|
|
421
|
+
``(week_start_at, metric)``.
|
|
422
|
+
|
|
423
|
+
Cheap indexed SELECT used as the pre-probe gate BEFORE any projection math
|
|
424
|
+
/ cost work ([Pre-probe before sync_cache]). Empty ``levels`` → True
|
|
425
|
+
(nothing owed). When False, at least one level is still un-recorded and the
|
|
426
|
+
caller must do the projection. Mirrors the per-week pre-probe SELECT in
|
|
427
|
+
:func:`maybe_record_budget_milestone`.
|
|
428
|
+
"""
|
|
429
|
+
if not levels:
|
|
430
|
+
return True
|
|
431
|
+
rows = conn.execute(
|
|
432
|
+
"SELECT threshold FROM projected_milestones "
|
|
433
|
+
"WHERE week_start_at = ? AND metric = ?",
|
|
434
|
+
(week_start_at, str(metric)),
|
|
435
|
+
).fetchall()
|
|
436
|
+
have = {int(r[0]) for r in rows}
|
|
437
|
+
return all(int(level) in have for level in levels)
|
|
438
|
+
|
|
439
|
+
|
|
372
440
|
def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
|
|
373
441
|
"""Forward-only-from-set reconcile (spec §5): on `budget set`, every
|
|
374
442
|
threshold ALREADY crossed for the current week is recorded with
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2186,14 +2186,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2186
2186
|
cctally alerts test
|
|
2187
2187
|
cctally alerts test --axis five-hour --threshold 95
|
|
2188
2188
|
cctally alerts test --axis budget --threshold 100
|
|
2189
|
+
cctally alerts test --axis projected --metric budget_usd
|
|
2189
2190
|
"""),
|
|
2190
2191
|
)
|
|
2191
2192
|
p_alerts_test.add_argument(
|
|
2192
2193
|
"--axis",
|
|
2193
|
-
choices=["weekly", "five-hour", "budget"],
|
|
2194
|
+
choices=["weekly", "five-hour", "budget", "projected"],
|
|
2194
2195
|
default="weekly",
|
|
2195
2196
|
help="Alert axis to simulate: weekly subscription window, 5h block, "
|
|
2196
|
-
"
|
|
2197
|
+
"equiv-$ budget, or projected-pace (default: weekly).",
|
|
2197
2198
|
)
|
|
2198
2199
|
p_alerts_test.add_argument(
|
|
2199
2200
|
"--threshold",
|
|
@@ -2201,6 +2202,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2201
2202
|
default=90,
|
|
2202
2203
|
help="Threshold percent (1-100, default: 90).",
|
|
2203
2204
|
)
|
|
2205
|
+
p_alerts_test.add_argument(
|
|
2206
|
+
"--metric",
|
|
2207
|
+
choices=["weekly_pct", "budget_usd"],
|
|
2208
|
+
default="weekly_pct",
|
|
2209
|
+
help="For --axis projected: which projected metric to preview "
|
|
2210
|
+
"(default: weekly_pct).",
|
|
2211
|
+
)
|
|
2204
2212
|
p_alerts_test.set_defaults(func=c.cmd_alerts_test)
|
|
2205
2213
|
|
|
2206
2214
|
# ---- setup (onboarding spec §2) ----
|