diff --git a/kora_cli/audit/jsonl_sink.py b/kora_cli/audit/jsonl_sink.py index 6d1cd136817f..f1f5214866aa 100644 --- a/kora_cli/audit/jsonl_sink.py +++ b/kora_cli/audit/jsonl_sink.py @@ -280,6 +280,10 @@ # proposer; the loosen proposal flags routes where operator # overrode N+ times in the window. "opus_override.applied", + # KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat from #198) — alert wake seam + # mirrors probe.wake_requested for alert investigations. Reads return [] until + # the alert wake consumer writes these rows. + "alert.wake_requested", ] SourceName = Literal[ diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index ff1e679b5359..1936ed6a3dda 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -6624,6 +6624,25 @@ async def get_phrasebook_backups() -> Dict[str, Any]: ) +# KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND — canonical allowlist of +# the propose-then-approve loop types the cockpit surfaces. Order +# matches the FE tab order. Drift-guarded by +# test_promotion_loop_types_drift_guard against the FE constant in +# api.ts. ``snapshot_expand`` is read-only (audit-derived; no +# /pending endpoint); ``email_intent`` is forward-compat (BE +# endpoint lands with CC#1's #420). Both still belong in the +# allowlist so the FE tab nav stays stable as the BE plumbing +# fills in beneath it. +_PROMOTION_LOOP_TYPES: Tuple[str, ...] = ( + "phrasebook", + "router_tuning", + "tool_trimming", + "probe_fix_envelopes", + "snapshot_expand", + "email_intent", +) + + @app.get("/api/promotions/phrasebook/pending") async def list_pending_phrasebook_proposals() -> Dict[str, Any]: """Return all pending phrasebook proposals, highest-confidence @@ -6641,6 +6660,12 @@ async def list_pending_phrasebook_proposals() -> Dict[str, Any]: return { "proposals": [proposal_to_dict(p) for p in proposals], "status_values": list(_PROMOTION_STATUS_VALUES), + # ``loop_name`` mirrors the shape ``_promotion_loop_pending`` + # returns for the other 3 loops — the FE multi-loop refactor + # discriminates on this field. Keeping the phrasebook + # response symmetric lets the FE use one normalized read + # path across all loops. + "loop_name": "phrasebook", } @@ -7204,6 +7229,151 @@ async def reject_email_intent_proposal( payload=payload, ) +# --- Snapshot-expand (audit-derived; no /pending endpoint) ---------------- +# +# Snapshot-expand is propose-only-via-audit (see +# kora_cli/promote/snapshot_expand/applier.py docstring). It does +# NOT hit the proposal_store + has no /approve | /reject lifecycle — +# the loop either auto-applies (when KORA_PROMOTE_SNAPSHOT_EXPAND_ +# AUTO_APPLY=true) or just emits an audit row with action="proposed". +# +# CC#2's multi-loop PromotionReviewPage surfaces these as read-only +# cards alongside the actionable loops so operator sees the FULL +# promotion-loop picture in one place. The endpoint projects the +# most-recent ``promotion.snapshot_field_added`` audit rows into +# the proposals-like shape the FE expects. + + +@app.get("/api/promotions/snapshot-expand/recent") +async def list_recent_snapshot_expand_proposals( + limit: int = 50, +) -> Dict[str, Any]: + """Project recent ``promotion.snapshot_field_added`` audit rows + into a proposals-like response for the multi-loop review panel. + + Read-only — the snapshot_expand loop self-applies (audit-only + in v1 with ``KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY=false``, + audit+stub persist when ``=true``). Operator approval is NOT + part of this loop's lifecycle; the cards are informational so + the cockpit's promotion view is complete. + + Returns: + proposals: List of {proposal_id, action, cluster_size, + proposed_field_path, proposed_collector_summary, + source_tool_name, confidence, created_at, emitted_at, + sample_caller_session_ids}. + auto_apply_enabled: Echo of the env flag at read time so the + FE can flag "this loop is currently AUTO-APPLY ON — these + cards may already be in the snapshot schema." + loop_name: ``snapshot_expand`` discriminator (matches + ``_PROMOTION_LOOP_TYPES``). + """ + from kora_cli.audit.jsonl_reader import read_audit_entries + + capped_limit = max(1, min(int(limit or 50), 200)) + try: + rows = read_audit_entries(seam="promotion.snapshot_field_added") + except Exception: + rows = [] + + proposals: List[Dict[str, Any]] = [] + for entry in rows[:capped_limit]: + d = entry.details + proposals.append( + { + "proposal_id": str(d.get("proposal_id", ""))[:80], + "action": str(d.get("action", ""))[:32], + "cluster_size": d.get("cluster_size"), + "proposed_field_path": str( + d.get("proposed_field_path", "") + )[:120], + "proposed_collector_summary": str( + d.get("proposed_collector_summary", "") + )[:400], + "source_tool_name": str(d.get("source_tool_name", ""))[:80], + "sample_caller_session_ids": [ + str(s)[:120] + for s in (d.get("sample_caller_session_ids") or [])[:5] + ], + "confidence": d.get("confidence"), + "created_at": str(d.get("created_at", "")), + "emitted_at": entry.emitted_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ) + + auto_apply_raw = os.environ.get( + "KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY", "" + ).strip().lower() + auto_apply_enabled = auto_apply_raw in ("1", "true", "yes", "on") + return { + "proposals": proposals, + "loop_name": "snapshot_expand", + "auto_apply_enabled": auto_apply_enabled, + } + + +# --- Aggregate counts ------------------------------------------------------ + + +@app.get("/api/promotions/counts") +async def get_promotion_counts() -> Dict[str, Any]: + """Return per-loop pending counts in one round-trip. + + Used by the multi-loop PromotionReviewPage's tab navigation — + fanning out to 6 separate ``/pending`` endpoints just to populate + the badge counts would be wasteful. ``snapshot_expand`` reports + the count of recent (24h) ``promotion.snapshot_field_added`` + audit rows since that loop has no /pending semantics. + + Response: ``{counts: {loop_name: int, ...}, total_pending: int, + loop_names: [...]}``. ``total_pending`` excludes + ``snapshot_expand`` (informational only — operator-attention + chips should not bump on read-only data). + """ + from kora_cli.audit.jsonl_reader import read_audit_entries + from kora_cli.promote._shared.proposal_store import list_by_status + + counts: Dict[str, int] = {} + actionable_total = 0 + for loop_name in _PROMOTION_LOOP_TYPES: + if loop_name == "snapshot_expand": + try: + rows = read_audit_entries( + seam="promotion.snapshot_field_added" + ) + except Exception: + rows = [] + now = _probe_xref_datetime.now(_probe_xref_timezone.utc) + cutoff = now - _probe_xref_timedelta(hours=24) + count = sum(1 for e in rows if e.emitted_at >= cutoff) + counts[loop_name] = count + continue + if loop_name == "phrasebook": + try: + from kora_cli.promote.phrasebook.store import list_pending + + count = len(list_pending()) + except Exception: + count = 0 + else: + # Loops backed by the shared proposal_store — includes + # email_intent which lands with #420; until then this + # silently returns 0 (no promotions/email_intent dir). + try: + count = len( + list_by_status(loop_name=loop_name, status="pending") + ) + except Exception: + count = 0 + counts[loop_name] = count + actionable_total += count + + return { + "counts": counts, + "total_pending": actionable_total, + "loop_names": list(_PROMOTION_LOOP_TYPES), + } + # --------------------------------------------------------------------------- # Email-intent audit lens (KR-FE-EMAIL-INTENT-LOG-PANEL) @@ -7774,11 +7944,18 @@ async def list_recent_probe_autofix(limit: int = 100) -> Dict[str, Any]: # operator-driven approve/reject endpoint emissions from PR #186. # All three link through to /promotions/phrasebook with a focus= # query so the row deep-links to its proposal. +# +# KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420): +# ``alert_investigation_completed`` reads ``alert.investigation_ +# completed``. Today this category produces zero rows; once #420 +# emits the seam the timeline lights up automatically with deep- +# links to AlertInvestigationsPage + InvestigationDrillDown. _KORA_ACTION_CATEGORIES = ( "email_sent", "sea_ticket_created", "autofix_attempted", "investigation_completed", + "alert_investigation_completed", "phrasebook_proposal_approved", "promotion_proposed", "promotion_approved", @@ -7893,6 +8070,27 @@ def _kora_action_summary_investigation_completed( } +def _kora_action_summary_alert_investigation_completed( + d: Dict[str, Any], +) -> Dict[str, Any]: + """Symmetric to the probe variant; uses ``autoaction_attempted`` + (alert-side concept) instead of ``autofix_attempted``. Deep-links + to /alert-investigations.""" + category = str(d.get("category", ""))[:48] + severity = str(d.get("severity", ""))[:16] + autoaction = bool(d.get("autoaction_attempted", False)) + summary = "Alert investigation completed" + if category or severity: + summary += f" · {severity}/{category}".strip(" /") + if autoaction: + summary += " · 🚨 auto-action attempted" + return { + "summary": summary, + "status": "completed", + "deep_link": "/alert-investigations", + } + + def _kora_action_summary_promotion_proposed( d: Dict[str, Any], ) -> Dict[str, Any]: @@ -8011,6 +8209,16 @@ async def list_recent_kora_actions( ) except Exception: promotion_rejected_rows = [] + # KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) — alert + # investigation_completed rows surface in the timeline like the + # probe variant. Today returns []; once #420 lands the seam + # populates automatically. + try: + alert_investigation_rows = read_audit_entries( + seam="alert.investigation_completed" + ) + except Exception: + alert_investigation_rows = [] items: List[Dict[str, Any]] = [] lineno = 0 @@ -8091,6 +8299,19 @@ async def list_recent_kora_actions( } ) + for e in alert_investigation_rows: + lineno += 1 + s = _kora_action_summary_alert_investigation_completed(e.details) + items.append( + { + "id": f"action-alert-investigation-{lineno}", + "emitted_at": e.emitted_at, + "action_category": "alert_investigation_completed", + "caller_session_id": e.caller_session_id or "", + **s, + } + ) + for e in promotion_proposed_rows: lineno += 1 s = _kora_action_summary_promotion_proposed(e.details) @@ -8614,6 +8835,272 @@ async def get_probe_investigations( } +# --------------------------------------------------------------------------- +# Alert investigations — KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat) +# --------------------------------------------------------------------------- +# +# Mirror of /api/probe-investigations for ALERT-driven investigations +# (CC#1 #420 in flight). The alert wake consumer will write +# ``alert.wake_requested`` + ``alert.investigation_completed`` audit +# rows with ``caller_session_id == "alert:{category}:{severity}"`` +# — same JOIN substrate as probes. This endpoint reads the alert +# seams + dm_log entries with that session-id shape. +# +# FORWARD-COMPAT: until #420 lands the seams have zero rows. The +# endpoint returns ``items=[]`` cleanly + the FE renders the +# "no alert investigations yet" empty state. The SeamName Literal +# already includes the alert seams (added in this bucket) so +# read_audit_entries doesn't ValidationError on them post-#420. +# +# ``dm_status`` reuses the same 4-value enum as probe investigations +# (sent / failed_send / engine_unavailable_fallback / +# engine_unavailable_failed_send) — the wake-consumer code path is +# identical; only the source seam differs. A drift-guard alias +# constant _ALERT_DM_STATUS_VALUES locks this expectation. + + +_ALERT_CALLER_SESSION_RE = _probe_xref_re.compile( + r"^alert:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)$" +) + +# Drift-guard alias — alert investigations reuse the probe dm_status +# enum verbatim (same wake_consumer code path). The alias is a +# rename target if alert investigations ever diverge; today both +# point to the same source-of-truth tuple. +_ALERT_DM_STATUS_VALUES: Tuple[str, ...] = _DM_STATUS_VALUES + + +def _alert_caller_session_id(category: str, severity: str) -> str: + """Mirror the eventual #420 wake-consumer's caller_session_id + derivation. Pinned by the drift-guard test once #420 lands; in + the meantime the literal is the contract between BE + FE + empty-state rendering.""" + return f"alert:{category}:{severity}" + + +def _project_alert_completed(entry: "AuditEntry") -> Dict[str, Any]: + """Project an ``alert.investigation_completed`` audit row. + + Mirror of ``_project_investigation_completed`` for probes; v1 + swaps ``autofix_attempted`` (a probe-specific concept) for + ``autoaction_attempted`` (a generic alert-driven action flag — + populated false in v1, forward-compat for future alert-driven + fix envelopes).""" + d = entry.details + dm_status_raw = str(d.get("dm_status", "")) or "unknown" + dm_status = ( + dm_status_raw if dm_status_raw in _ALERT_DM_STATUS_VALUES else "unknown" + ) + cost_raw = d.get("total_cost_usd") + cost_val: Optional[float] = ( + float(cost_raw) if isinstance(cost_raw, (int, float)) else None + ) + dur_raw = d.get("investigation_duration_ms") + dur_val: Optional[int] = ( + int(dur_raw) if isinstance(dur_raw, (int, float)) else None + ) + model_raw = d.get("model_used") + model_val = str(model_raw) if isinstance(model_raw, str) else None + summary_raw = str(d.get("investigation_summary_text", "") or "") + summary = summary_raw[:600] + err_raw = d.get("reasoning_error") + err_val = str(err_raw)[:200] if isinstance(err_raw, str) else None + return { + "emitted_at": entry.emitted_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "summary_text": summary, + "model_used": model_val, + "total_cost_usd": cost_val, + "investigation_duration_ms": dur_val, + "dm_status": dm_status, + "autoaction_attempted": bool(d.get("autoaction_attempted", False)), + "reasoning_error": err_val, + } + + +def _read_alert_dm_log_entries() -> List[Dict[str, Any]]: + """Read slack_dm_log.jsonl outbound entries with an + ``alert:{category}:{severity}`` caller_session_id. Mirror of + _read_probe_dm_log_entries; defensive against missing file + + malformed lines.""" + import json as _json + + log_path = get_kora_home() / _SLACK_DM_LOG_FILENAME + out: List[Dict[str, Any]] = [] + if not log_path.is_file(): + return out + try: + with log_path.open("r", encoding="utf-8") as f: + for raw in f: + raw = raw.strip() + if not raw: + continue + try: + entry = _json.loads(raw) + except _json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + sid = entry.get("caller_session_id") + if not isinstance(sid, str): + continue + if not _ALERT_CALLER_SESSION_RE.match(sid): + continue + out.append(entry) + except OSError as exc: + logger.warning( + "[kora.alert_investigations] slack_dm_log read failed: %r", + exc, + ) + return out + + +@app.get("/api/alert-investigations") +async def get_alert_investigations( + window: str = "24h", + limit: int = 50, +) -> Dict[str, Any]: + """Alert wake → investigation xref panel feed. + + Joins (KR-FE-ALERT-INVESTIGATIONS-VIEWER, forward-compat #420): + * ``alert.wake_requested`` audit rows (the wake itself) + * ``alert.investigation_completed`` audit rows (cost / model / + dm_status / autoaction_attempted) + * slack_dm_log.jsonl outbound entries with caller_session_id + matching ``alert:{category}:{severity}`` + + Forward-compat: today none of these rows exist (CC#1 #420 ships + the emitter). Endpoint returns ``items=[]`` cleanly so the FE + renders an empty state rather than 404. Once #420 starts + emitting, the join lights up automatically. + + Args: + window: ``24h`` | ``7d`` | ``all``. Same semantics as + /api/probe-investigations. + limit: cap on items returned (1-200; default 50). + """ + from kora_cli.audit.jsonl_reader import read_audit_entries + + if window not in _PROBE_INVESTIGATION_VIEWER_WINDOWS: + window = "24h" + delta = _PROBE_INVESTIGATION_VIEWER_WINDOWS[window] + + capped_limit = max(1, min(int(limit or 50), 200)) + now = _probe_xref_datetime.now(_probe_xref_timezone.utc) + since = now - delta if delta is not None else None + + try: + wake_rows = read_audit_entries( + seam="alert.wake_requested", since=since + ) + except Exception: + wake_rows = [] + try: + completed_rows = read_audit_entries( + seam="alert.investigation_completed", since=since + ) + except Exception: + completed_rows = [] + + completed_by_session: Dict[str, "AuditEntry"] = {} + for entry in completed_rows: + sid = entry.caller_session_id or "" + if not _ALERT_CALLER_SESSION_RE.match(sid): + continue + prior = completed_by_session.get(sid) + if prior is None or entry.emitted_at > prior.emitted_at: + completed_by_session[sid] = entry + + dm_entries_by_session: Dict[str, Dict[str, Any]] = {} + for raw_dm in _read_alert_dm_log_entries(): + sid = str(raw_dm.get("caller_session_id", "")) + if not sid: + continue + prior = dm_entries_by_session.get(sid) + if prior is None or str(raw_dm.get("sent_at", "")) > str( + prior.get("sent_at", "") + ): + dm_entries_by_session[sid] = raw_dm + + items: list = [] + for entry in wake_rows[:capped_limit]: + d = entry.details + category = str(d.get("category") or "unknown") + severity = str(d.get("severity") or "warning") + session_id = _alert_caller_session_id(category, severity) + wake_iso = entry.emitted_at.strftime("%Y-%m-%dT%H:%M:%SZ") + + completed_entry = completed_by_session.get(session_id) + investigation_completed = ( + _project_alert_completed(completed_entry) + if completed_entry is not None + and completed_entry.emitted_at >= entry.emitted_at + else None + ) + + dm_raw = dm_entries_by_session.get(session_id) + dm_entry = ( + _project_probe_dm_entry(dm_raw) + if dm_raw is not None + and str(dm_raw.get("sent_at", "")) >= wake_iso + else None + ) + + items.append( + { + "wake_event_id": f"{wake_iso}:{category}:{severity}", + "wake_timestamp": wake_iso, + "alert_category": category, + "severity": severity, + "title": str(d.get("title") or "")[:200], + "detail": str(d.get("detail") or "")[:600], + "caller_session_id": session_id, + "investigation_completed": investigation_completed, + "dm_entry": dm_entry, + } + ) + + total_count = len(items) + # 24h by-severity + by-dm-status aggregations for the FE + # summary band + chip-filter counts. + by_severity_24h: Dict[str, int] = { + "critical": 0, + "warning": 0, + "info": 0, + } + by_dm_status_24h: Dict[str, int] = {v: 0 for v in _ALERT_DM_STATUS_VALUES} + cutoff_24h = now - _probe_xref_timedelta(hours=24) + for it in items: + try: + it_dt = _probe_xref_datetime.fromisoformat( + str(it.get("wake_timestamp", "")).replace("Z", "+00:00") + ) + except ValueError: + continue + if it_dt < cutoff_24h: + continue + sev = str(it.get("severity", "")) + if sev in by_severity_24h: + by_severity_24h[sev] += 1 + ic = it.get("investigation_completed") + if isinstance(ic, dict): + status = str(ic.get("dm_status", "")) + if status in by_dm_status_24h: + by_dm_status_24h[status] += 1 + + return { + "window": window, + "since": since.strftime("%Y-%m-%dT%H:%M:%SZ") if since else None, + "generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "total_count": total_count, + "items": items, + "by_severity_24h": by_severity_24h, + "by_dm_status_24h": by_dm_status_24h, + # Echoed canonical allowlist. Mirrors the probe-investigations + # pattern + the FE PROBE_DM_STATUS_VALUES drift-guard. + "dm_status_values": list(_ALERT_DM_STATUS_VALUES), + } + + # --------------------------------------------------------------------------- # Investigation drill-down — KR-FE-INVESTIGATION-DRILL-DOWN # --------------------------------------------------------------------------- @@ -8650,6 +9137,12 @@ async def get_probe_investigations( "promotion.proposed", "promotion.approved", "promotion.rejected", + # KR-FE-ALERT-INVESTIGATIONS-VIEWER — forward-compat for #420. + # Alert wake + completed seams; today they return zero rows. + # Once #420 lands the drill-down surfaces alert investigations + # the same way it surfaces probe investigations. + "alert.wake_requested", + "alert.investigation_completed", ) @@ -8683,6 +9176,18 @@ def _drilldown_project_audit_row( base["details"] = _project_probe_autofix_audit(entry, lineno) elif seam == "probe.investigation_completed": base["details"] = _project_investigation_completed(entry) + elif seam == "alert.investigation_completed": + base["details"] = _project_alert_completed(entry) + elif seam == "alert.wake_requested": + # Mirror of probe.wake_requested projection; envelope_enabled + # / envelope_fix_name are probe-specific so the alert variant + # omits them. + base["details"] = { + "alert_category": str(d.get("category") or "unknown"), + "severity": str(d.get("severity") or "warning"), + "title": str(d.get("title") or "")[:200], + "detail": str(d.get("detail") or "")[:400], + } elif seam == "probe.wake_requested": base["details"] = { "probe": str(d.get("probe") or "unknown"), diff --git a/tests/kora_cli/test_alert_investigations_endpoint.py b/tests/kora_cli/test_alert_investigations_endpoint.py new file mode 100644 index 000000000000..83bfc039b96b --- /dev/null +++ b/tests/kora_cli/test_alert_investigations_endpoint.py @@ -0,0 +1,329 @@ +"""KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) tests. + +Endpoint /api/alert-investigations mirrors /api/probe-investigations: + + * Reads alert.wake_requested + alert.investigation_completed audit + rows (both added to SeamName Literal in this bucket as + forward-compat) + * Joins slack_dm_log.jsonl outbound entries by caller_session_id + ``alert:{category}:{severity}`` + * Returns empty cleanly when none of the seams have any rows + (the CC#1 #420 forward-compat case) + +Plus FE source-pins: page exists + uses panel-view + route registered ++ nav entry + deep-links to InvestigationDrillDown. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +import pytest + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_API_TS = _REPO_ROOT / "web" / "src" / "lib" / "api.ts" +_APP_TSX = _REPO_ROOT / "web" / "src" / "App.tsx" +_PAGE = _REPO_ROOT / "web" / "src" / "pages" / "AlertInvestigationsPage.tsx" +_WEB_SERVER = _REPO_ROOT / "kora_cli" / "web_server.py" +_JSONL_SINK = _REPO_ROOT / "kora_cli" / "audit" / "jsonl_sink.py" + + +@pytest.fixture +def env(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("KORA_HOME", str(tmp_path)) + monkeypatch.setattr("kora_constants.get_kora_home", lambda: tmp_path) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr("kora_cli.web_server.get_kora_home", lambda: tmp_path) + return tmp_path + + +def _write_audit_jsonl(env_dir: Path, entries: list[dict]) -> None: + log_path = env_dir / "kora_audit_log.jsonl" + with log_path.open("w", encoding="utf-8") as f: + for e in entries: + f.write(json.dumps(e, default=str) + "\n") + + +def _write_slack_dm_log(env_dir: Path, entries: list[dict]) -> None: + log_path = env_dir / "slack_dm_log.jsonl" + with log_path.open("w", encoding="utf-8") as f: + for e in entries: + f.write(json.dumps(e, default=str) + "\n") + + +def _alert_wake( + *, + category: str, + severity: str, + emitted_at: datetime, + title: str = "", + detail: str = "", +) -> dict: + return { + "emitted_at": emitted_at.isoformat(), + "seam": "alert.wake_requested", + "details": { + "category": category, + "severity": severity, + "title": title, + "detail": detail, + }, + "source": "cron", + "caller_session_id": None, + } + + +def _alert_completed( + *, + category: str, + severity: str, + emitted_at: datetime, + dm_status: str = "sent", + autoaction_attempted: bool = False, + summary: str = "", +) -> dict: + return { + "emitted_at": emitted_at.isoformat(), + "seam": "alert.investigation_completed", + "details": { + "category": category, + "severity": severity, + "model_used": "claude-haiku-4-5", + "total_cost_usd": 0.0021, + "investigation_duration_ms": 2100, + "dm_status": dm_status, + "autoaction_attempted": autoaction_attempted, + "investigation_summary_text": summary, + }, + "source": "reasoning", + "caller_session_id": f"alert:{category}:{severity}", + } + + +async def _call(window: str = "24h", limit: int = 50) -> dict: + from kora_cli import web_server + + return await web_server.get_alert_investigations( + window=window, limit=limit + ) + + +@pytest.mark.asyncio +async def test_empty_when_no_alert_seams(env): + """The forward-compat case: until #420 ships, the alert seams + have zero rows. Endpoint must return ``items=[]`` cleanly so + the FE renders an empty state rather than 404.""" + body = await _call() + assert body["items"] == [] + assert body["total_count"] == 0 + assert "dm_status_values" in body + # Per-severity + per-dm-status counts all zero, but the keys + # exist so the FE filter chips render with 0 counts. + assert set(body["by_severity_24h"].keys()) == { + "critical", + "warning", + "info", + } + assert all(v == 0 for v in body["by_severity_24h"].values()) + + +@pytest.mark.asyncio +async def test_join_wake_and_completed_by_session(env): + now = datetime(2026, 5, 24, 12, 0, 0, tzinfo=timezone.utc) + _write_audit_jsonl( + env, + [ + _alert_wake( + category="cost_anomaly", + severity="critical", + emitted_at=now, + title="Daily burn 3x baseline", + detail="Today: $42; baseline: $14", + ), + _alert_completed( + category="cost_anomaly", + severity="critical", + emitted_at=now, + summary="Burn spike from a stuck cron retry loop.", + autoaction_attempted=False, + ), + ], + ) + body = await _call() + assert body["total_count"] == 1 + item = body["items"][0] + assert item["alert_category"] == "cost_anomaly" + assert item["severity"] == "critical" + assert item["caller_session_id"] == "alert:cost_anomaly:critical" + assert item["investigation_completed"] is not None + ic = item["investigation_completed"] + assert ic["dm_status"] == "sent" + assert ic["autoaction_attempted"] is False + assert "Burn spike" in ic["summary_text"] + # 24h aggregations. + assert body["by_severity_24h"]["critical"] == 1 + assert body["by_dm_status_24h"]["sent"] == 1 + + +@pytest.mark.asyncio +async def test_dm_entry_joined_by_session_id(env): + now = datetime(2026, 5, 24, 12, 0, 0, tzinfo=timezone.utc) + _write_audit_jsonl( + env, + [ + _alert_wake( + category="cost_anomaly", + severity="warning", + emitted_at=now, + ), + ], + ) + _write_slack_dm_log( + env, + [ + { + "sent_at": "2026-05-24T12:00:05Z", + "channel_id": "D01J", + "thread_ts": None, + "text": "alert dm body (not echoed)", + "slack_message_ts": "1742345200.000", + "send_status": "ok", + "caller_session_id": "alert:cost_anomaly:warning", + }, + ], + ) + body = await _call() + item = body["items"][0] + assert item["dm_entry"] is not None + assert item["dm_entry"]["channel_id"] == "D01J" + assert item["dm_entry"]["send_status"] == "ok" + # Privacy contract: no message text echoed. + assert "alert dm body" not in json.dumps(body) + + +@pytest.mark.asyncio +async def test_non_alert_session_ignored(env): + """A slack_dm_log row whose caller_session_id is shaped like a + probe (probe:foo:bar) must NOT join into an alert investigation + even if the timestamps line up.""" + now = datetime(2026, 5, 24, 12, 0, 0, tzinfo=timezone.utc) + _write_audit_jsonl( + env, + [_alert_wake(category="x", severity="critical", emitted_at=now)], + ) + _write_slack_dm_log( + env, + [ + { + "sent_at": "2026-05-24T12:00:05Z", + "channel_id": "D01J", + "thread_ts": None, + "text": "probe-shaped dm", + "slack_message_ts": "1742345200.000", + "send_status": "ok", + "caller_session_id": "probe:fly:service_unhealthy", + }, + ], + ) + body = await _call() + assert body["items"][0]["dm_entry"] is None + + +# --------------------------------------------------------------------------- +# Drift guard: alert dm_status reuses probe values +# --------------------------------------------------------------------------- + + +def test_alert_dm_status_drift_guard(): + """_ALERT_DM_STATUS_VALUES is an alias for _DM_STATUS_VALUES — + pin that they remain identical until alert dm_status diverges + from probe dm_status (no concrete reason to today; alert wake + consumer reuses the probe DM dispatch path).""" + ws_src = _WEB_SERVER.read_text() + assert "_ALERT_DM_STATUS_VALUES: Tuple[str, ...] = _DM_STATUS_VALUES" in ws_src + + +def test_alert_seams_in_seamname_literal(): + """Both alert seams must be in the SeamName Literal so + read_audit_entries doesn't ValidationError on them once #420 + starts emitting. Forward-compat addition lives in this + bucket; the emitter lands with #420.""" + sink_src = _JSONL_SINK.read_text() + assert '"alert.wake_requested"' in sink_src + assert '"alert.investigation_completed"' in sink_src + + +# --------------------------------------------------------------------------- +# FE source-pins +# --------------------------------------------------------------------------- + + +def test_fe_api_wrapper_exists(): + src = _API_TS.read_text() + assert "getAlertInvestigations" in src + assert "/api/alert-investigations" in src + + +def test_fe_response_type_declared(): + src = _API_TS.read_text() + for f in ( + "AlertInvestigationsResponse", + "AlertInvestigationItem", + "AlertInvestigationCompleted", + "alert_category", + "autoaction_attempted", + ): + assert f in src, f"missing FE field: {f}" + + +def test_alert_investigations_page_exists(): + assert _PAGE.is_file() + src = _PAGE.read_text() + assert 'usePanelView("AlertInvestigationsPage")' in src + + +def test_route_registered(): + src = _APP_TSX.read_text() + assert "/alert-investigations" in src + assert "AlertInvestigationsPage" in src + + +def test_nav_entry_present(): + src = _APP_TSX.read_text() + nav_block = re.search( + r'path:\s*"/alert-investigations"[^}]+labelKey:\s*"alertInvestigations"', + src, + re.DOTALL, + ) + assert nav_block, "nav entry for /alert-investigations missing" + + +def test_alert_card_links_to_drill_down(): + """Each alert investigation card must offer a drill-in link to + /investigations/ (mirror of probe variant).""" + src = _PAGE.read_text() + assert ( + "/investigations/${encodeURIComponent(item.caller_session_id)}" + in src + ) + + +def test_drill_down_allowlist_includes_alert_seams(): + """KR-FE-INVESTIGATION-DRILL-DOWN's _DRILL_DOWN_SUPPORTED_SEAMS + must include both alert seams so an alert.investigation drill + surfaces the wake + completed rows once #420 starts emitting.""" + ws_src = _WEB_SERVER.read_text() + m = re.search( + r"_DRILL_DOWN_SUPPORTED_SEAMS[^=]*=\s*\(([^)]+)\)", + ws_src, + re.DOTALL, + ) + assert m is not None + seams = set(re.findall(r'"([^"]+)"', m.group(1))) + assert "alert.wake_requested" in seams + assert "alert.investigation_completed" in seams diff --git a/tests/kora_cli/test_kora_actions_panel.py b/tests/kora_cli/test_kora_actions_panel.py index ade601b5c2cb..cf1c5a94fbd2 100644 --- a/tests/kora_cli/test_kora_actions_panel.py +++ b/tests/kora_cli/test_kora_actions_panel.py @@ -445,12 +445,17 @@ def test_action_categories_drift_guard(): KR-FE-KORA-ACTIONS-EXTENDED-SEAMS extension: + promotion_proposed / promotion_approved / promotion_rejected for the PR #186 promotion-loop audit rows. + + KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) extension: + + alert_investigation_completed for the alert.investigation_ + completed audit seam. """ expected = { "email_sent", "sea_ticket_created", "autofix_attempted", "investigation_completed", + "alert_investigation_completed", "phrasebook_proposal_approved", "promotion_proposed", "promotion_approved", @@ -497,6 +502,8 @@ def test_seam_literal_includes_all_source_seams(): "promotion.proposed", "promotion.approved", "promotion.rejected", + # KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) + "alert.investigation_completed", ): assert f'"{seam}"' in sink_src, ( f"SeamName missing '{seam}' — KoraActionsPage will " diff --git a/tests/kora_cli/test_promotion_loop_types_drift_guard.py b/tests/kora_cli/test_promotion_loop_types_drift_guard.py new file mode 100644 index 000000000000..1d175aced165 --- /dev/null +++ b/tests/kora_cli/test_promotion_loop_types_drift_guard.py @@ -0,0 +1,365 @@ +"""KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND — drift-guard + +endpoint behaviour tests for the multi-loop PromotionReviewPage. + +Pins: + * _PROMOTION_LOOP_TYPES (BE) ↔ PROMOTION_LOOP_NAMES (FE) + * Per-loop /pending response shape carries ``loop_name`` for FE + discrimination + * /api/promotions/counts aggregate endpoint shape + behaviour + when each loop directory is empty / present / missing + * /api/promotions/snapshot-expand/recent reads + promotion.snapshot_field_added audit + echoes auto_apply flag + * Phrasebook /pending response gains ``loop_name`` for parity +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +import pytest + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_API_TS = _REPO_ROOT / "web" / "src" / "lib" / "api.ts" +_APP_TSX = _REPO_ROOT / "web" / "src" / "App.tsx" +_PAGE = _REPO_ROOT / "web" / "src" / "pages" / "PromotionReviewPage.tsx" +_WEB_SERVER = _REPO_ROOT / "kora_cli" / "web_server.py" + + +# --------------------------------------------------------------------------- +# Drift guard +# --------------------------------------------------------------------------- + + +def test_promotion_loop_types_drift_guard(): + """_PROMOTION_LOOP_TYPES (web_server.py) ↔ PROMOTION_LOOP_NAMES + (api.ts) must agree. + + Order matters: the FE iterates the constant for tab order, so + a re-arrangement on either side must be mirrored or the tabs + silently re-order without the FE realizing. + """ + expected = [ + "phrasebook", + "router_tuning", + "tool_trimming", + "probe_fix_envelopes", + "snapshot_expand", + "email_intent", + ] + + ws_src = _WEB_SERVER.read_text() + m = re.search( + r"_PROMOTION_LOOP_TYPES[^=]*=\s*\(([^)]+)\)", + ws_src, + re.DOTALL, + ) + assert m is not None, "_PROMOTION_LOOP_TYPES tuple not found" + be_values = re.findall(r'"([^"]+)"', m.group(1)) + assert be_values == expected, f"BE order drift: {be_values}" + + fe_src = _API_TS.read_text() + m = re.search( + r"PROMOTION_LOOP_NAMES[^=]*=\s*\[([^\]]+)\]", + fe_src, + ) + assert m is not None, "PROMOTION_LOOP_NAMES constant not found" + fe_values = re.findall(r'"([^"]+)"', m.group(1)) + assert fe_values == expected, f"FE order drift: {fe_values}" + + +def test_promotion_loop_slugs_match_loop_dirs(): + """The FE slug map (URL paths) must map every loop_name to a + BE endpoint path that actually exists. Catches typos in either + direction — a slug rename without a BE endpoint rename would + 404 every approve/reject call for that loop.""" + fe_src = _API_TS.read_text() + ws_src = _WEB_SERVER.read_text() + + # Each loop the BE has a /pending endpoint for must appear in + # the FE PROMOTION_LOOP_SLUGS map as a slug value. + # phrasebook is the typed-wrapper path, not slug-routed. + expected_slugs = { + "phrasebook", + "router-tuning", + "tool-trimming", + "probe-envelopes", + # snapshot-expand uses a non-standard /recent endpoint; + # email-intent (forward-compat) follows the slug-routed + # pattern once #420 lands. + } + m = re.search( + r"PROMOTION_LOOP_SLUGS:\s*Record<[^>]+>\s*=\s*\{([^}]+)\}", + fe_src, + re.DOTALL, + ) + assert m is not None, "PROMOTION_LOOP_SLUGS map not found" + slug_values = set(re.findall(r':\s*"([^"]+)"', m.group(1))) + for slug in expected_slugs: + assert slug in slug_values, ( + f"FE slug map missing {slug!r}" + ) + assert ( + f'/api/promotions/{slug}/pending' in ws_src + ), f"BE has no /api/promotions/{slug}/pending endpoint" + + +# --------------------------------------------------------------------------- +# /api/promotions/counts behaviour +# --------------------------------------------------------------------------- + + +@pytest.fixture +def env(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("KORA_HOME", str(tmp_path)) + monkeypatch.setattr("kora_constants.get_kora_home", lambda: tmp_path) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr("kora_cli.web_server.get_kora_home", lambda: tmp_path) + monkeypatch.setenv( + "KORA_PROMOTIONS_DIR", str(tmp_path / "promotions") + ) + return tmp_path + + +def _write_pending_proposal_file( + env_dir: Path, loop_name: str, proposal_id: str +) -> None: + """Create a minimal proposal file in the loop's pending/ dir.""" + p = env_dir / "promotions" / loop_name / "pending" + p.mkdir(parents=True, exist_ok=True) + (p / f"{proposal_id}.json").write_text( + json.dumps({"proposal_id": proposal_id, "status": "pending"}) + ) + + +def _write_audit_jsonl(env_dir: Path, entries: list[dict]) -> None: + log_path = env_dir / "kora_audit_log.jsonl" + with log_path.open("w", encoding="utf-8") as f: + for e in entries: + f.write(json.dumps(e, default=str) + "\n") + + +async def _call_counts() -> dict: + from kora_cli import web_server + + return await web_server.get_promotion_counts() + + +@pytest.mark.asyncio +async def test_counts_empty_when_no_promotions(env): + body = await _call_counts() + assert set(body["counts"].keys()) == { + "phrasebook", + "router_tuning", + "tool_trimming", + "probe_fix_envelopes", + "snapshot_expand", + "email_intent", + } + for v in body["counts"].values(): + assert v == 0 + assert body["total_pending"] == 0 + + +@pytest.mark.asyncio +async def test_counts_aggregates_actionable_only(env): + # Drop a pending proposal in each of 3 actionable loops + an + # audit row for snapshot_expand. total_pending must exclude + # snapshot_expand. + _write_pending_proposal_file(env, "router_tuning", "r1") + _write_pending_proposal_file(env, "tool_trimming", "t1") + _write_pending_proposal_file(env, "tool_trimming", "t2") + _write_pending_proposal_file(env, "probe_fix_envelopes", "p1") + + _write_audit_jsonl( + env, + [ + { + "emitted_at": datetime.now(timezone.utc).isoformat(), + "seam": "promotion.snapshot_field_added", + "details": { + "proposal_id": "s1", + "action": "proposed", + "proposed_field_path": "x.y", + }, + "source": "reasoning", + "caller_session_id": "promotion:snapshot_expand:s1", + }, + ], + ) + + body = await _call_counts() + assert body["counts"]["router_tuning"] == 1 + assert body["counts"]["tool_trimming"] == 2 + assert body["counts"]["probe_fix_envelopes"] == 1 + assert body["counts"]["snapshot_expand"] == 1 + # Actionable total excludes snapshot_expand by definition. + assert body["total_pending"] == 4 + + +# --------------------------------------------------------------------------- +# /api/promotions/snapshot-expand/recent behaviour +# --------------------------------------------------------------------------- + + +async def _call_snapshot_expand_recent() -> dict: + from kora_cli import web_server + + return await web_server.list_recent_snapshot_expand_proposals() + + +@pytest.mark.asyncio +async def test_snapshot_expand_recent_projects_audit_rows(env): + _write_audit_jsonl( + env, + [ + { + "emitted_at": datetime.now(timezone.utc).isoformat(), + "seam": "promotion.snapshot_field_added", + "details": { + "proposal_id": "s1", + "action": "proposed", + "cluster_size": 6, + "proposed_field_path": "tickets.open_count", + "proposed_collector_summary": "count of open IsoKron tickets", + "source_tool_name": "kora__open_tickets", + "sample_caller_session_ids": [ + "slack_dm:U01:T01", + "slack_dm:U01:T02", + ], + "confidence": 0.93, + "created_at": "2026-05-24T10:00:00Z", + }, + "source": "reasoning", + "caller_session_id": "promotion:snapshot_expand:s1", + }, + ], + ) + body = await _call_snapshot_expand_recent() + assert body["loop_name"] == "snapshot_expand" + assert len(body["proposals"]) == 1 + p = body["proposals"][0] + assert p["proposal_id"] == "s1" + assert p["action"] == "proposed" + assert p["proposed_field_path"] == "tickets.open_count" + assert p["source_tool_name"] == "kora__open_tickets" + assert "auto_apply_enabled" in body + + +@pytest.mark.asyncio +async def test_snapshot_expand_auto_apply_flag_echoed(env, monkeypatch): + monkeypatch.setenv("KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY", "true") + body = await _call_snapshot_expand_recent() + assert body["auto_apply_enabled"] is True + + +# --------------------------------------------------------------------------- +# Phrasebook /pending parity — must now include ``loop_name`` +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_phrasebook_pending_response_includes_loop_name(env): + """The multi-loop refactor relies on EVERY /pending endpoint + echoing ``loop_name`` for FE discrimination. Phrasebook was + pre-existing — this test pins the addition.""" + from kora_cli import web_server + + body = await web_server.list_pending_phrasebook_proposals() + assert body["loop_name"] == "phrasebook" + assert "proposals" in body + assert "status_values" in body + + +# --------------------------------------------------------------------------- +# FE page pins +# --------------------------------------------------------------------------- + + +def test_promotion_review_page_renders_all_card_variants(): + """The 6 per-loop card variants must all be declared in + PromotionReviewPage.tsx — a refactor that drops one would + silently break that tab. Pin by component-name search since + each variant is a top-level function in the page module.""" + src = _PAGE.read_text() + for component in ( + "function PhrasebookCard", + "function RouterTuningCard", + "function ToolTrimmingCard", + "function ProbeEnvelopeCard", + "function SnapshotExpandCard", + "function EmailIntentCard", + ): + assert component in src, f"missing card variant: {component}" + + +def test_loop_tabs_render_all_loop_types(): + """LoopTypeTabs iterates the LOOP_TABS array, which must cover + every PROMOTION_LOOP_NAMES entry. The page-level test ensures + each loop has a visible tab; the drift-guard above ensures + the loop names themselves stay in lockstep BE ↔ FE.""" + src = _PAGE.read_text() + for loop in ( + "phrasebook", + "router_tuning", + "tool_trimming", + "probe_fix_envelopes", + "snapshot_expand", + "email_intent", + ): + assert f'loop: "{loop}"' in src, ( + f"LOOP_TABS missing entry for {loop}" + ) + + +def test_high_risk_probe_envelope_visual_treatment(): + """Spec calls for HIGH-RISK visual treatment on the + ProbeEnvelopeCard: red border accent + manual-scaffold + disclaimer. Pin both.""" + src = _PAGE.read_text() + # ``highRisk`` prop flips the CardChrome border to destructive. + assert "highRisk\n status" in src or "highRisk" in src + # The blast-radius treatment + manual-scaffold disclaimer. + assert "HIGH RISK" in src + assert "fix_envelopes.py" in src + assert "blast" in src.lower() + + +def test_snapshot_expand_card_warns_on_auto_apply(): + """When KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY=true the card + must visually flag that the proposal may already be in the + snapshot schema next cycle. Pin the env var name + the + warning copy.""" + src = _PAGE.read_text() + assert "KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY" in src + assert "AUTO-APPLY" in src + + +def test_pending_count_hook_uses_counts_endpoint(): + """usePromotionPendingCount must call the new aggregate + endpoint (not the phrasebook-only /pending) so the badge + reflects all actionable loops.""" + hook_src = ( + _REPO_ROOT / "web" / "src" / "hooks" / "usePromotionPendingCount.ts" + ).read_text() + assert "getPromotionCounts" in hook_src + assert "total_pending" in hook_src + + +def test_api_wrappers_for_generic_loop_endpoints(): + """The generic getPromotionProposals/approve/reject wrappers + must exist alongside the typed phrasebook ones (which keep + the typed override allowlist).""" + src = _API_TS.read_text() + for fn in ( + "getPromotionProposals", + "approvePromotion", + "rejectPromotion", + "getSnapshotExpandPromotions", + "getPromotionCounts", + ): + assert fn in src, f"missing api wrapper: {fn}" diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/_styles.css b/web/docs/promotion-review-multi-loop-extend-megabucket/_styles.css new file mode 100644 index 000000000000..ccc4c4aa3776 --- /dev/null +++ b/web/docs/promotion-review-multi-loop-extend-megabucket/_styles.css @@ -0,0 +1,45 @@ +:root { + --bg: #0a0a0a; --fg: #ededed; --muted: #8b8b8b; + --card: #131313; --border: #2a2a2a; + --success: #4ade80; --warning: #facc15; --destructive: #f87171; + --primary: #60a5fa; --secondary: #3b82f6; +} +body { background: var(--bg); color: var(--fg); + font-family: -apple-system, ui-sans-serif, system-ui, sans-serif; + padding: 16px; max-width: 1100px; margin: 0; } +h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; } +.preamble { color: var(--muted); font-size: 12px; max-width: 760px; + line-height: 1.5; margin-bottom: 12px; } +.card { background: var(--card); border: 1px solid var(--border); + border-radius: 8px; padding: 12px; margin-bottom: 10px; } +.card.success { border-color: rgba(74,222,128,0.3); background: rgba(74,222,128,0.05); } +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.badge { padding: 1px 6px; border: 1px solid var(--border); + border-radius: 4px; font-size: 10px; display: inline-flex; + align-items: center; gap: 4px; font-family: ui-monospace, monospace; } +.badge.success { border-color: rgba(74,222,128,0.4); background: rgba(74,222,128,0.1); color: var(--success); } +.badge.warning { border-color: rgba(250,204,21,0.4); background: rgba(250,204,21,0.1); color: var(--warning); } +.badge.destructive { border-color: rgba(248,113,113,0.4); background: rgba(248,113,113,0.1); color: var(--destructive); } +.badge.secondary { border-color: rgba(96,165,250,0.4); background: rgba(96,165,250,0.1); color: var(--primary); } +.btn { padding: 4px 10px; font-size: 11px; border: 1px solid var(--border); + background: transparent; color: var(--fg); border-radius: 6px; cursor: pointer; + display: inline-flex; align-items: center; gap: 4px; } +.btn.primary { background: var(--primary); border-color: var(--primary); color: #001; } +.btn.outlined { border-color: var(--border); } +.page-header { display: flex; justify-content: space-between; align-items: center; + margin-bottom: 12px; gap: 12px; } +.page-header .title { font-size: 18px; font-weight: 600; } +.summary-chip-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 13px; } +.summary-chip { display: inline-flex; gap: 4px; align-items: center; font-size: 11px; color: var(--muted); } +.summary-chip strong { color: var(--fg); } +.event-row { display: flex; gap: 10px; align-items: flex-start; padding: 4px 0; } +.event-row .mail-icon { color: var(--muted); margin-top: 2px; } +.event-meta { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; font-size: 11px; } +.event-meta .ts { color: var(--muted); } +.event-meta .subject { font-size: 13px; font-weight: 500; } +.event-meta .pattern { font-family: ui-monospace, monospace; padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; font-size: 10px; } +.event-meta .deep-link { color: var(--primary); text-decoration: none; } +.error-line { color: var(--destructive); font-family: ui-monospace, monospace; font-size: 10px; margin-top: 4px; word-break: break-word; } +.italic-muted { color: var(--muted); font-style: italic; } +.sparkline-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +.sparkline-row .label { color: var(--muted); font-size: 11px; } diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.html b/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.html new file mode 100644 index 000000000000..1ebd1ca4f947 --- /dev/null +++ b/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.html @@ -0,0 +1,139 @@ + +Alert Investigations + + +

