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.
@@ -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
- def _build_alerts_envelope_array(
4069
- conn: sqlite3.Connection,
4070
- limit: int = 100,
4071
- ) -> list[dict]:
4072
- """Return the ``alerts`` array for the SSE snapshot envelope.
4073
-
4074
- Union of ``percent_milestones``, ``five_hour_milestones``, and
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
- Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
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
- weekly_rows = conn.execute(
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 percent_milestones
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
- for r in weekly_rows:
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": "weekly",
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 at
4142
- # line ~2597.
4143
- fh_rows = conn.execute(
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 five_hour_milestones m
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
- for r in fh_rows:
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": "five_hour",
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
- budget_rows = conn.execute(
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 budget_milestones
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
- for r in budget_rows:
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": "budget",
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 added
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 top-level key is optional;
5186
- any subset may be sent together (combined save). Unknown
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`` must be a
5193
- JSON boolean (string "yes"/"true" rejected, per spec). Merged
5194
- block is validated via ``_get_alerts_config(merged)``;
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; merged onto the persisted
5207
- ``budget`` block and validated via ``_get_budget_config(merged)``
5208
- (issue #19); ``_BudgetConfigError`` 400. Budget is its OWN
5209
- config block, distinct from ``alerts``.
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 ("weekly_usd", "alerts_enabled", "alert_thresholds"):
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
- out["alerts"] = _get_alerts_config(merged)
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}``. Defaults:
5645
- axis="weekly", threshold=90.
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' or 'budget', "
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,
@@ -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(conn, *, target_usd, now_utc, alert_thresholds):
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(week_start_at, now_utc, mode="auto")
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(recent_start, now_utc, mode="auto")
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,