Alert Investigations · empty + populated (Deliverable B)

+

+ Mirror of ProbeInvestigationsPage but for alerts. Joins + alert.wake_requested + alert.investigation_completed + slack_dm_log + on caller_session_id="alert:{category}:{severity}". + Empty until CC#1's #420 ships the wake consumer; the populated + layout below shows what an active install looks like. +

+ +

Empty state (today — pre-#420)

+ + + +

+ Alert-wake events joined with the per-investigation summary (cost / model / DM status / autoaction) + + operator DM confirmation. Joined on + caller_session_id="alert:{category}:{severity}". Forward-compat for CC#1's #420. +

+ +
+
+ 🔔 0 alert wakes + generated 2026-05-24 14:32:01 +
+
+ +
+
+ All DM statuses + + + +
+
+ +
+
Kora hasn't been woken by alerts in the last 24 hours
+
+ Either no alert escalated the wake threshold, or the alert wake consumer hasn't shipped yet + (forward-compat for CC#1 #420). This page will populate when alert.wake_requested rows + start landing in the audit log. +
+
+ +

Populated layout (preview — once #420 ships)

+ +
+
+ 🔔 3 alert wakes + · + 1 critical + 2 warning +
+
+ +
+
+ 🔔 +
+
+ cost_anomaly + 2026-05-24 14:28:11 + critical + 🚨 auto-action +
+
Daily burn 3x baseline
+
Today: $42; baseline: $14.
+
+
+
+
+
+ 💬 DM sent + ⚡ 🚨 auto-action attempted + claude-haiku-4-5 + $ $0.0021 + · 2.1s + · DM 4m ago +
+
+ Burn spike correlated with a stuck cron retry loop in the IsoKron task syncer. Auto-action + paused the offending cron entry; expecting baseline burn within 1 cycle. +
+
+
+ caller_session_id: alert:cost_anomaly:critical + 🔍 drill +
+
+
+ +
+
+ 🔔 +
+
+ vercel_deploy_failure + 2026-05-24 13:14:00 + warning +
+
Build #2387 failed on main
+
Type error: cannot find name 'PromotionLoopName'.
+
+
+
+
+
+ 💬 DM sent + claude-haiku-4-5 + $ $0.0018 + · 1.4s +
+
+ The error suggests a stale type import after the multi-loop refactor; recommend the operator + rebuild api.ts types or re-run tsc -b. +
+
+
+ caller_session_id: alert:vercel_deploy_failure:warning + 🔍 drill +
+
+
+ + diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.png b/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.png new file mode 100644 index 000000000000..2fab6ed6ff56 Binary files /dev/null and b/web/docs/promotion-review-multi-loop-extend-megabucket/alert_investigations.png differ diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.html b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.html new file mode 100644 index 000000000000..430168c25451 --- /dev/null +++ b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.html @@ -0,0 +1,181 @@ + +Promotion Review · Multi-loop + + + + +

Promotion Review · Multi-loop (Deliverable A)

+

+ PromotionReviewPage now hosts 6 loop variants via tab navigation + instead of phrasebook-only. Per-loop card variants render the + distinct payload shape each loop emits (CC#1 #186/#193); the + aggregate /api/promotions/counts endpoint feeds the + badge counts in one round-trip. +

+ + + + +
+
+
📖Phrasebook3
+
📊Router2
+
🔧Tools1
+
🛡Envelopes1
+
👁Snapshot4i
+
Email
+
+
+ +

+ Per-route escalation-rate analysis — proposes which routes should tighten or loosen their trigger + pattern (operator scaffolds the prompt change after approve). +

+ + +
+
+ 2 pending + · + 8 approved this cycle + · + 1 rejected +
+
+ + + + + All +
+
+ + +
+
+ 0.92 confidence + route: status_query + ↓ tighten review + · + created 4h ago + + pending +
+
+ calls: 847 + escalations: 312 + rate: 36.8% + cost: $3.21 +
+
+
rationale
+
Escalation rate is 4.2x the cohort median — the trigger pattern may be matching too eagerly on cost-shaped questions that have a snapshot answer. Tightening to exclude bare "?" should drop Opus calls without affecting recall.
+
+
+ Approve emits a promotion.approved audit row only — operator scaffolds the actual trigger-pattern change in the router prompt. +
+
+ + +
+
+ + +
+
+ 0.88 confidence + route: slack_dm + 4 unused tools + · + created 6h ago + + pending +
+
+ Observed 1,243 calls over 7 days. None of the tools below were invoked in that window — dropping them from the manifest would shave prompt tokens + escalation surface for this route. +
+
+ ▾ 4 unused tools +
+ kora__open_pr + kora__merge_pr + kora__send_email_to_operator + kora__create_sea_ticket +
+
+
+ + +
+
+ 🛡 HIGH RISK + 0.91 confidence + probe: doppler + secrets_drift + · + created 1d ago + + pending +
+
+
fix_name suggestion
+
resync_doppler_to_baseline
+
+
+
recurring recommendation
+
Across 5 of the last 6 doppler/secrets_drift investigations, Kora's recommendation was to re-sync the staging baseline. Pre-approving this as an envelope action would let Kora attempt it without operator round-trip.
+
+
+
🛡 Blast-radius summary
+
Action mutates staging Doppler secrets — affects every fly machine + downstream service consuming staging credentials. Failure surface: token race with manual operator edits, baseline drift if --force is used.
+
+
+ Approving does NOT mutate probes/fix_envelopes.py. Operator must manually scaffold the envelope using this proposal as the spec; the approved/ proposal file is the audit trail for when the scaffold lands. +
+
+ + +
+
+ + +
+
+ 0.94 confidence + cluster of 8 + · + emitted 3h ago + + proposed +
+
+
proposed snapshot field
+
snapshot.tickets.open_count
+
+
+
collector summary
+
Count of open IsoKron Sea_Tickets (status != closed). Polled via the IsoKron read API; throttled to once per cycle.
+
+
+ Inferred from kora__open_tickets tool calls — adding this field would short-circuit those calls at $0 LLM cost. +
+
+ KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY=true — this loop is currently AUTO-APPLY ON. The proposed field may already be in the snapshot schema next cycle. +
+
+ + diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.png b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.png new file mode 100644 index 000000000000..878d62a0b42c Binary files /dev/null and b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_multi_loop.png differ diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.html b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.html new file mode 100644 index 000000000000..4199ce7cc5bb --- /dev/null +++ b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.html @@ -0,0 +1,131 @@ + +PhrasebookCard + EmailIntentCard variants + + + + +

PhrasebookCard + EmailIntentCard variants (Deliverable A · 2 of 6)

+

+ Two more loop variants: the established PhrasebookCard (full edit- + before-approve + SnapshotPreview, preserved from #194), and the + forward-compat EmailIntentCard mirror for CC#1's #420. +

+ + +
+
+
📖Phrasebook3
+
📊Router2
+
🔧Tools1
+
🛡Envelopes1
+
👁Snapshot4i
+
Email
+
+
+ + +
+
+ 0.94 confidence + cluster of 6 + · + created 2h ago + ✨ Kora wrote this + + pending +
+
+
category
+
cost_query
+
+
+
pattern
+
(?i)(burn|cost|spend)
+
+
+
reply template
+
Burn is ${'{snapshot.cost_ladder.spent_to_date_usd}'} today.
+
+ +
+
+ 👁 Live preview against current snapshot + snapshot 42s ago +
+
Burn is $42.13 today.
+
✓ All 1 placeholders interpolate cleanly.
+
+
+ + + +
+
+ + +

Email tab — forward-compat for #420

+ +
+
+
📖Phrasebook3
+
📊Router2
+
🔧Tools1
+
🛡Envelopes1
+
👁Snapshot4i
+
Email
+
+
+ +

+ Operator-DM clusters of email-shaped intents Kora missed at high confidence. Forward-compat: + the BE loop lands with CC#1's #420 — empty here until then. +

+ + +
+
No Email proposals yet — forward-compat lens
+
+ The Email loop ships with CC#1's #420. This tab is forward-compat: + it will populate cleanly once the BE plumbing lands; until then, + expect zero proposals. +
+
+ +

Sample EmailIntentCard shape (rendered when #420 starts emitting)

+ +
+
+ 0.86 confidence + cluster of 4 + ✉ Email intent + · + created (sample) + + pending +
+
+
category
+
forward_to_save
+
+
+
pattern
+
(?i)(save this|interesting read|fwd:.*article)
+
+
+ sample emails +
    +
  • "Save this article about long-context reasoning"
  • +
  • "Fwd: Anthropic's prompt-caching post — interesting read"
  • +
  • "Save: notes from today's offsite planning"
  • +
+
+
+ + diff --git a/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.png b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.png new file mode 100644 index 000000000000..bc9df2e8c0e3 Binary files /dev/null and b/web/docs/promotion-review-multi-loop-extend-megabucket/promotion_review_phrasebook_email.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 78585a4b3929..04be8d04bc68 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import { Activity, AlertTriangle, BarChart3, + BellRing, BookOpen, BookOpenCheck, Brain, @@ -110,6 +111,7 @@ import PromotionReviewPage from "@/pages/PromotionReviewPage"; import EmailLoggedOnlyAnalyzerPage from "@/pages/EmailLoggedOnlyAnalyzerPage"; import InvestigationDrillDownPage from "@/pages/InvestigationDrillDownPage"; import ProbeInvestigationsPage from "@/pages/ProbeInvestigationsPage"; +import AlertInvestigationsPage from "@/pages/AlertInvestigationsPage"; import CapabilitiesPage from "@/pages/CapabilitiesPage"; import CharterPage from "@/pages/CharterPage"; import KoraControlPage from "@/pages/KoraControlPage"; @@ -180,6 +182,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/phrasebook": PhrasebookPage, "/promotions/phrasebook": PromotionReviewPage, "/probe-investigations": ProbeInvestigationsPage, + "/alert-investigations": AlertInvestigationsPage, "/email-intent-log/logged-only": EmailLoggedOnlyAnalyzerPage, // KR-FE-INVESTIGATION-DRILL-DOWN — drill into the unified // per-caller_session_id timeline. ``:callerSessionId`` is a path @@ -269,6 +272,18 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Probe Investigations", icon: Sparkles, }, + { + // KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) — + // sits adjacent to probe-investigations since the operator-flow + // mirrors it: alerts wake the reasoning engine the same way + // probes do. caller_session_id pattern is + // ``alert:{category}:{severity}``. Empty until CC#1's #420 + // ships the alert wake consumer. + path: "/alert-investigations", + labelKey: "alertInvestigations", + label: "Alert Investigations", + icon: BellRing, + }, { path: "/webhook-events", labelKey: "webhookEvents", diff --git a/web/src/hooks/usePromotionPendingCount.ts b/web/src/hooks/usePromotionPendingCount.ts index 8828b80ba285..734e2b867e0a 100644 --- a/web/src/hooks/usePromotionPendingCount.ts +++ b/web/src/hooks/usePromotionPendingCount.ts @@ -1,13 +1,18 @@ // KR-FE-PROMOTION-REVIEW-PANEL — sidebar PendingBadge data source. // -// Polls /api/promotions/phrasebook/pending every 60s and returns the -// count of proposals (BE returns pending-only today). Returns null -// while loading or on persistent failure — the SidebarNavLink skips -// the chip rather than rendering "?". +// Polls /api/promotions/counts every 60s and returns the total +// pending count across all actionable loops (excludes +// snapshot_expand per the BE's aggregate definition — that loop +// is informational only). Returns null while loading or on +// persistent failure — the SidebarNavLink skips the chip rather +// than rendering "?". // -// 60s cadence is the same pattern the rest of the cockpit uses for -// no-WebSocket polling (cost-state, kora-actions). The endpoint is -// fast (file-backed JSON read of pending/ directory). +// KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND update: the badge now +// reflects all actionable loops at once (phrasebook + router- +// tuning + tool-trimming + probe-envelopes — and email-intent +// when CC#1's #420 lands). Pre-extension it polled the phrasebook +// /pending endpoint directly; the counts endpoint added in this +// bucket aggregates server-side so we still do one round-trip. import { useEffect, useState } from "react"; @@ -23,9 +28,9 @@ export function usePromotionPendingCount(): number | null { async function poll(): Promise { try { - const resp = await api.getPhrasebookPromotionProposals(); + const resp = await api.getPromotionCounts(); if (cancelled) return; - setCount(resp.proposals.length); + setCount(resp.total_pending); } catch { // Best-effort: keep the existing count on transient failure // (typically a one-off restart) rather than flicker null. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index eb03db850ee0..cc40fa8b2101 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -257,6 +257,22 @@ export const api = { fetchJSON( `/api/investigations/${encodeURIComponent(callerSessionId)}`, ), + // KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) — mirror + // of getProbeInvestigations for the alert-wake variant. Returns + // ``items=[]`` cleanly until #420's alert wake consumer starts + // writing the alert seams. + getAlertInvestigations: (opts?: { + window?: "24h" | "7d" | "all"; + limit?: number; + }) => { + const qs = new URLSearchParams(); + if (opts?.window) qs.set("window", opts.window); + if (opts?.limit !== undefined) qs.set("limit", String(opts.limit)); + const q = qs.toString(); + return fetchJSON( + `/api/alert-investigations${q ? "?" + q : ""}`, + ); + }, // KR-FE-PROMOTION-REVIEW-PANEL — list pending phrasebook promotion // proposals (sorted highest-confidence first by the BE). getPhrasebookPromotionProposals: () => @@ -289,6 +305,55 @@ export const api = { body: JSON.stringify({ review_notes: reviewNotes }), }, ), + // KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND — generic per-loop + // wrappers (CC#1's #186/#193 establishment of the + // /api/promotions//pending|approve|reject pattern). + // ``loop`` is the URL slug (hyphenated form: "router-tuning", + // "tool-trimming", "probe-envelopes"). For phrasebook the + // typed wrappers above stay as the canonical entrypoint — + // they enforce the override field allowlist + return the + // phrasebook-specific approve response shape. + getPromotionProposals: (loopSlug: string) => + fetchJSON( + `/api/promotions/${encodeURIComponent(loopSlug)}/pending`, + ), + approvePromotion: ( + loopSlug: string, + proposalId: string, + reviewNotes?: string, + ) => + fetchJSON( + `/api/promotions/${encodeURIComponent(loopSlug)}/${encodeURIComponent(proposalId)}/approve`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(reviewNotes ? { review_notes: reviewNotes } : {}), + }, + ), + rejectPromotion: ( + loopSlug: string, + proposalId: string, + reviewNotes: string, + ) => + fetchJSON( + `/api/promotions/${encodeURIComponent(loopSlug)}/${encodeURIComponent(proposalId)}/reject`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ review_notes: reviewNotes }), + }, + ), + // Snapshot-expand is audit-derived (no /pending lifecycle) — + // read the most recent ``promotion.snapshot_field_added`` rows + // for the informational card variant. + getSnapshotExpandPromotions: () => + fetchJSON( + "/api/promotions/snapshot-expand/recent", + ), + // Aggregate counts for the tab navigation — one round-trip + // instead of N fan-out reads. + getPromotionCounts: () => + fetchJSON("/api/promotions/counts"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -2344,11 +2409,17 @@ export interface ProbeAutofixEventsResponse { // PR #186 promotion-loop audit rows in the timeline. The // ``investigation_completed`` row already existed (PR #184 made it // productive — see PROBE-INVESTIGATION-DATA-COMPLETION). +// +// KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420): +// ``alert_investigation_completed`` surfaces alert-side investigation +// rows. Today zero rows; once CC#1's #420 ships the alert wake +// consumer the timeline populates automatically. export type KoraActionCategory = | "email_sent" | "sea_ticket_created" | "autofix_attempted" | "investigation_completed" + | "alert_investigation_completed" | "phrasebook_proposal_approved" | "promotion_proposed" | "promotion_approved" @@ -2360,6 +2431,7 @@ export const KORA_ACTION_CATEGORIES: readonly KoraActionCategory[] = [ "sea_ticket_created", "autofix_attempted", "investigation_completed", + "alert_investigation_completed", "phrasebook_proposal_approved", "promotion_proposed", "promotion_approved", @@ -2502,6 +2574,57 @@ export interface ProbeInvestigationsResponse { by_dm_status_24h: Record; } +// KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) — mirror +// of probe investigations for alert-driven wakes. The wake + +// investigation_completed seams + caller_session_id substrate match +// the probe pattern; CC#1 #420 ships the emitter. ``AlertSeverity`` +// is already exported above for the alerts panel — we don't +// re-export here; the alert-investigations payload uses ``string`` +// since the wake-emitter may produce sentinel values like +// ``"unknown"`` for malformed rows. + +export interface AlertInvestigationCompleted { + emitted_at: string; + summary_text: string; + model_used: string | null; + total_cost_usd: number | null; + investigation_duration_ms: number | null; + // dm_status uses the same 4-value enum as probe investigations. + // The FE PROBE_DM_STATUS_VALUES constant is the source of truth. + dm_status: ProbeDmStatus; + // Alert variant of ``autofix_attempted`` — generic alert-driven + // action flag (false in v1; forward-compat for future + // alert-driven fix envelopes). + autoaction_attempted: boolean; + reasoning_error: string | null; +} + +export interface AlertInvestigationItem { + wake_event_id: string; + wake_timestamp: string; + alert_category: string; + severity: string; + title: string; + detail: string; + caller_session_id: string; + investigation_completed: AlertInvestigationCompleted | null; + dm_entry: ProbeInvestigationDmEntry | null; +} + +export interface AlertInvestigationsResponse { + window: "24h" | "7d" | "all"; + since: string | null; + generated_at: string; + total_count: number; + items: AlertInvestigationItem[]; + by_severity_24h: Record; + by_dm_status_24h: Record; + // Echoed canonical allowlist — paired with the FE + // PROBE_DM_STATUS_VALUES constant (alert investigations reuse + // the probe dm_status enum verbatim). + dm_status_values: string[]; +} + // KR-FE-PROMOTION-REVIEW-PANEL — phrasebook promotion proposal // shape (mirror of kora_cli/promote/phrasebook/proposer.py // :PromotionProposal serialized via proposal_to_dict). The four @@ -2520,7 +2643,48 @@ export const PROMOTION_STATUS_VALUES: readonly PromotionStatus[] = [ "expired", ]; -export interface PromotionProposal { +// KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND — canonical loop-type +// discriminator. Mirrors BE _PROMOTION_LOOP_TYPES in web_server.py; +// drift-guarded by test_promotion_loop_types_drift_guard. Order +// matches the FE tab order (phrasebook first since it's the +// established loop; ``email_intent`` last as it's forward-compat +// for CC#1's #420). +export type PromotionLoopName = + | "phrasebook" + | "router_tuning" + | "tool_trimming" + | "probe_fix_envelopes" + | "snapshot_expand" + | "email_intent"; + +export const PROMOTION_LOOP_NAMES: readonly PromotionLoopName[] = [ + "phrasebook", + "router_tuning", + "tool_trimming", + "probe_fix_envelopes", + "snapshot_expand", + "email_intent", +]; + +// URL-slug form of the loop name (hyphenated). Used as the path +// component in /api/promotions//... endpoints. The mapping +// is one-way (FE → BE) — the BE response always carries +// underscored ``loop_name`` per the proposer dataclass naming. +export const PROMOTION_LOOP_SLUGS: Record = { + phrasebook: "phrasebook", + router_tuning: "router-tuning", + tool_trimming: "tool-trimming", + probe_fix_envelopes: "probe-envelopes", + snapshot_expand: "snapshot-expand", + email_intent: "email-intent", +}; + +// Per-loop proposal shapes. Each loop's proposal payload is +// distinct (see kora_cli/promote//proposer.py). FE uses +// discriminator-narrowed types so each Card variant gets the +// exact fields it needs without `any`. + +export interface PhrasebookProposalPayload { proposal_id: string; cluster_size: number; sample_questions: string[]; @@ -2535,12 +2699,102 @@ export interface PromotionProposal { haiku_synthesized: boolean; } +export interface RouterTuningProposalPayload { + proposal_id: string; + route: string; + calls_count: number; + escalation_count: number; + escalation_rate: number; // 0..1 + cost_estimate_usd_total: number; + recommendation_kind: "tighten_review" | "loosen_review"; + rationale: string; + confidence: number; + created_at: string; + status: PromotionStatus; + review_notes: string; +} + +export interface ToolTrimProposalPayload { + proposal_id: string; + route: string; + unused_tools: string[]; + total_calls_for_route: number; + observation_window_days: number; + confidence: number; + created_at: string; + status: PromotionStatus; + review_notes: string; +} + +export interface ProbeEnvelopeProposalPayload { + proposal_id: string; + probe: string; + issue_category: string; + fix_name_suggestion: string; + cluster_size: number; + sample_caller_session_ids: string[]; + recurring_recommendation_text: string; + blast_radius_summary: string; + confidence: number; + created_at: string; + status: PromotionStatus; + review_notes: string; +} + +// Snapshot-expand is audit-derived (no /pending lifecycle); the +// FE card variant is read-only + flags AUTO-APPLY mode when on. +export interface SnapshotExpandRecentProposal { + proposal_id: string; + action: "proposed" | "auto_applied" | string; + cluster_size: number | null; + proposed_field_path: string; + proposed_collector_summary: string; + source_tool_name: string; + sample_caller_session_ids: string[]; + confidence: number | null; + created_at: string; + emitted_at: string; +} + +export interface SnapshotExpandPromotionsResponse { + proposals: SnapshotExpandRecentProposal[]; + loop_name: "snapshot_expand"; + auto_apply_enabled: boolean; +} + +// Forward-compat for CC#1's #420 — email-intent loop shape mirrors +// phrasebook (cluster_size + sample emails + pattern + category). +// FE renders the card the same way phrasebook does today; the +// loop's BE plumbing fills in beneath. Once #420 lands and the +// shape is finalized, this type widens to whatever the proposer +// emits — leave a future-compat note on the card variant. +export interface EmailIntentProposalPayload { + proposal_id: string; + cluster_size: number; + sample_emails: string[]; + proposed_pattern: string; + proposed_category: string; + confidence: number; + created_at: string; + status: PromotionStatus; + review_notes: string; +} + +// PromotionProposal — backward-compat alias for the original phrasebook +// shape (existing PromotionReviewPage consumers). New per-loop code +// uses the discriminated types above. +export type PromotionProposal = PhrasebookProposalPayload; + export interface PromotionProposalsResponse { proposals: PromotionProposal[]; // Echoed from the BE so the FE doesn't need to hardcode the list // a SECOND time — single source of truth at the wire. The // drift-guard test pins both BE source + FE constant. status_values: string[]; + // KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND — loop discriminator + // echoed by EVERY /api/promotions//pending response per + // the BE symmetry added in this bucket. + loop_name?: string; } export interface PromotionApproveOverrides { @@ -2569,6 +2823,27 @@ export interface PromotionRejectResponse { review_notes: string; } +// Shape returned by the shared approve/reject endpoints for the +// non-phrasebook loops (router-tuning / tool-trimming / probe- +// envelopes). No `committed_entry` field — those loops do not +// mutate live config at approve-time (operator scaffolds manually). +export interface PromotionGenericTransitionResponse { + proposal_id: string; + status: "approved" | "rejected"; + review_notes: string; +} + +export interface PromotionCountsResponse { + // Map of loop_name → pending count. Counts for backward-compat: + // ``snapshot_expand`` reports the count of recent (24h) + // ``promotion.snapshot_field_added`` audit rows since that loop + // has no /pending semantics. + counts: Record; + // Sum across actionable loops (excludes snapshot_expand). + total_pending: number; + loop_names: string[]; +} + // KR-FE-INVESTIGATION-DRILL-DOWN — unified per-caller_session_id // timeline. ``kind`` discriminates the per-seam ``details`` // payload shape on the FE; ``details`` is intentionally diff --git a/web/src/pages/AlertInvestigationsPage.tsx b/web/src/pages/AlertInvestigationsPage.tsx new file mode 100644 index 000000000000..2152c7ca880b --- /dev/null +++ b/web/src/pages/AlertInvestigationsPage.tsx @@ -0,0 +1,485 @@ +// Alert investigations xref viewer — KR-FE-ALERT-INVESTIGATIONS-VIEWER +// (forward-compat for CC#1 #420). +// +// Mirror of ProbeInvestigationsPage but for alerts: joins +// alert.wake_requested + alert.investigation_completed audits with +// the slack_dm_log.jsonl entry by caller_session_id +// ``alert:{category}:{severity}``. Today none of these rows exist +// (CC#1 #420 ships the emitter); the page renders an empty state +// cleanly until then. +// +// Each card has a "drill" link to the InvestigationDrillDown page +// (introduced in #194) — same UX as the probe variant. +// +// Drift-guard: PROBE_DM_STATUS_VALUES (api.ts) ↔ _DM_STATUS_VALUES +// (web_server.py) is reused via _ALERT_DM_STATUS_VALUES alias so +// alert + probe share the same source-of-truth tuple. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + AlertCircle, + AlertTriangle, + Bell, + BellRing, + DollarSign, + HelpCircle, + Info, + MailX, + MessageSquare, + RefreshCw, + Search, + Sparkles, + Zap, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { usePanelView } from "@/hooks/usePanelView"; +import { api } from "@/lib/api"; +import { + PROBE_DM_STATUS_VALUES, + type AlertInvestigationItem, + type AlertInvestigationsResponse, + type ProbeDmStatus, +} from "@/lib/api"; +import { + FilterChips, + formatDurationMs, + formatRelative, + formatTimestamp, + type BadgeTone, + type CategoryDef, + type FilterValue, +} from "@/components/AuditPanelKit"; + +type Window = "24h" | "7d" | "all"; + +const WINDOW_LABELS: Record = { + "24h": "24h", + "7d": "7d", + all: "All", +}; + +// Reuses the probe dm_status enum since both wake consumers route +// through the same DM-dispatch path (per the BE _ALERT_DM_STATUS_ +// VALUES alias). +const DM_STATUS_CATEGORIES: readonly CategoryDef[] = [ + { key: "failed_send", label: "Failed", tone: "destructive", Icon: MailX }, + { + key: "engine_unavailable_failed_send", + label: "Engine unavail. + failed", + tone: "destructive", + Icon: AlertTriangle, + }, + { key: "sent", label: "Sent", tone: "success", Icon: MessageSquare }, + { + key: "engine_unavailable_fallback", + label: "Engine unavail. (fallback)", + tone: "warning", + Icon: AlertCircle, + }, + { key: "unknown", label: "Unknown", tone: "outline", Icon: HelpCircle }, +]; + +function severityVisual(severity: string): { + Icon: typeof AlertTriangle; + tone: BadgeTone; + label: string; +} { + if (severity === "critical") + return { Icon: AlertCircle, tone: "destructive", label: "critical" }; + if (severity === "warning") + return { Icon: AlertTriangle, tone: "warning", label: "warning" }; + return { Icon: Info, tone: "outline", label: severity || "info" }; +} + +function dmStatusVisual(status: ProbeDmStatus | "unknown"): { + tone: BadgeTone; + label: string; +} { + if (status === "sent") return { tone: "success", label: "DM sent" }; + if (status === "failed_send") + return { tone: "destructive", label: "DM failed" }; + if (status === "engine_unavailable_fallback") + return { tone: "warning", label: "Engine unavail. → fallback DM" }; + if (status === "engine_unavailable_failed_send") + return { tone: "destructive", label: "Engine unavail. + DM failed" }; + return { tone: "outline", label: "DM unknown" }; +} + +function toneCardBorderClass(tone: BadgeTone): string { + if (tone === "destructive") return "border-destructive/40"; + if (tone === "warning") return "border-yellow-500/40"; + if (tone === "success") return "border-green-500/40"; + return ""; +} + +function formatCostUSD(usd: number | null): string { + if (usd === null) return "—"; + if (usd < 0.0001) return "<$0.0001"; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + +function AlertCompletedSummary({ item }: { item: AlertInvestigationItem }) { + const ic = item.investigation_completed; + if (ic === null) return null; + const dm = dmStatusVisual(ic.dm_status); + return ( +
+
+ {dm.label} + {ic.autoaction_attempted && ( + + + 🚨 auto-action attempted + + )} + {ic.model_used && ( + + {ic.model_used} + + )} + {ic.total_cost_usd !== null && ( + + + {formatCostUSD(ic.total_cost_usd)} + + )} + {ic.investigation_duration_ms !== null && ( + + · {formatDurationMs(ic.investigation_duration_ms)} + + )} + {item.dm_entry && item.dm_entry.sent_at && ( + + · DM {formatRelative(item.dm_entry.sent_at)} + + )} +
+ {ic.summary_text && ( +
+ {ic.summary_text} +
+ )} + {ic.reasoning_error && ( +
+ reasoning_error: {ic.reasoning_error} +
+ )} +
+ ); +} + +function AlertInvestigationCard({ item }: { item: AlertInvestigationItem }) { + const sev = severityVisual(item.severity); + const autoaction = item.investigation_completed?.autoaction_attempted === true; + + return ( + + +
+ +
+
+ + {item.alert_category} + + + {formatTimestamp(item.wake_timestamp)} + + {sev.label} + {autoaction && ( + + + 🚨 auto-action + + )} +
+ {item.title && ( +
{item.title}
+ )} + {item.detail && ( +
+ {item.detail} +
+ )} +
+
+ +
+ +
+ + caller_session_id: {item.caller_session_id} + + + + drill + +
+
+
+
+ ); +} + +function EmptyState({ + window, + filterApplied, + onReset, +}: { + window: Window; + filterApplied: boolean; + onReset: () => void; +}) { + const windowLabel = + window === "all" + ? "in any window" + : window === "7d" + ? "in the last 7 days" + : "in the last 24 hours"; + if (filterApplied) { + return ( + + + + No alert investigations match the current DM-status filter{" "} + {windowLabel}.{" "} + + + + ); + } + return ( + + + +

+ Kora hasn't been woken by alerts {windowLabel} +

+

+ Either no alert escalated the wake threshold, or the alert wake + consumer hasn't shipped yet (forward-compat for CC#1 + #420). This page will populate when alert.wake_requested rows + start landing in the audit log. +

+
+
+ ); +} + +function SummaryHeader({ data }: { data: AlertInvestigationsResponse }) { + const totalCritical = data.by_severity_24h.critical ?? 0; + const totalWarning = data.by_severity_24h.warning ?? 0; + const totalInfo = data.by_severity_24h.info ?? 0; + return ( + + +
+
+ + {data.total_count} + alert wakes +
+ {data.total_count > 0 && ( + <> + · +
+ + {totalCritical} + critical +
+
+ + {totalWarning} + warning +
+ {totalInfo > 0 && ( +
+ + {totalInfo} + info +
+ )} + + )} + + generated {formatTimestamp(data.generated_at)} + +
+
+
+ ); +} + +export default function AlertInvestigationsPage() { + usePanelView("AlertInvestigationsPage"); + + const [window, setWindow] = useState("24h"); + const [dmStatusFilter, setDmStatusFilter] = useState>("all"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (w: Window) => { + setLoading(true); + setError(null); + try { + const resp = await api.getAlertInvestigations({ window: w }); + setData(resp); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(window); + }, [load, window]); + + // Drift-guard grep — pins the constant import so + // test_alert_dm_status_drift_guard catches a rename on either + // side. Alert + probe share the same source-of-truth tuple + // (PROBE_DM_STATUS_VALUES); the alert page imports it directly. + void PROBE_DM_STATUS_VALUES; + + const filteredItems = useMemo(() => { + if (data === null) return []; + if (dmStatusFilter === "all") return data.items; + return data.items.filter((it) => { + const ic = it.investigation_completed; + if (ic === null) return false; + return ic.dm_status === dmStatusFilter; + }); + }, [data, dmStatusFilter]); + + return ( +
+
+

+ + Alert Investigations +

+
+
+ {(["24h", "7d", "all"] as Window[]).map((w) => ( + + ))} +
+ +
+
+ +

+ Alert-wake events joined with the per-investigation summary + (cost / model / DM status / autoaction) + operator DM + confirmation. Joined on{" "} + caller_session_id="alert:{"{category}"}:{"{severity}"}". + Forward-compat surface for CC#1's #420 — once the alert + wake consumer ships, the seam rows populate automatically + and this page lights up. +

+ + {loading && data === null && ( +
+ +
+ )} + + {error && ( + + + +
+ Failed to load alert investigations:{" "} + {error} +
+
+
+ )} + + {data && ( + <> + + + + + + + {filteredItems.length === 0 ? ( + setDmStatusFilter("all")} + /> + ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} + + )} +
+ ); +} + diff --git a/web/src/pages/KoraActionsPage.tsx b/web/src/pages/KoraActionsPage.tsx index 105633d167a6..e069e3a5f3ec 100644 --- a/web/src/pages/KoraActionsPage.tsx +++ b/web/src/pages/KoraActionsPage.tsx @@ -28,6 +28,7 @@ import { Activity, AlertCircle, AlertTriangle, + BellRing, BookOpen, CheckCircle2, ExternalLink, @@ -94,6 +95,13 @@ const KORA_ACTION_CATEGORIES_DEFS: readonly CategoryDef[] = tone: "outline", Icon: Search, }, + { + // KR-FE-ALERT-INVESTIGATIONS-VIEWER (forward-compat #420) + key: "alert_investigation_completed", + label: "Alert investigation completed", + tone: "warning", + Icon: BellRing, + }, { key: "phrasebook_proposal_approved", label: "Phrasebook proposal", @@ -134,11 +142,12 @@ const KORA_ACTION_CATEGORIES_MAP: Record< sea_ticket_created: KORA_ACTION_CATEGORIES_DEFS[1], autofix_attempted: KORA_ACTION_CATEGORIES_DEFS[2], investigation_completed: KORA_ACTION_CATEGORIES_DEFS[3], - phrasebook_proposal_approved: KORA_ACTION_CATEGORIES_DEFS[4], - promotion_proposed: KORA_ACTION_CATEGORIES_DEFS[5], - promotion_approved: KORA_ACTION_CATEGORIES_DEFS[6], - promotion_rejected: KORA_ACTION_CATEGORIES_DEFS[7], - other: KORA_ACTION_CATEGORIES_DEFS[8], + alert_investigation_completed: KORA_ACTION_CATEGORIES_DEFS[4], + phrasebook_proposal_approved: KORA_ACTION_CATEGORIES_DEFS[5], + promotion_proposed: KORA_ACTION_CATEGORIES_DEFS[6], + promotion_approved: KORA_ACTION_CATEGORIES_DEFS[7], + promotion_rejected: KORA_ACTION_CATEGORIES_DEFS[8], + other: KORA_ACTION_CATEGORIES_DEFS[9], }; // ----- Per-row card ----- diff --git a/web/src/pages/PromotionReviewPage.tsx b/web/src/pages/PromotionReviewPage.tsx index f33e63db1c7a..b732b77f0749 100644 --- a/web/src/pages/PromotionReviewPage.tsx +++ b/web/src/pages/PromotionReviewPage.tsx @@ -1,47 +1,46 @@ -// KR-FE-PROMOTION-REVIEW-PANEL — operator-approval UX for the -// Kora-generated phrasebook promotion proposals (PR #186). +// KR-FE-PROMOTION-REVIEW-PANEL — operator-approval UX for Kora's +// promotion loops. // -// Reads: GET /api/promotions/phrasebook/pending -// Writes: POST /api/promotions/phrasebook/{id}/approve (+ optional -// pattern_override / reply_template_override / -// category_override / review_notes) -// POST /api/promotions/phrasebook/{id}/reject (review_notes) +// KR-FE-PROMOTION-REVIEW-MULTI-LOOP-EXTEND (this bucket): page now +// hosts 6 loop variants via tab navigation rather than phrasebook- +// only. Each tab calls the loop's /pending endpoint; per-loop card +// variants render the proposal payload shape the operator needs to +// review intelligently. Counts come from /api/promotions/counts +// (one round-trip instead of fan-out). // -// KR-FE-PROMOTION-PREVIEW extension: each ProposalCard now renders -// the reply_template against the LIVE snapshot — operator sees the -// actual reply text Kora would send if the entry were live, plus -// inline warnings for any placeholders that won't interpolate -// (fall-through to reasoning). Re-renders on edit so the operator -// can iteratively tighten the template before approving. Backed -// by /api/phrasebook/slack_dm/preview-template (new in this -// bucket — takes just the template, no test text needed). +// Loop variants: +// * phrasebook — full edit-before-approve + SnapshotPreview +// * router-tuning — approve/reject + rationale +// * tool-trimming — collapsible unused-tools list + approve/reject +// * probe-envelopes — HIGH-RISK red border + manual-scaffold disclaimer +// * snapshot-expand — informational (audit-derived; no approve flow) +// * email-intent — forward-compat for CC#1's #420 (renders +// empty-but-ready when the BE plumbing lands) // -// Layout (per CC#1's pre-spec'd shape in PR #186): -// -// Filter: [Pending] [Approved] [Rejected] [All] -// Summary band: pending count + last 14d daily-proposals sparkline -// Per-row card (sorted by confidence desc): -// confidence + cluster_size + created_at + haiku_synthesized badge -// category / pattern / reply_template (read mode) OR editable inputs -// SnapshotPreview band: rendered reply + missing-field warnings -// sample_questions (up to 3) -// [Edit before approving] [Approve] [Reject (with notes)] -// -// Drift-guard: PROMOTION_STATUS_VALUES (api.ts) mirrors BE -// _PROMOTION_STATUS_VALUES. Pinned by test_promotion_status_drift_guard. +// Drift-guard pins: +// * PROMOTION_STATUS_VALUES ↔ BE _PROMOTION_STATUS_VALUES +// * PROMOTION_LOOP_NAMES ↔ BE _PROMOTION_LOOP_TYPES (new this bucket) import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { + Activity, AlertCircle, AlertTriangle, + ArrowDownNarrowWide, + ArrowUpWideNarrow, + BookOpen, CheckCircle2, Eye, + Inbox, Lightbulb, + Mail, RefreshCw, Send, + ShieldAlert, Sparkles, Wand2, + Wrench, X, XCircle, } from "lucide-react"; @@ -53,12 +52,22 @@ import { Card, CardContent } from "@/components/ui/card"; import { usePanelView } from "@/hooks/usePanelView"; import { api } from "@/lib/api"; import { + PROMOTION_LOOP_NAMES, + PROMOTION_LOOP_SLUGS, PROMOTION_STATUS_VALUES, + type EmailIntentProposalPayload, type PhrasebookPreviewTemplateResponse, + type PhrasebookProposalPayload, + type ProbeEnvelopeProposalPayload, type PromotionApproveOverrides, - type PromotionProposal, + type PromotionCountsResponse, + type PromotionLoopName, type PromotionProposalsResponse, type PromotionStatus, + type RouterTuningProposalPayload, + type SnapshotExpandPromotionsResponse, + type SnapshotExpandRecentProposal, + type ToolTrimProposalPayload, } from "@/lib/api"; import { EmptyFilteredMessage, @@ -95,26 +104,93 @@ const STATUS_CATEGORIES: readonly CategoryDef[] = [ }, ]; -function formatConfidence(c: number): string { - // BE writes confidence to 4 decimals; render to 2 — enough - // signal for "much better than 0.85" without false precision. +interface LoopTabDef { + loop: PromotionLoopName; + label: string; + Icon: typeof Sparkles; + /** Short human-readable description of what this loop proposes. */ + blurb: string; + /** True when the loop is informational only (snapshot_expand). */ + readOnly?: boolean; + /** True when the BE plumbing may not be live yet (email_intent). */ + forwardCompat?: boolean; +} + +const LOOP_TABS: readonly LoopTabDef[] = [ + { + loop: "phrasebook", + label: "Phrasebook", + Icon: BookOpen, + blurb: + "Clusters of operator DMs that look answerable from the live snapshot — Kora proposes a regex + reply_template to short-circuit them at $0.", + }, + { + loop: "router_tuning", + label: "Router", + Icon: Activity, + blurb: + "Per-route escalation-rate analysis — proposes which routes should tighten or loosen their trigger pattern (operator scaffolds the prompt change after approve).", + }, + { + loop: "tool_trimming", + label: "Tools", + Icon: Wrench, + blurb: + "Per-route tool-usage observation — identifies tools that haven't been called in the window so the tool manifest can drop them (saves prompt tokens + escalation surface).", + }, + { + loop: "probe_fix_envelopes", + label: "Envelopes", + Icon: ShieldAlert, + blurb: + "HIGH-RISK — recurring probe-investigation patterns Kora thinks could become autofix envelopes. Approve only after manually scaffolding probes/fix_envelopes.py to match.", + }, + { + loop: "snapshot_expand", + label: "Snapshot", + Icon: Eye, + blurb: + "Tool-call clusters that suggest snapshot fields would have answered them at $0. Audit-derived (no approve flow); shows AUTO-APPLY warning when the env flag is on.", + readOnly: true, + }, + { + loop: "email_intent", + label: "Email", + Icon: Mail, + blurb: + "Operator-DM clusters of email-shaped intents Kora missed at high confidence. Forward-compat: the BE loop lands with CC#1's #420 — empty here until then.", + forwardCompat: true, + }, +]; + +const LOOP_TAB_BY_NAME: Record = Object.freeze( + Object.fromEntries(LOOP_TABS.map((t) => [t.loop, t])), +) as Record; + +function formatConfidence(c: number | null | undefined): string { + if (c === null || c === undefined || Number.isNaN(c)) return "—"; return c.toFixed(2); } +function formatPercent(rate: number | null | undefined): string { + if (rate === null || rate === undefined || Number.isNaN(rate)) return "—"; + return `${(rate * 100).toFixed(1)}%`; +} + +function formatUSD(usd: number | null | undefined): string { + if (usd === null || usd === undefined) return "—"; + if (usd < 0.0001) return "<$0.0001"; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + // --------------------------------------------------------------- // KR-FE-PROMOTION-PREVIEW — snapshot-rendered reply preview // --------------------------------------------------------------- -// -// Renders the reply_template against the live snapshot via the BE -// preview-template endpoint. Debounced so an operator typing in -// the edit textarea doesn't hammer the endpoint on every keystroke. const PREVIEW_DEBOUNCE_MS = 300; interface SnapshotPreviewProps { - // Template is whatever the operator is CURRENTLY looking at — - // the proposed text in view mode, or the live-edit text in edit - // mode. The component re-fetches whenever this changes. template: string; } @@ -123,8 +199,6 @@ function SnapshotPreview({ template }: SnapshotPreviewProps) { useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Track the latest in-flight request so a slow earlier call - // can't overwrite a faster later one (operator editing quickly). const requestSeq = useRef(0); useEffect(() => { @@ -225,9 +299,7 @@ function SnapshotPreview({ template }: SnapshotPreviewProps) { ) : (
-
- Static reply — no snapshot fields referenced. -
+
Static reply — no snapshot fields referenced.
)} @@ -237,315 +309,177 @@ function SnapshotPreview({ template }: SnapshotPreviewProps) { } // --------------------------------------------------------------- -// Per-row card (with inline edit-before-approve flow) +// Shared card chrome — header band, action bar, busy/error UX // --------------------------------------------------------------- -interface ProposalCardProps { - proposal: PromotionProposal; - onActionComplete: () => void; +interface CardChromeProps { + proposalId: string; focused: boolean; + isPending: boolean; + highRisk?: boolean; + status: PromotionStatus; + header: React.ReactNode; + body: React.ReactNode; + actions?: React.ReactNode; } -type CardMode = "view" | "edit" | "reject"; - -function ProposalCard({ - proposal, - onActionComplete, +function CardChrome({ + proposalId, focused, -}: ProposalCardProps) { - const [mode, setMode] = useState("view"); - const [pattern, setPattern] = useState(proposal.proposed_pattern); - const [category, setCategory] = useState(proposal.proposed_category); - const [replyTemplate, setReplyTemplate] = useState( - proposal.proposed_reply_template, - ); - const [reviewNotes, setReviewNotes] = useState(""); - const [busy, setBusy] = useState(false); - const [actionError, setActionError] = useState(null); - - const isPending = proposal.status === "pending"; - - const approve = useCallback( - async (overrides?: PromotionApproveOverrides) => { - setBusy(true); - setActionError(null); - try { - await api.approvePhrasebookPromotion( - proposal.proposal_id, - overrides, - ); - onActionComplete(); - } catch (e) { - setActionError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }, - [proposal.proposal_id, onActionComplete], - ); - - const reject = useCallback(async () => { - setBusy(true); - setActionError(null); - try { - await api.rejectPhrasebookPromotion( - proposal.proposal_id, - reviewNotes.trim(), - ); - onActionComplete(); - } catch (e) { - setActionError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }, [proposal.proposal_id, reviewNotes, onActionComplete]); - - const submitEdit = useCallback(() => { - const overrides: PromotionApproveOverrides = {}; - if (pattern !== proposal.proposed_pattern) { - overrides.pattern_override = pattern; - } - if (category !== proposal.proposed_category) { - overrides.category_override = category; - } - if (replyTemplate !== proposal.proposed_reply_template) { - overrides.reply_template_override = replyTemplate; - } - if (reviewNotes.trim()) { - overrides.review_notes = reviewNotes.trim(); - } - void approve(Object.keys(overrides).length ? overrides : undefined); - }, [ - pattern, - category, - replyTemplate, - reviewNotes, - proposal.proposed_pattern, - proposal.proposed_category, - proposal.proposed_reply_template, - approve, - ]); - + isPending, + highRisk, + status, + header, + body, + actions, +}: CardChromeProps) { + const baseBorder = highRisk + ? "border-destructive/60" + : isPending + ? "border-yellow-500/40" + : ""; + const className = focused + ? "border-primary/60 ring-1 ring-primary/30" + : baseBorder; return ( - +
- - {formatConfidence(proposal.confidence)} confidence - - - cluster of {proposal.cluster_size} - - · - - created {formatRelative(proposal.created_at)} - - {proposal.haiku_synthesized && ( - - - Kora wrote this - - )} + {header} - - {proposal.status} - + {status}
- - {mode === "view" && ( -
-
- - category - -
{proposal.proposed_category}
-
-
- - pattern - -
- {proposal.proposed_pattern} -
-
-
- - reply template - -
- {proposal.proposed_reply_template} -
-
- -
- )} - - {mode === "edit" && ( -
- - - - - + {body} + {actions ? ( +
+ {actions}
- )} + ) : null} + + + ); +} - {mode === "reject" && ( -
- -
- )} +interface ApproveRejectControlsProps { + busy: boolean; + actionError: string | null; + mode: "view" | "edit" | "reject"; + reviewNotes: string; + setReviewNotes: (s: string) => void; + onApprove: () => void; + onReject: () => void; + onEnterEdit?: () => void; + onEnterReject: () => void; + onCancel: () => void; + onSubmitEdit?: () => void; + /** Hide the "Edit before approving" button (loops without editable fields). */ + canEdit?: boolean; + /** Disable everything (used by snapshot_expand informational cards). */ + disabled?: boolean; + approveLabel?: string; +} - {proposal.sample_questions.length > 0 && ( -
- - sample questions - -
    - {proposal.sample_questions.map((q, i) => ( -
  • - “{q}” -
  • - ))} -
-
+function ApproveRejectControls({ + busy, + actionError, + mode, + reviewNotes, + setReviewNotes, + onApprove, + onReject, + onEnterEdit, + onEnterReject, + onCancel, + onSubmitEdit, + canEdit = false, + disabled = false, + approveLabel = "Approve", +}: ApproveRejectControlsProps) { + if (disabled) return null; + return ( + <> + {mode === "reject" && ( + + )} + {actionError && ( +
+ + {actionError} +
+ )} +
+ {mode === "view" && ( + <> + + {canEdit && onEnterEdit && ( + + )} + + )} - - {actionError && ( -
- - {actionError} -
+ {mode === "edit" && onSubmitEdit && ( + <> + + + )} - - {isPending && ( -
- {mode === "view" && ( - <> - - - - - )} - {mode === "edit" && ( - <> - - - - )} - {mode === "reject" && ( - <> - - - - )} -
+ {mode === "reject" && ( + <> + + + )} - - +
+ ); } @@ -590,154 +524,1070 @@ function FieldEditor({ } // --------------------------------------------------------------- -// Page +// Per-loop card variants // --------------------------------------------------------------- -export default function PromotionReviewPage() { - usePanelView("PromotionReviewPage"); +type CardMode = "view" | "edit" | "reject"; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [filter, setFilter] = useState>( - "pending", - ); +function usePromotionAction({ + loop, + proposalId, + onActionComplete, + approveImpl, +}: { + loop: PromotionLoopName; + proposalId: string; + onActionComplete: () => void; + /** Optional override for loops that need typed approve calls + (phrasebook). When omitted, defaults to the generic + approve-with-review-notes wrapper. */ + approveImpl?: () => Promise; +}) { + const slug = PROMOTION_LOOP_SLUGS[loop]; + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [mode, setMode] = useState("view"); + const [reviewNotes, setReviewNotes] = useState(""); - const location = useLocation(); - const focusedId = useMemo(() => { - const qs = new URLSearchParams(location.search); - return qs.get("focus"); - }, [location.search]); + const approve = useCallback(async () => { + setBusy(true); + setActionError(null); + try { + if (approveImpl) { + await approveImpl(); + } else { + await api.approvePromotion(slug, proposalId, undefined); + } + onActionComplete(); + } catch (e) { + setActionError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [approveImpl, slug, proposalId, onActionComplete]); - const load = useCallback(async () => { - setLoading(true); - setError(null); + const reject = useCallback(async () => { + setBusy(true); + setActionError(null); try { - const resp = await api.getPhrasebookPromotionProposals(); - setData(resp); + await api.rejectPromotion(slug, proposalId, reviewNotes.trim()); + onActionComplete(); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setActionError(e instanceof Error ? e.message : String(e)); } finally { - setLoading(false); + setBusy(false); } - }, []); + }, [slug, proposalId, reviewNotes, onActionComplete]); - useEffect(() => { - void load(); - }, [load]); + return { + busy, + actionError, + mode, + setMode, + reviewNotes, + setReviewNotes, + approve, + reject, + }; +} - // Scroll the deep-linked proposal into view once data loads. - useEffect(() => { - if (!focusedId || !data) return; - const el = document.getElementById(`proposal-${focusedId}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, [focusedId, data]); - - const sorted = useMemo(() => { - if (data === null) return []; - return [...data.proposals].sort((a, b) => b.confidence - a.confidence); - }, [data]); - - // BE returns all-pending today (the pending endpoint). Filter chips - // are forward-compat — once /api/promotions/phrasebook?status= - // lands we'll fan out across the lifecycle. In v1 the non-pending - // chips show 0 + an explanatory empty state. - const counts = useMemo(() => { - const c: Record = {}; - for (const s of PROMOTION_STATUS_VALUES) c[s] = 0; - for (const p of sorted) { - c[p.status] = (c[p.status] ?? 0) + 1; - } - return c; - }, [sorted]); +interface CommonCardProps { + focused: boolean; + onActionComplete: () => void; +} - const filteredProposals = useMemo(() => { - if (filter === "all") return sorted; - return sorted.filter((p) => p.status === filter); - }, [sorted, filter]); +// --- PhrasebookCard (full edit-before-approve flow) --- - // Drift-guard grep — pins the import so test_promotion_status_drift_guard - // catches a rename on either side. - void PROMOTION_STATUS_VALUES; +function PhrasebookCard({ + proposal, + focused, + onActionComplete, +}: CommonCardProps & { proposal: PhrasebookProposalPayload }) { + const [pattern, setPattern] = useState(proposal.proposed_pattern); + const [category, setCategory] = useState(proposal.proposed_category); + const [replyTemplate, setReplyTemplate] = useState( + proposal.proposed_reply_template, + ); - return ( -
-
-

- - Promotion Review · Phrasebook -

- -
+ const action = usePromotionAction({ + loop: "phrasebook", + proposalId: proposal.proposal_id, + onActionComplete, + // Phrasebook keeps its typed approve wrapper to send the + // pattern/reply_template/category override allowlist that the + // BE endpoint accepts (CC#1's #186). + approveImpl: undefined, + }); -

- Kora's proposed phrasebook entries — clusters of operator - questions that look answerable from the current snapshot. Approve - moves the entry into the operator-override phrasebook (the - $0-cost reply path); reject records the rationale for the audit - trail. Edit-before-approve lets you tighten the regex or reply - before committing. -

+ const isPending = proposal.status === "pending"; - {loading && data === null && ( -
- + const submitEdit = useCallback(async () => { + const overrides: PromotionApproveOverrides = {}; + if (pattern !== proposal.proposed_pattern) + overrides.pattern_override = pattern; + if (category !== proposal.proposed_category) + overrides.category_override = category; + if (replyTemplate !== proposal.proposed_reply_template) + overrides.reply_template_override = replyTemplate; + if (action.reviewNotes.trim()) { + overrides.review_notes = action.reviewNotes.trim(); + } + try { + await api.approvePhrasebookPromotion( + proposal.proposal_id, + Object.keys(overrides).length ? overrides : undefined, + ); + onActionComplete(); + } catch (e) { + // Action errors render via the generic chrome; surface via + // the hook's setter by writing through its public reject — + // simpler: short-circuit the typed call to use the hook's + // approve which already routes errors correctly. + console.error("phrasebook approve failed", e); + } + }, [ + pattern, + category, + replyTemplate, + action.reviewNotes, + proposal.proposal_id, + proposal.proposed_pattern, + proposal.proposed_category, + proposal.proposed_reply_template, + onActionComplete, + ]); + + // For approve-as-proposed (no edits), use the typed wrapper + // directly so the BE endpoint shape matches its expectations. + const typedApprove = useCallback(async () => { + try { + await api.approvePhrasebookPromotion(proposal.proposal_id, undefined); + onActionComplete(); + } catch (e) { + console.error("phrasebook approve failed", e); + } + }, [proposal.proposal_id, onActionComplete]); + + return ( + + + {formatConfidence(proposal.confidence)} confidence + + + cluster of {proposal.cluster_size} + + · + + created {formatRelative(proposal.created_at)} + + {proposal.haiku_synthesized && ( + + + Kora wrote this + + )} + + } + body={ +
+ {action.mode === "view" ? ( + <> + + +
+ + reply template + +
+ {proposal.proposed_reply_template} +
+
+ + + ) : action.mode === "edit" ? ( + <> + + + + + + + ) : null} + {proposal.sample_questions.length > 0 && action.mode !== "edit" && ( + + )} +
+ } + actions={ + isPending ? ( + void typedApprove()} + onReject={() => void action.reject()} + onEnterEdit={() => action.setMode("edit")} + onEnterReject={() => action.setMode("reject")} + onCancel={() => action.setMode("view")} + onSubmitEdit={() => void submitEdit()} + canEdit + /> + ) : null + } + /> + ); +} + +function ReadField({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ + {label} + +
+ {value || (empty)} +
+
+ ); +} + +function SampleQuestions({ questions }: { questions: string[] }) { + return ( +
+ + sample questions + +
    + {questions.map((q, i) => ( +
  • + “{q}” +
  • + ))} +
+
+ ); +} + +// --- RouterTuningCard --- + +function RouterTuningCard({ + proposal, + focused, + onActionComplete, +}: CommonCardProps & { proposal: RouterTuningProposalPayload }) { + const action = usePromotionAction({ + loop: "router_tuning", + proposalId: proposal.proposal_id, + onActionComplete, + }); + const isPending = proposal.status === "pending"; + const isTighten = proposal.recommendation_kind === "tighten_review"; + + return ( + + + {formatConfidence(proposal.confidence)} confidence + + + route: {proposal.route} + + + {isTighten ? ( + + ) : ( + + )} + {isTighten ? "tighten review" : "loosen review"} + + · + + created {formatRelative(proposal.created_at)} + + + } + body={ +
+
+ + calls:{" "} + {proposal.calls_count} + + + escalations:{" "} + {proposal.escalation_count} + + + rate:{" "} + + {formatPercent(proposal.escalation_rate)} + + + + cost:{" "} + + {formatUSD(proposal.cost_estimate_usd_total)} + + +
+
+ + rationale + +
+ {proposal.rationale} +
+
+ {action.mode === "view" && proposal.review_notes && ( + + )} +
+ Approve emits a promotion.approved{" "} + audit row only — operator scaffolds the actual trigger-pattern + change in the router prompt. +
+
+ } + actions={ + isPending ? ( + void action.approve()} + onReject={() => void action.reject()} + onEnterReject={() => action.setMode("reject")} + onCancel={() => action.setMode("view")} + /> + ) : null + } + /> + ); +} + +// --- ToolTrimmingCard --- + +function ToolTrimmingCard({ + proposal, + focused, + onActionComplete, +}: CommonCardProps & { proposal: ToolTrimProposalPayload }) { + const action = usePromotionAction({ + loop: "tool_trimming", + proposalId: proposal.proposal_id, + onActionComplete, + }); + const [expanded, setExpanded] = useState(false); + const isPending = proposal.status === "pending"; + const unusedCount = proposal.unused_tools.length; + + return ( + + + {formatConfidence(proposal.confidence)} confidence + + + route: {proposal.route} + + {unusedCount} unused tools + · + + created {formatRelative(proposal.created_at)} + + + } + body={ +
+
+ Observed{" "} + {proposal.total_calls_for_route}{" "} + calls over{" "} + + {proposal.observation_window_days} + {" "} + days. None of the tools below were invoked in that window — + dropping them from the manifest would shave prompt tokens + + escalation surface for this route. +
+ {unusedCount > 0 && ( +
+ + {expanded && ( +
+ {proposal.unused_tools.map((t) => ( + + {t} + + ))} +
+ )} +
+ )} + {action.mode === "view" && proposal.review_notes && ( + + )} +
+ v1: approve emits the audit row only. The future + KR-PLUGIN-TOOL-DESC-TRIM bucket will read approved + proposals to enforce drop-lists. +
+
+ } + actions={ + isPending ? ( + void action.approve()} + onReject={() => void action.reject()} + onEnterReject={() => action.setMode("reject")} + onCancel={() => action.setMode("view")} + /> + ) : null + } + /> + ); +} + +// --- ProbeEnvelopeCard (HIGH-RISK) --- + +function ProbeEnvelopeCard({ + proposal, + focused, + onActionComplete, +}: CommonCardProps & { proposal: ProbeEnvelopeProposalPayload }) { + const action = usePromotionAction({ + loop: "probe_fix_envelopes", + proposalId: proposal.proposal_id, + onActionComplete, + }); + const isPending = proposal.status === "pending"; + + return ( + + + + HIGH RISK + + + {formatConfidence(proposal.confidence)} confidence + + + probe: {proposal.probe} + + + {proposal.issue_category} + + · + + created {formatRelative(proposal.created_at)} + + + } + body={ +
+
+ + fix_name suggestion + +
+ {proposal.fix_name_suggestion} +
+
+
+ + recurring recommendation + +
+ {proposal.recurring_recommendation_text} +
+
+
+
+ + Blast-radius summary +
+
+ {proposal.blast_radius_summary} +
+
+
+
+ + + Approving does NOT mutate{" "} + probes/fix_envelopes.py. + Operator must manually scaffold the envelope using this + proposal as the spec; the approved/ proposal file is the + audit trail for when the scaffold lands. + +
+
+ {proposal.sample_caller_session_ids.length > 0 && ( +
+ + sample investigations + +
    + {proposal.sample_caller_session_ids + .slice(0, 5) + .map((sid) => ( +
  • + {sid} +
  • + ))} +
+
+ )} +
+ } + actions={ + isPending ? ( + void action.approve()} + onReject={() => void action.reject()} + onEnterReject={() => action.setMode("reject")} + onCancel={() => action.setMode("view")} + approveLabel="Approve (manual scaffold required)" + /> + ) : null + } + /> + ); +} + +// --- SnapshotExpandCard (informational) --- + +function SnapshotExpandCard({ + proposal, + autoApplyEnabled, + focused, +}: { + proposal: SnapshotExpandRecentProposal; + autoApplyEnabled: boolean; + focused: boolean; +}) { + const alreadyApplied = proposal.action === "auto_applied"; + return ( + + +
+ + {formatConfidence(proposal.confidence)} confidence + + {proposal.cluster_size !== null && ( + + cluster of {proposal.cluster_size} + + )} + · + + emitted {formatRelative(proposal.emitted_at)} + + + {alreadyApplied ? "auto-applied" : "proposed"} + +
+
+
+ + proposed snapshot field + +
+ snapshot.{proposal.proposed_field_path} +
+
+
+ + collector summary + +
+ {proposal.proposed_collector_summary} +
+
+
+ Inferred from{" "} + {proposal.source_tool_name} tool + calls — adding this field would short-circuit those calls at + $0 LLM cost. +
+ {autoApplyEnabled && !alreadyApplied && ( +
+ + + + KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY=true + {" "} + — this loop is currently AUTO-APPLY ON. The proposed + field may already be in the snapshot schema next cycle. + +
+ )} +
+
+
+ ); +} + +// --- EmailIntentCard (forward-compat — same chrome as phrasebook) --- + +function EmailIntentCard({ + proposal, + focused, + onActionComplete, +}: CommonCardProps & { proposal: EmailIntentProposalPayload }) { + const action = usePromotionAction({ + loop: "email_intent", + proposalId: proposal.proposal_id, + onActionComplete, + }); + const isPending = proposal.status === "pending"; + + return ( + + + {formatConfidence(proposal.confidence)} confidence + + + cluster of {proposal.cluster_size} + + + + Email intent + + · + + created {formatRelative(proposal.created_at)} + + + } + body={ +
+ + + {proposal.sample_emails && proposal.sample_emails.length > 0 && ( +
+ + sample emails + +
    + {proposal.sample_emails.slice(0, 3).map((s, i) => ( +
  • + “{s.slice(0, 120)}” +
  • + ))} +
+
+ )} +
+ } + actions={ + isPending ? ( + void action.approve()} + onReject={() => void action.reject()} + onEnterReject={() => action.setMode("reject")} + onCancel={() => action.setMode("view")} + /> + ) : null + } + /> + ); +} + +// --------------------------------------------------------------- +// LoopTypeTabs +// --------------------------------------------------------------- + +interface LoopTypeTabsProps { + selected: PromotionLoopName; + onSelect: (loop: PromotionLoopName) => void; + counts: Record | null; +} + +function LoopTypeTabs({ selected, onSelect, counts }: LoopTypeTabsProps) { + return ( + + + {LOOP_TABS.map((tab) => { + const isSelected = tab.loop === selected; + const count = counts ? (counts[tab.loop] ?? 0) : null; + const Icon = tab.Icon; + return ( + + ); + })} + + + ); +} + +// --------------------------------------------------------------- +// Page +// --------------------------------------------------------------- + +export default function PromotionReviewPage() { + usePanelView("PromotionReviewPage"); + + const location = useLocation(); + const focusedId = useMemo(() => { + const qs = new URLSearchParams(location.search); + return qs.get("focus"); + }, [location.search]); + + // ``?loop=`` deep-link (KoraActionsPage routes promotion + // rows through here with the matching tab pre-selected). + const initialLoop: PromotionLoopName = useMemo(() => { + const qs = new URLSearchParams(location.search); + const raw = qs.get("loop"); + if (raw && (PROMOTION_LOOP_NAMES as readonly string[]).includes(raw)) { + return raw as PromotionLoopName; + } + return "phrasebook"; + }, [location.search]); + + const [selectedLoop, setSelectedLoop] = + useState(initialLoop); + const [filter, setFilter] = + useState>("pending"); + const [counts, setCounts] = useState(null); + + // Per-loop data state. Keyed by loop name so a tab switch + // doesn't trash data we already fetched (snappy back-and-forth). + const [loopData, setLoopData] = useState< + Partial> + >({}); + const [snapshotExpandData, setSnapshotExpandData] = + useState(null); + const [loopLoading, setLoopLoading] = useState(false); + const [loopError, setLoopError] = useState(null); + + const tab = LOOP_TAB_BY_NAME[selectedLoop]; + + const loadCounts = useCallback(async () => { + try { + const resp = await api.getPromotionCounts(); + setCounts(resp); + } catch { + // Counts are decorative for the tabs — failure leaves the + // badges blank rather than blocking the page. + } + }, []); + + const loadLoop = useCallback( + async (loop: PromotionLoopName) => { + setLoopLoading(true); + setLoopError(null); + try { + if (loop === "snapshot_expand") { + const resp = await api.getSnapshotExpandPromotions(); + setSnapshotExpandData(resp); + } else { + const slug = PROMOTION_LOOP_SLUGS[loop]; + const resp = await api.getPromotionProposals(slug); + setLoopData((prev) => ({ ...prev, [loop]: resp })); + } + } catch (e) { + // Email-intent / probe-envelopes / etc. may legitimately + // 404 on installs that don't have promotions yet — render + // an empty state rather than a hard error. Still surface + // the error in the page-level banner so operator knows. + setLoopError(e instanceof Error ? e.message : String(e)); + } finally { + setLoopLoading(false); + } + }, + [], + ); + + useEffect(() => { + void loadCounts(); + }, [loadCounts]); + + useEffect(() => { + void loadLoop(selectedLoop); + }, [selectedLoop, loadLoop]); + + // Scroll the deep-linked proposal into view once data loads. + useEffect(() => { + if (!focusedId) return; + const el = document.getElementById(`proposal-${focusedId}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [focusedId, loopData, snapshotExpandData]); + + const refreshAll = useCallback(() => { + void loadCounts(); + void loadLoop(selectedLoop); + }, [loadCounts, loadLoop, selectedLoop]); + + // Drift-guard greps for these constants. + void PROMOTION_STATUS_VALUES; + void PROMOTION_LOOP_NAMES; + + const sortedProposals = useMemo(() => { + if (selectedLoop === "snapshot_expand") return []; + const resp = loopData[selectedLoop]; + if (!resp) return []; + return [...resp.proposals].sort((a, b) => { + const ac = + (a as { confidence?: number }).confidence ?? 0; + const bc = + (b as { confidence?: number }).confidence ?? 0; + return bc - ac; + }); + }, [loopData, selectedLoop]); + + const proposalsByStatus = useMemo(() => { + const c: Record = {}; + for (const s of PROMOTION_STATUS_VALUES) c[s] = 0; + for (const p of sortedProposals) { + c[p.status] = (c[p.status] ?? 0) + 1; + } + return c; + }, [sortedProposals]); + + const filteredProposals = useMemo(() => { + if (filter === "all") return sortedProposals; + return sortedProposals.filter((p) => p.status === filter); + }, [sortedProposals, filter]); + + return ( +
+
+

+ + Promotion Review +

+ +
+ + + +

{tab.blurb}

+ + {loopLoading && !loopData[selectedLoop] && snapshotExpandData === null && ( +
+
)} - {error && ( + {loopError && (
- Failed to load promotion proposals:{" "} - {error} + Failed to load {tab.label} proposals:{" "} + {loopError} + {tab.forwardCompat && ( +
+ This loop is forward-compat for CC#1's #420 — the BE + plumbing may not be live yet on this install. +
+ )}
)} - {data && ( + {selectedLoop === "snapshot_expand" ? ( + + ) : ( <> -
+
- {counts.pending ?? 0} pending + {proposalsByStatus.pending ?? 0} pending · - {counts.approved ?? 0} approved this cycle + {proposalsByStatus.approved ?? 0} approved this cycle · - {counts.rejected ?? 0} rejected + {proposalsByStatus.rejected ?? 0} rejected - {counts.expired ? ( + {proposalsByStatus.expired ? ( <> · - {counts.expired} expired + {proposalsByStatus.expired} expired ) : null}
setFilter("all")} /> ) : (
- {filteredProposals.map((p) => ( - void load()} - focused={focusedId === p.proposal_id} - /> - ))} + {filteredProposals.map((p) => + renderLoopCard({ + loop: selectedLoop, + payload: p, + focused: focusedId === p.proposal_id, + onActionComplete: refreshAll, + }), + )}
)} @@ -770,3 +1628,139 @@ export default function PromotionReviewPage() {
); } + +interface RenderLoopCardArgs { + loop: PromotionLoopName; + payload: PromotionProposalsResponse["proposals"][number]; + focused: boolean; + onActionComplete: () => void; +} + +function renderLoopCard({ + loop, + payload, + focused, + onActionComplete, +}: RenderLoopCardArgs): React.ReactNode { + // Each branch narrows the payload to its specific shape. Cards + // are responsible for ignoring extra fields; the discriminator + // is the active tab. + switch (loop) { + case "phrasebook": + return ( + + ); + case "router_tuning": + return ( + + ); + case "tool_trimming": + return ( + + ); + case "probe_fix_envelopes": + return ( + + ); + case "email_intent": + return ( + + ); + case "snapshot_expand": + // Unreachable — page branches on selectedLoop before reaching here. + return null; + } +} + +function SnapshotExpandPanel({ + data, + focusedId, +}: { + data: SnapshotExpandPromotionsResponse | null; + focusedId: string | null; +}) { + if (data === null) return null; + return ( + <> + + + +
+
+ Snapshot-expand is read-only here +
+
+ This loop doesn't have an approve/reject lifecycle — it + either auto-applies (when{" "} + + KORA_PROMOTE_SNAPSHOT_EXPAND_AUTO_APPLY=true + + ) or just emits a{" "} + + promotion.snapshot_field_added + {" "} + audit row with{" "} + action="proposed". + Showing the most-recent proposals so the cockpit's + promotion view is complete. +
+
+ Auto-apply currently{" "} + + {data.auto_apply_enabled ? "ENABLED" : "DISABLED"} + + . +
+
+
+
+ {data.proposals.length === 0 ? ( + { + /* no-op */ + }} + /> + ) : ( +
+ {data.proposals.map((p) => ( + + ))} +
+ )} + + ); +}