diff --git a/kora_cli/audit/jsonl_sink.py b/kora_cli/audit/jsonl_sink.py index a2990cbc6680..396e71e328b8 100644 --- a/kora_cli/audit/jsonl_sink.py +++ b/kora_cli/audit/jsonl_sink.py @@ -109,6 +109,15 @@ # KR-FE-EMAIL-INTENT-LOG-PANEL surfaces this seam in the # cockpit. "intent.email_to_sea_ticket", + # KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — operator-driven phrasebook + # edits via the cockpit PUT endpoint. Each successful write + # emits one entry with entry_count_before / entry_count_after / + # backup_filename so operator-attention triage can reconstruct + # "when did the phrasebook change + did the change have a + # backup to revert to." Future actor extension (e.g. + # ``actor="kora_proposal_approved"`` from the promotion-loop + # bucket) reuses this seam shape. + "phrasebook.updated", ] SourceName = Literal[ diff --git a/kora_cli/short_circuit/phrasebook_editor.py b/kora_cli/short_circuit/phrasebook_editor.py new file mode 100644 index 000000000000..5cf3e3027876 --- /dev/null +++ b/kora_cli/short_circuit/phrasebook_editor.py @@ -0,0 +1,574 @@ +"""KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — write-path support. + +Hosts everything that the read-only viewer (PR #167) didn't need: + + * Per-entry validation (regex compiles, snapshot paths resolve to + real fields, length caps, catastrophic-backtracking guard, + duplicate-key dedup) + * Atomic write to the operator override at + ``${KORA_HOME}/phrasebook/slack_dm.yml`` via + :func:`utils.atomic_replace` (same pattern as snapshot writer) + * Backup-on-write with ISO-Z-timestamped filenames + env-tunable + rotation (``KORA_PHRASEBOOK_BACKUP_COUNT``) + * Revert to a specific backup OR the most-recent OR full removal + (falls back to bundled default when no override present) + * Backup listing for the cockpit dropdown + +The read path stays in :mod:`kora_cli.short_circuit.dm_phrasebook` — +this module is import-once-on-write and never invoked by the live +DM handler. Keeps the hot path clean of YAML-serialization + +filesystem code. + +Snapshot field-path validation +============================== + +Validation rejects ``{snapshot.X.Y}`` placeholders that don't +resolve to a known scalar in the snapshot v4 schema. This is a +STATIC allow-list, pinned against ``kora_cli/snapshot/state_snapshot.py`` +by ``test_static_schema_matches_snapshot_collectors``. + +Why static vs dynamic walk of a live snapshot: + + * Validation must be deterministic regardless of holder warm-up + state (a freshly-booted daemon's snapshot may have everything + degraded to ``"unknown"``; validation must still accept the + canonical paths) + * A static set documents the operator-facing API surface — what + paths CAN be referenced is decoupled from what's currently + populated + * If we walked live snapshot keys, ``daemon_health.listeners.X`` + dynamic listener names would appear as valid paths during + normal operation, then fail validation when the listener + isn't running. False inconsistency. + +The allow-list includes only SCALAR paths (paths that render +sensibly via ``str(value)`` substitution). Nested dicts like +``alerts`` aren't valid templates (str(dict) leaks Python's repr). +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + + +REQUIRED_FIELDS = ("pattern", "category", "description", "reply_template") + +# Length caps — defensive against runaway templates / regex +# bombs. Operator-facing values; not security boundaries (regex +# compile + backtracking guard below are the real safety net). +MAX_PATTERN_LENGTH = 512 +MAX_REPLY_TEMPLATE_LENGTH = 4096 +MAX_DESCRIPTION_LENGTH = 256 +MAX_CATEGORY_LENGTH = 64 +MAX_ENTRIES_PER_PHRASEBOOK = 200 + +# Catastrophic-backtracking safety guard. Catches the most common +# pathological shapes: nested unbounded quantifiers like ``(a+)+``, +# ``(.*)*``, ``(x*)?`` etc. Doesn't catch all ReDoS but covers the +# operator-typo cases. A real ReDoS analyzer is overkill for the +# v1 operator surface. +_NESTED_UNBOUNDED_QUANTIFIER_RE = re.compile(r"\([^()]*[+*][^()]*\)[+*?]") + +# Snapshot placeholder regex — mirrors dm_phrasebook._PLACEHOLDER_RE +# verbatim. Pinned by the existing +# test_placeholder_regex_matches_dm_phrasebook_source in +# tests/kora_cli/test_phrasebook_endpoints.py. +_PLACEHOLDER_RE = re.compile(r"\{snapshot\.([a-zA-Z0-9_.]+)\}") + +# Backup rotation default + env override. Default is 10 backups +# kept; operator can set KORA_PHRASEBOOK_BACKUP_COUNT to a smaller +# number for low-disk environments or a larger number to keep a +# longer revert history. +DEFAULT_BACKUP_KEEP = 10 +BACKUP_KEEP_ENV = "KORA_PHRASEBOOK_BACKUP_COUNT" + +# Static snapshot v4 scalar-path allow-list for placeholder +# validation. KEEP THIS IN SYNC with kora_cli/snapshot/state_snapshot.py +# — pinned by test_static_schema_matches_snapshot_collectors which +# greps the snapshot collector functions for each path. +# +# Only SCALAR paths are listed. Nested-dict paths like +# "alerts.by_severity" aren't included because they str() to +# Python repr and would render garbage in operator-facing DMs. +SNAPSHOT_SCALAR_PATHS: frozenset[str] = frozenset( + [ + # operational_state — _collect_operational_state + "operational_state.primary", + "operational_state.paused", + "operational_state.pause_reason", + # alerts — _collect_alerts (scalars only; by_category is + # a dict of dynamic keys → invalid template path) + "alerts.active_count", + "alerts.by_severity.critical", + "alerts.by_severity.warning", + "alerts.by_severity.info", + # cost_ladder — _collect_cost_ladder (schema v3) + "cost_ladder.current_tier", + "cost_ladder.monthly_budget_pct_used", + "cost_ladder.model_default", + "cost_ladder.spent_to_date_usd", + "cost_ladder.credit_pool_usd", + # service_health — _collect_service_health (5 known probes) + "service_health.supabase", + "service_health.fly", + "service_health.vercel", + "service_health.sentry", + "service_health.doppler", + # daemon_health — _collect_daemon_health (schema v4) + # listeners.* + cost_telemetry.* are dynamic-key dicts; + # not included. + "daemon_health.overall_status", + "daemon_health.boot_at", + "daemon_health.uptime_seconds", + "daemon_health.recent_error_count_5min", + # tasks — _collect_tasks (v1 deferred to "unknown" but + # canonical paths are still operator-referenceable) + "tasks.open_count", + "tasks.in_progress_count", + # Snapshot metadata — operator may want to embed the + # computed_at in a reply ("snapshot was N min old when I + # answered"). + "computed_at", + "schema_version", + ] +) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EntryValidationError: + """One validation failure. ``entry_index = -1`` for root-level + errors (e.g. payload isn't a list). ``field = '_root'`` for + errors that don't tie to a specific field (duplicates, count).""" + + entry_index: int + field: str + error: str + + def as_dict(self) -> Dict[str, Any]: + return { + "entry_index": self.entry_index, + "field": self.field, + "error": self.error, + } + + +def validate_entries(entries: Any) -> List[EntryValidationError]: + """Return a list of validation errors. Empty list ≡ valid; + callers must check len(errors) == 0 before writing. + + Checks (in order; later checks skipped for an entry that + already failed an earlier check, to avoid noise): + 1. Payload is a list of dicts + 2. List length <= MAX_ENTRIES_PER_PHRASEBOOK + 3. Per entry: required fields present + non-empty strings + 4. Per entry: length caps on pattern / reply / desc / cat + 5. Per entry: pattern compiles as Python regex (re.IGNORECASE) + 6. Per entry: pattern doesn't have nested unbounded quantifier + 7. Per entry: every {snapshot.X.Y} placeholder in + reply_template resolves to SNAPSHOT_SCALAR_PATHS + 8. Cross-entry: no duplicate (pattern, category) tuples + """ + errors: List[EntryValidationError] = [] + + # 1. payload is a list + if not isinstance(entries, list): + errors.append(EntryValidationError(-1, "_root", "entries must be a list")) + return errors + + # 2. length cap + if len(entries) > MAX_ENTRIES_PER_PHRASEBOOK: + errors.append( + EntryValidationError( + -1, + "_root", + f"too many entries ({len(entries)} > {MAX_ENTRIES_PER_PHRASEBOOK})", + ) + ) + # don't short-circuit — operator may still want per-entry + # feedback on the offenders below + + seen_pairs: set = set() + + for i, entry in enumerate(entries): + # 3a. entry shape + if not isinstance(entry, dict): + errors.append(EntryValidationError(i, "_root", "entry must be an object")) + continue + + # 3b. required fields present + non-empty + missing_field = False + for f in REQUIRED_FIELDS: + v = entry.get(f) + if not isinstance(v, str) or not v.strip(): + errors.append( + EntryValidationError(i, f, f"required field '{f}' missing or empty") + ) + missing_field = True + if missing_field: + continue # downstream checks need the strings + + pattern_str: str = entry["pattern"] + reply_str: str = entry["reply_template"] + cat_str: str = entry["category"] + desc_str: str = entry["description"] + + # 4. length caps + if len(pattern_str) > MAX_PATTERN_LENGTH: + errors.append( + EntryValidationError( + i, "pattern", f"pattern exceeds {MAX_PATTERN_LENGTH} characters" + ) + ) + if len(reply_str) > MAX_REPLY_TEMPLATE_LENGTH: + errors.append( + EntryValidationError( + i, + "reply_template", + f"reply_template exceeds {MAX_REPLY_TEMPLATE_LENGTH} characters", + ) + ) + if len(desc_str) > MAX_DESCRIPTION_LENGTH: + errors.append( + EntryValidationError( + i, + "description", + f"description exceeds {MAX_DESCRIPTION_LENGTH} characters", + ) + ) + if len(cat_str) > MAX_CATEGORY_LENGTH: + errors.append( + EntryValidationError( + i, "category", f"category exceeds {MAX_CATEGORY_LENGTH} characters" + ) + ) + + # 5. pattern compiles + compile_ok = True + try: + re.compile(pattern_str, re.IGNORECASE) + except re.error as exc: + errors.append( + EntryValidationError(i, "pattern", f"invalid regex: {exc}") + ) + compile_ok = False + + # 6. catastrophic-backtracking guard (only if compile-ok; + # otherwise the regex is invalid for a different reason) + if compile_ok and _NESTED_UNBOUNDED_QUANTIFIER_RE.search(pattern_str): + errors.append( + EntryValidationError( + i, + "pattern", + "nested unbounded quantifier (possible catastrophic " + "backtracking) — rewrite to use bounded counts or " + "anchored alternations", + ) + ) + + # 7. snapshot placeholder paths + for path in _PLACEHOLDER_RE.findall(reply_str): + if path not in SNAPSHOT_SCALAR_PATHS: + errors.append( + EntryValidationError( + i, + "reply_template", + f"snapshot path '{path}' not in known scalar " + f"schema (see SNAPSHOT_SCALAR_PATHS in " + f"kora_cli/short_circuit/phrasebook_editor.py)", + ) + ) + + # 8. cross-entry dedup + key = (pattern_str, cat_str) + if key in seen_pairs: + errors.append( + EntryValidationError( + i, + "_root", + f"duplicate (pattern, category) — already declared at " + f"index {_find_first_index(entries, pattern_str, cat_str, i)}", + ) + ) + seen_pairs.add(key) + + return errors + + +def _find_first_index( + entries: List[Any], pattern_str: str, cat_str: str, before: int +) -> int: + for j in range(before): + e = entries[j] + if not isinstance(e, dict): + continue + if e.get("pattern") == pattern_str and e.get("category") == cat_str: + return j + return -1 + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def _override_path() -> Path: + """``${KORA_HOME}/phrasebook/slack_dm.yml`` — the operator + override that takes precedence over the bundled default + in :func:`dm_phrasebook.load_phrasebook`.""" + from kora_constants import get_kora_home + + return get_kora_home() / "phrasebook" / "slack_dm.yml" + + +def _backup_dir() -> Path: + """``${KORA_HOME}/phrasebook/backups/`` — sibling of the + override. Created lazily by write_backup_for + write_phrasebook.""" + from kora_constants import get_kora_home + + return get_kora_home() / "phrasebook" / "backups" + + +def _backup_keep_count() -> int: + """Read the rotation count from env. Defaults + clamps to + sensible bounds (1-1000) so a typo doesn't blow up disk.""" + raw = os.environ.get(BACKUP_KEEP_ENV, "").strip() + if not raw: + return DEFAULT_BACKUP_KEEP + try: + n = int(raw) + except ValueError: + logger.warning( + "[kora.phrasebook] %s=%r is not an int — using default %d", + BACKUP_KEEP_ENV, + raw, + DEFAULT_BACKUP_KEEP, + ) + return DEFAULT_BACKUP_KEEP + if n < 1: + return 1 + if n > 1000: + return 1000 + return n + + +# --------------------------------------------------------------------------- +# Backups +# --------------------------------------------------------------------------- + + +def write_backup_for(override_path: Path) -> Optional[Path]: + """Copy the current override into the backups dir. Returns the + backup path on success, None when the override doesn't exist + (first-edit case — nothing to back up).""" + if not override_path.is_file(): + return None + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") + bkp_dir = _backup_dir() + bkp_dir.mkdir(parents=True, exist_ok=True) + bkp_path = bkp_dir / f"slack_dm.{ts}.yml" + # If the same-second timestamp collides (rapid sequential + # writes), append a counter so we never silently clobber. + counter = 1 + while bkp_path.exists(): + bkp_path = bkp_dir / f"slack_dm.{ts}-{counter}.yml" + counter += 1 + shutil.copy2(override_path, bkp_path) + return bkp_path + + +def rotate_backups(keep: int) -> List[Path]: + """Delete backups beyond the ``keep`` most-recent. Returns the + paths that were removed.""" + bkp_dir = _backup_dir() + if not bkp_dir.is_dir(): + return [] + # ISO-Z timestamps in filenames sort chronologically as plain + # strings (oldest first). sorted() + slice-off-the-old. + files = sorted(bkp_dir.glob("slack_dm.*.yml")) + if len(files) <= keep: + return [] + to_remove = files[: len(files) - keep] + removed: List[Path] = [] + for p in to_remove: + try: + p.unlink() + removed.append(p) + except OSError as exc: + logger.warning( + "[kora.phrasebook] backup rotation failed for %s: %r", + p, + exc, + ) + return removed + + +def list_backups() -> List[Dict[str, Any]]: + """Return newest-first list of available backups for the cockpit + dropdown. Each entry: filename / timestamp / size_bytes / + entry_count (None when the backup file can't be parsed — + indicates a corrupt backup the operator might want to skip).""" + bkp_dir = _backup_dir() + if not bkp_dir.is_dir(): + return [] + out: List[Dict[str, Any]] = [] + for p in sorted(bkp_dir.glob("slack_dm.*.yml"), reverse=True): + # Filename: slack_dm.{TIMESTAMP}.yml — strip prefix + + # suffix to recover the timestamp. + ts = p.name[len("slack_dm.") : -len(".yml")] + try: + size = p.stat().st_size + except OSError: + continue + entry_count: Optional[int] + try: + doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + if isinstance(doc, dict) and isinstance(doc.get("entries"), list): + entry_count = len(doc["entries"]) + else: + entry_count = None + except Exception: + entry_count = None + out.append( + { + "filename": p.name, + "timestamp": ts, + "size_bytes": size, + "entry_count": entry_count, + } + ) + return out + + +# --------------------------------------------------------------------------- +# Write + revert +# --------------------------------------------------------------------------- + + +def write_phrasebook(entries: List[Dict[str, Any]]) -> Path: + """Atomically write entries to the override path. Caller MUST + have called :func:`validate_entries` first + checked it + returned empty. This function does NOT re-validate (separation + keeps endpoint code readable + makes the error path testable + in isolation).""" + from utils import atomic_replace + + override = _override_path() + override.parent.mkdir(parents=True, exist_ok=True) + + # Serialize in a deterministic field order so YAML diffs stay + # readable across edits. sort_keys=False preserves the per- + # entry field order we set; the entry list itself is operator- + # ordered (first-match-wins is the runtime semantic). + payload = { + "entries": [ + { + "pattern": e["pattern"], + "category": e["category"], + "description": e["description"], + "reply_template": e["reply_template"], + } + for e in entries + ], + } + yaml_text = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True) + + # Atomic write — write to tmp, then atomic_replace into place. + # Same pattern as kora_cli/snapshot/state_snapshot.py. + tmp = override.with_suffix(".yml.tmp") + tmp.write_text(yaml_text, encoding="utf-8") + atomic_replace(tmp, override) + return override + + +def revert_phrasebook(filename: Optional[str] = None) -> Dict[str, Any]: + """Revert the override to a specific backup OR the most-recent + backup OR (no backup available) remove the override entirely. + + Args: + filename: When given, revert to this specific backup + (filename only, not path — looked up under _backup_dir()). + When None, revert to the most-recent backup. + + Returns: ``{"reverted_to": "", "source_path": "" | None}`` + where source is one of: + * "" — restored a specific backup + * "bundled_default" — no backup available + override removed + (live handler now falls back to the bundled default) + + Raises FileNotFoundError when the requested filename doesn't + exist; the endpoint surfaces this as a 404.""" + override = _override_path() + bkp_dir = _backup_dir() + + target_backup: Optional[Path] = None + if filename is not None: + # Specific backup requested — defense against path + # traversal: filename must be in our directory and match + # the slack_dm.*.yml shape. + if ( + "/" in filename + or "\\" in filename + or ".." in filename + or not filename.startswith("slack_dm.") + or not filename.endswith(".yml") + ): + raise ValueError(f"invalid backup filename: {filename!r}") + candidate = bkp_dir / filename + if not candidate.is_file(): + raise FileNotFoundError( + f"backup not found: {filename}" + ) + target_backup = candidate + else: + if bkp_dir.is_dir(): + files = sorted(bkp_dir.glob("slack_dm.*.yml"), reverse=True) + if files: + target_backup = files[0] + + if target_backup is None: + # No backup → remove override; live handler will fall + # back to the bundled default automatically. + if override.is_file(): + try: + override.unlink() + except OSError as exc: + logger.warning( + "[kora.phrasebook] override unlink failed: %r", exc + ) + raise + return {"reverted_to": "bundled_default", "source_path": None} + + # Copy backup → override atomically (same pattern as + # write_phrasebook). Don't move/delete the backup itself; + # operator may want to revert again to the same point. + from utils import atomic_replace + + override.parent.mkdir(parents=True, exist_ok=True) + tmp = override.with_suffix(".yml.tmp") + shutil.copy2(target_backup, tmp) + atomic_replace(tmp, override) + return { + "reverted_to": target_backup.name, + "source_path": str(override), + } diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index 753d96c59ef7..62e256b1375a 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -6277,6 +6277,236 @@ async def test_phrasebook_match(payload: Dict[str, Any]) -> Dict[str, Any]: } +# --------------------------------------------------------------------------- +# DM phrasebook write path — KR-FE-PHRASEBOOK-EDITOR-AND-CRUD +# --------------------------------------------------------------------------- +# +# Read endpoints are above (PR #167). This block adds: +# +# * PUT /api/phrasebook/slack_dm — replace entries +# * POST /api/phrasebook/slack_dm/revert — revert to backup +# * GET /api/phrasebook/slack_dm/backups — list backups +# +# Validation, atomic write, backup rotation, and the static +# snapshot-schema allow-list live in +# kora_cli/short_circuit/phrasebook_editor.py — this block is +# request-handling + audit emission only. +# +# Audit: each successful PUT (or revert) emits seam=phrasebook.updated +# with entry_count_before / entry_count_after / backup_filename / +# actor="operator". Audit-panel consumers (KR-AUDIT-PANEL-ENDPOINTS +# / future KR-PROMOTION-REVIEW-PANEL) join on this seam to +# reconstruct edit history. + + +def _phrasebook_count_current_entries() -> int: + """Best-effort count of currently-live entries (for the + entry_count_before audit field). Returns 0 if load fails so a + failure here doesn't block the write.""" + try: + from kora_cli.short_circuit import dm_phrasebook + + return len(dm_phrasebook.load_phrasebook()) + except Exception: + return 0 + + +@app.put("/api/phrasebook/slack_dm") +async def put_phrasebook(payload: Dict[str, Any]) -> Any: + """Replace the entire operator-override phrasebook. + + Atomic-semantic: if ANY entry fails validation, the whole + payload is refused (422 with per-entry errors); the existing + override is preserved. Successful writes go through + backup-then-write-atomic so an in-flight crash can't leave + the override half-written or back-up-less. + + Payload shape: + {"entries": [ + {"pattern": "...", "category": "...", + "description": "...", "reply_template": "..."}, ... + ]} + + On success returns the new entries (echoed verbatim so the + cockpit can refresh from the response) + the backup + filename written (if any) + the source path. + """ + from kora_cli.audit import emit_audit + from kora_cli.short_circuit import phrasebook_editor + + entries = payload.get("entries") if isinstance(payload, dict) else None + errors = phrasebook_editor.validate_entries(entries) + if errors: + return JSONResponse( + status_code=422, + content={ + "error": "validation_failed", + "errors": [e.as_dict() for e in errors], + }, + ) + + typed_entries: List[Dict[str, Any]] = entries # type: ignore[assignment] + count_before = _phrasebook_count_current_entries() + + override_path = phrasebook_editor._override_path() + backup_path = phrasebook_editor.write_backup_for(override_path) + + try: + phrasebook_editor.write_phrasebook(typed_entries) + except Exception as exc: + logger.warning( + "[kora.phrasebook] write_phrasebook raised %r — backup " + "preserved at %s, override unchanged", + exc, + backup_path, + ) + return JSONResponse( + status_code=500, + content={ + "error": "write_failed", + "detail": repr(exc), + "backup_filename": ( + backup_path.name if backup_path is not None else None + ), + }, + ) + + keep = phrasebook_editor._backup_keep_count() + try: + rotated = phrasebook_editor.rotate_backups(keep) + except Exception as exc: + logger.warning( + "[kora.phrasebook] backup rotation raised %r — write " + "succeeded; rotation will catch up next write", + exc, + ) + rotated = [] + + try: + emit_audit( + seam="phrasebook.updated", + details={ + "actor": "operator", + "action": "put", + "entry_count_before": count_before, + "entry_count_after": len(typed_entries), + "backup_filename": ( + backup_path.name if backup_path is not None else None + ), + "rotated_backup_count": len(rotated), + }, + source=None, + ) + except Exception as exc: + logger.warning( + "[kora.phrasebook] audit emit_audit raised %r — write " + "still succeeded", + exc, + ) + + return { + "source_path": str(override_path), + "entry_count": len(typed_entries), + "backup_filename": ( + backup_path.name if backup_path is not None else None + ), + "rotated_backup_count": len(rotated), + "entries": [ + { + "pattern": e["pattern"], + "category": e["category"], + "description": e["description"], + "reply_template": e["reply_template"], + "referenced_snapshot_fields": ( + _extract_phrasebook_snapshot_refs(e["reply_template"]) + ), + } + for e in typed_entries + ], + } + + +@app.post("/api/phrasebook/slack_dm/revert") +async def revert_phrasebook_endpoint( + payload: Optional[Dict[str, Any]] = None, +) -> Any: + """Revert the override to a specific backup OR (when no + filename supplied) the most-recent backup OR (when no backups + exist) remove the override entirely. + + Payload (all optional): ``{"filename": "slack_dm.YYYY-...Z.yml"}`` + + Defense against path traversal lives in + phrasebook_editor.revert_phrasebook — filename must match the + slack_dm.*.yml shape with no path separators. + """ + from kora_cli.audit import emit_audit + from kora_cli.short_circuit import phrasebook_editor + + filename: Optional[str] = None + if isinstance(payload, dict): + raw = payload.get("filename") + if isinstance(raw, str) and raw: + filename = raw + + count_before = _phrasebook_count_current_entries() + + try: + result = phrasebook_editor.revert_phrasebook(filename=filename) + except ValueError as exc: + return JSONResponse( + status_code=400, + content={"error": "invalid_filename", "detail": str(exc)}, + ) + except FileNotFoundError as exc: + return JSONResponse( + status_code=404, + content={"error": "backup_not_found", "detail": str(exc)}, + ) + except Exception as exc: + logger.warning("[kora.phrasebook] revert raised %r", exc) + return JSONResponse( + status_code=500, + content={"error": "revert_failed", "detail": repr(exc)}, + ) + + count_after = _phrasebook_count_current_entries() + + try: + emit_audit( + seam="phrasebook.updated", + details={ + "actor": "operator", + "action": "revert", + "entry_count_before": count_before, + "entry_count_after": count_after, + "reverted_to": result.get("reverted_to"), + }, + source=None, + ) + except Exception as exc: + logger.warning( + "[kora.phrasebook] revert audit emit_audit raised %r", exc + ) + + return result + + +@app.get("/api/phrasebook/slack_dm/backups") +async def get_phrasebook_backups() -> Dict[str, Any]: + """Newest-first list of available backups, for the cockpit + revert dropdown. Each entry: filename / timestamp / + size_bytes / entry_count (None when the backup can't be + parsed, so the cockpit can grey out corrupt backups instead + of pretending they're valid revert targets).""" + from kora_cli.short_circuit import phrasebook_editor + + return { + "backups": phrasebook_editor.list_backups(), + "rotation_keep": phrasebook_editor._backup_keep_count(), + } + + # --------------------------------------------------------------------------- # Probe investigations xref — KR-FE-PROBE-INVESTIGATION-VIEWER # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_phrasebook_editor.py b/tests/kora_cli/test_phrasebook_editor.py new file mode 100644 index 000000000000..a79fc67a416a --- /dev/null +++ b/tests/kora_cli/test_phrasebook_editor.py @@ -0,0 +1,687 @@ +"""KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — backend tests. + +Covers the write path added on top of PR #167's read-only viewer: + + Validation (kora_cli/short_circuit/phrasebook_editor.py): + 1. Valid entry list → no errors + 2. Non-list payload → root error + 3. Missing field → per-field error + 4. Empty field (whitespace) → per-field error + 5. Pattern length cap + 6. Reply template length cap + 7. Description / category length caps + 8. Entries count cap (MAX_ENTRIES_PER_PHRASEBOOK) + 9. Invalid regex → pattern error + 10. Catastrophic-backtracking guard catches (x+)+, (.*)* + 11. Snapshot path not in static schema → reply_template error + 12. Valid snapshot path accepted + 13. Duplicate (pattern, category) → root error pointing at first + 14. Schema set matches snapshot collectors (drift guard) + + Backup discipline: + 15. write_backup_for with no override → returns None (first edit) + 16. write_backup_for copies content + timestamps filename + 17. rotate_backups keeps N most recent + 18. KORA_PHRASEBOOK_BACKUP_COUNT env override read + clamped + 19. list_backups returns newest-first with entry_count + + Write + revert: + 20. write_phrasebook serializes deterministic YAML + uses atomic_replace + 21. revert with named backup restores its content + 22. revert with no filename takes most-recent backup + 23. revert with no backups removes the override entirely + 24. revert refuses path-traversal filenames + + Endpoints: + 25. PUT valid → 200 + override written + backup + audit row + 26. PUT invalid → 422; no write; previous content preserved + 27. PUT with no existing override → no backup but still writes + 28. PUT write_failed audit not emitted (failure path) + 29. POST revert → 200 + correct reverted_to + audit row + 30. POST revert invalid filename → 400 + 31. POST revert missing backup → 404 + 32. GET backups → newest-first list + + Audit seam: + 33. SeamName Literal includes phrasebook.updated +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List + +import pytest + +from tests.kora_cli._panel_test_helpers import isolated_kora_home + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_EDITOR_PY = ( + _REPO_ROOT / "kora_cli" / "short_circuit" / "phrasebook_editor.py" +) +_SNAPSHOT_PY = _REPO_ROOT / "kora_cli" / "snapshot" / "state_snapshot.py" +_JSONL_SINK = _REPO_ROOT / "kora_cli" / "audit" / "jsonl_sink.py" + + +@pytest.fixture +def env(tmp_path, monkeypatch): + return isolated_kora_home(tmp_path, monkeypatch) + + +def _valid_entry( + pattern: str = r"^hello$", + category: str = "greeting", + description: str = "test", + reply_template: str = "Hi!", +) -> Dict[str, Any]: + return { + "pattern": pattern, + "category": category, + "description": description, + "reply_template": reply_template, + } + + +# --------------------------------------------------------------------------- +# 1-13. Validation +# --------------------------------------------------------------------------- + + +def test_valid_entry_list_no_errors(): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries([_valid_entry()]) + assert errors == [] + + +def test_non_list_payload_root_error(): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries("not a list") + assert len(errors) == 1 + assert errors[0].entry_index == -1 + assert errors[0].field == "_root" + + +def test_missing_field_per_field_error(): + from kora_cli.short_circuit import phrasebook_editor + + bad = {"pattern": "x", "category": "y"} + errors = phrasebook_editor.validate_entries([bad]) + fields = {e.field for e in errors} + assert "description" in fields + assert "reply_template" in fields + + +def test_empty_whitespace_field_error(): + from kora_cli.short_circuit import phrasebook_editor + + bad = _valid_entry(pattern=" ") + errors = phrasebook_editor.validate_entries([bad]) + assert any(e.field == "pattern" for e in errors) + + +def test_pattern_length_cap(): + from kora_cli.short_circuit import phrasebook_editor + + long_pattern = "a" * (phrasebook_editor.MAX_PATTERN_LENGTH + 1) + errors = phrasebook_editor.validate_entries( + [_valid_entry(pattern=long_pattern)] + ) + assert any( + e.field == "pattern" and "exceeds" in e.error for e in errors + ) + + +def test_reply_template_length_cap(): + from kora_cli.short_circuit import phrasebook_editor + + long_reply = "a" * (phrasebook_editor.MAX_REPLY_TEMPLATE_LENGTH + 1) + errors = phrasebook_editor.validate_entries( + [_valid_entry(reply_template=long_reply)] + ) + assert any( + e.field == "reply_template" and "exceeds" in e.error for e in errors + ) + + +def test_description_and_category_length_caps(): + from kora_cli.short_circuit import phrasebook_editor + + long_desc = "x" * (phrasebook_editor.MAX_DESCRIPTION_LENGTH + 1) + long_cat = "y" * (phrasebook_editor.MAX_CATEGORY_LENGTH + 1) + errors = phrasebook_editor.validate_entries( + [_valid_entry(description=long_desc, category=long_cat)] + ) + fields = {e.field for e in errors} + assert "description" in fields + assert "category" in fields + + +def test_entries_count_cap(): + from kora_cli.short_circuit import phrasebook_editor + + too_many = [ + _valid_entry(pattern=f"^p{i}$", category=f"c{i}") + for i in range(phrasebook_editor.MAX_ENTRIES_PER_PHRASEBOOK + 1) + ] + errors = phrasebook_editor.validate_entries(too_many) + assert any( + e.entry_index == -1 and "too many" in e.error for e in errors + ) + + +def test_invalid_regex_error(): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries( + [_valid_entry(pattern="(unclosed")] + ) + assert any( + e.field == "pattern" and "invalid regex" in e.error for e in errors + ) + + +@pytest.mark.parametrize( + "pathological", + [ + r"(a+)+", + r"(.*)*", + r"(\w*)?", + r"(.+)+", + ], +) +def test_catastrophic_backtracking_guard(pathological): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries( + [_valid_entry(pattern=pathological)] + ) + assert any( + e.field == "pattern" and "backtracking" in e.error for e in errors + ), f"pattern {pathological!r} should be flagged" + + +def test_unknown_snapshot_path_rejected(): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries( + [ + _valid_entry( + reply_template="Hello {snapshot.totally.not.real.field}!" + ) + ] + ) + assert any( + e.field == "reply_template" and "not in known scalar schema" in e.error + for e in errors + ) + + +def test_known_snapshot_path_accepted(): + from kora_cli.short_circuit import phrasebook_editor + + errors = phrasebook_editor.validate_entries( + [ + _valid_entry( + reply_template=( + "Burn: {snapshot.cost_ladder.spent_to_date_usd} / " + "{snapshot.cost_ladder.credit_pool_usd}" + ) + ) + ] + ) + assert errors == [] + + +def test_duplicate_pattern_category_pair_rejected(): + from kora_cli.short_circuit import phrasebook_editor + + entries = [ + _valid_entry(pattern="^hi$", category="greeting"), + _valid_entry( + pattern="^hi$", + category="greeting", + description="dup", + reply_template="hi", + ), + ] + errors = phrasebook_editor.validate_entries(entries) + assert any( + e.entry_index == 1 + and e.field == "_root" + and "duplicate" in e.error + for e in errors + ) + + +def test_static_schema_matches_snapshot_collectors(): + """Drift guard: SNAPSHOT_SCALAR_PATHS in phrasebook_editor must + name fields that are actually populated by the snapshot + collector functions in state_snapshot.py. If the snapshot adds + a new scalar field (e.g. cost_ladder.foo) without us updating + the allow-list, operator templates referencing it would be + rejected. If state_snapshot RENAMES a field without us + updating, accepted templates would silently fall through at + runtime. This test catches the rename case by greping the + snapshot source for each declared scalar key.""" + from kora_cli.short_circuit import phrasebook_editor + + snap_src = _SNAPSHOT_PY.read_text() + # The allow-list uses dotted paths; the snapshot source + # writes them as nested dict keys. For each path, the LEAF + # key must appear in the snapshot source. (Catches rename; + # doesn't catch deeper structural moves — those'd need a + # round-trip snapshot build, which is heavier than warranted + # for a v1 drift guard.) + for path in phrasebook_editor.SNAPSHOT_SCALAR_PATHS: + leaf = path.split(".")[-1] + # Top-level metadata (computed_at / schema_version) live + # directly in the build dict — also pinned. + assert f'"{leaf}"' in snap_src, ( + f"SNAPSHOT_SCALAR_PATHS path '{path}' leaf '{leaf}' not " + f"found in {_SNAPSHOT_PY} — drift between allow-list " + f"and snapshot collectors" + ) + + +# --------------------------------------------------------------------------- +# 15-19. Backups +# --------------------------------------------------------------------------- + + +def test_write_backup_for_no_override_returns_none(env): + from kora_cli.short_circuit import phrasebook_editor + + override = phrasebook_editor._override_path() + assert phrasebook_editor.write_backup_for(override) is None + + +def test_write_backup_for_copies_content_and_timestamps(env): + from kora_cli.short_circuit import phrasebook_editor + + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text("entries:\n - pattern: hi\n", encoding="utf-8") + + bkp = phrasebook_editor.write_backup_for(override) + assert bkp is not None + assert bkp.exists() + assert bkp.read_text() == override.read_text() + # Filename: slack_dm.{ISO}.yml + assert re.match( + r"slack_dm\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:-\d+)?\.yml", + bkp.name, + ) + + +def test_rotate_backups_keeps_n_most_recent(env): + from kora_cli.short_circuit import phrasebook_editor + + bkp_dir = phrasebook_editor._backup_dir() + bkp_dir.mkdir(parents=True, exist_ok=True) + # Names sort chronologically as plain strings. + names = [ + "slack_dm.2026-01-01T00-00-00Z.yml", + "slack_dm.2026-01-02T00-00-00Z.yml", + "slack_dm.2026-01-03T00-00-00Z.yml", + "slack_dm.2026-01-04T00-00-00Z.yml", + "slack_dm.2026-01-05T00-00-00Z.yml", + ] + for n in names: + (bkp_dir / n).write_text("entries: []\n", encoding="utf-8") + removed = phrasebook_editor.rotate_backups(keep=3) + assert len(removed) == 2 + remaining = sorted(p.name for p in bkp_dir.glob("slack_dm.*.yml")) + assert remaining == names[-3:] + + +@pytest.mark.parametrize( + "envval,expected", + [ + ("", 10), # default + ("5", 5), + ("not-a-number", 10), + ("0", 1), # clamp lower + ("99999", 1000), # clamp upper + ], +) +def test_backup_keep_count_env_clamping(envval, expected, monkeypatch): + from kora_cli.short_circuit import phrasebook_editor + + if envval: + monkeypatch.setenv(phrasebook_editor.BACKUP_KEEP_ENV, envval) + else: + monkeypatch.delenv(phrasebook_editor.BACKUP_KEEP_ENV, raising=False) + assert phrasebook_editor._backup_keep_count() == expected + + +def test_list_backups_returns_newest_first_with_entry_count(env): + from kora_cli.short_circuit import phrasebook_editor + + bkp_dir = phrasebook_editor._backup_dir() + bkp_dir.mkdir(parents=True, exist_ok=True) + (bkp_dir / "slack_dm.2026-01-01T00-00-00Z.yml").write_text( + "entries:\n - pattern: a\n category: c\n description: d\n" + " reply_template: r\n", + encoding="utf-8", + ) + (bkp_dir / "slack_dm.2026-01-02T00-00-00Z.yml").write_text( + "entries:\n - pattern: a\n category: c\n description: d\n" + " reply_template: r\n - pattern: b\n category: c\n" + " description: d\n reply_template: r\n", + encoding="utf-8", + ) + backups = phrasebook_editor.list_backups() + assert [b["filename"] for b in backups] == [ + "slack_dm.2026-01-02T00-00-00Z.yml", + "slack_dm.2026-01-01T00-00-00Z.yml", + ] + assert backups[0]["entry_count"] == 2 + assert backups[1]["entry_count"] == 1 + + +# --------------------------------------------------------------------------- +# 20-24. Write + revert +# --------------------------------------------------------------------------- + + +def test_write_phrasebook_produces_loadable_yaml(env): + from kora_cli.short_circuit import dm_phrasebook, phrasebook_editor + + entries = [_valid_entry()] + override = phrasebook_editor.write_phrasebook(entries) + assert override.is_file() + # Round-trip: the same load_phrasebook the live handler uses + # must read what we just wrote. + loaded = dm_phrasebook.load_phrasebook() + assert len(loaded) == 1 + assert loaded[0].pattern.pattern == "^hello$" + + +def test_revert_with_named_backup_restores(env): + from kora_cli.short_circuit import phrasebook_editor + + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text("entries:\n - pattern: original\n", encoding="utf-8") + bkp = phrasebook_editor.write_backup_for(override) + # Now overwrite the override + override.write_text("entries:\n - pattern: changed\n", encoding="utf-8") + # Revert by name + result = phrasebook_editor.revert_phrasebook(filename=bkp.name) + assert result["reverted_to"] == bkp.name + assert "original" in override.read_text() + + +def test_revert_with_no_filename_takes_most_recent(env): + from kora_cli.short_circuit import phrasebook_editor + + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text("entries:\n - pattern: v1\n", encoding="utf-8") + bkp_dir = phrasebook_editor._backup_dir() + bkp_dir.mkdir(parents=True, exist_ok=True) + older = bkp_dir / "slack_dm.2026-01-01T00-00-00Z.yml" + newer = bkp_dir / "slack_dm.2026-01-02T00-00-00Z.yml" + older.write_text("entries:\n - pattern: older\n", encoding="utf-8") + newer.write_text("entries:\n - pattern: newer\n", encoding="utf-8") + result = phrasebook_editor.revert_phrasebook(filename=None) + assert result["reverted_to"] == newer.name + assert "newer" in override.read_text() + + +def test_revert_with_no_backups_removes_override(env): + from kora_cli.short_circuit import phrasebook_editor + + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text("entries:\n - pattern: v1\n", encoding="utf-8") + result = phrasebook_editor.revert_phrasebook(filename=None) + assert result["reverted_to"] == "bundled_default" + assert result["source_path"] is None + assert not override.exists() + + +@pytest.mark.parametrize( + "bad_filename", + [ + "../../../etc/passwd", + "/etc/passwd", + "slack_dm/../passwd.yml", + "not_a_phrasebook.yml", + "slack_dm.foo.txt", + ], +) +def test_revert_refuses_path_traversal(env, bad_filename): + from kora_cli.short_circuit import phrasebook_editor + + with pytest.raises(ValueError): + phrasebook_editor.revert_phrasebook(filename=bad_filename) + + +# --------------------------------------------------------------------------- +# 25-32. Endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_put_valid_writes_and_emits_audit(env): + from kora_cli import web_server + from kora_cli.short_circuit import phrasebook_editor + + payload = {"entries": [_valid_entry()]} + result = await web_server.put_phrasebook(payload) + # Successful response is a dict (not a JSONResponse). + assert isinstance(result, dict) + assert result["entry_count"] == 1 + assert result["source_path"] == str(phrasebook_editor._override_path()) + + # Override file written. + assert phrasebook_editor._override_path().is_file() + + # Audit row emitted. + audit_log = env / "kora_audit_log.jsonl" + assert audit_log.is_file() + lines = [ + json.loads(line) + for line in audit_log.read_text().splitlines() + if line.strip() + ] + seams = {e.get("seam") for e in lines} + assert "phrasebook.updated" in seams + pb_audit = [e for e in lines if e.get("seam") == "phrasebook.updated"][0] + assert pb_audit["details"]["actor"] == "operator" + assert pb_audit["details"]["action"] == "put" + assert pb_audit["details"]["entry_count_after"] == 1 + + +@pytest.mark.asyncio +async def test_put_invalid_preserves_previous_content(env): + from fastapi.responses import JSONResponse + from kora_cli.short_circuit import phrasebook_editor + from kora_cli import web_server + + # Seed an existing override the operator should not lose. + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + seed_yaml = ( + "entries:\n" + " - pattern: '^seed$'\n" + " category: seed_cat\n" + " description: seed\n" + " reply_template: seed\n" + ) + override.write_text(seed_yaml, encoding="utf-8") + before = override.read_text() + + bad_payload = { + "entries": [_valid_entry(pattern="(unclosed")] + } + result = await web_server.put_phrasebook(bad_payload) + assert isinstance(result, JSONResponse) + assert result.status_code == 422 + body = json.loads(result.body.decode()) + assert body["error"] == "validation_failed" + assert any(e["field"] == "pattern" for e in body["errors"]) + # Override unchanged. + assert override.read_text() == before + + +@pytest.mark.asyncio +async def test_put_no_existing_override_no_backup_but_writes(env): + from kora_cli import web_server + from kora_cli.short_circuit import phrasebook_editor + + payload = {"entries": [_valid_entry()]} + result = await web_server.put_phrasebook(payload) + assert isinstance(result, dict) + # No previous override → backup_filename should be None + assert result["backup_filename"] is None + # But override file is now written + assert phrasebook_editor._override_path().is_file() + + +@pytest.mark.asyncio +async def test_post_revert_with_backup(env): + from kora_cli import web_server + from kora_cli.short_circuit import phrasebook_editor + + # Seed override + make a backup of it + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + override.write_text( + "entries:\n - pattern: original\n category: c\n" + " description: d\n reply_template: r\n", + encoding="utf-8", + ) + bkp = phrasebook_editor.write_backup_for(override) + # Overwrite override + override.write_text( + "entries:\n - pattern: changed\n category: c\n" + " description: d\n reply_template: r\n", + encoding="utf-8", + ) + # Revert via endpoint + result = await web_server.revert_phrasebook_endpoint( + {"filename": bkp.name} + ) + assert isinstance(result, dict) + assert result["reverted_to"] == bkp.name + assert "original" in override.read_text() + # Audit emitted + audit = env / "kora_audit_log.jsonl" + lines = [json.loads(line) for line in audit.read_text().splitlines() if line.strip()] + assert any( + e.get("seam") == "phrasebook.updated" + and e["details"].get("action") == "revert" + for e in lines + ) + + +@pytest.mark.asyncio +async def test_post_revert_invalid_filename_400(env): + from fastapi.responses import JSONResponse + from kora_cli import web_server + + result = await web_server.revert_phrasebook_endpoint( + {"filename": "../../etc/passwd"} + ) + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + + +@pytest.mark.asyncio +async def test_post_revert_missing_backup_404(env): + from fastapi.responses import JSONResponse + from kora_cli import web_server + + result = await web_server.revert_phrasebook_endpoint( + {"filename": "slack_dm.2099-01-01T00-00-00Z.yml"} + ) + assert isinstance(result, JSONResponse) + assert result.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_backups_newest_first(env): + from kora_cli import web_server + from kora_cli.short_circuit import phrasebook_editor + + bkp_dir = phrasebook_editor._backup_dir() + bkp_dir.mkdir(parents=True, exist_ok=True) + (bkp_dir / "slack_dm.2026-01-01T00-00-00Z.yml").write_text( + "entries: []\n", encoding="utf-8" + ) + (bkp_dir / "slack_dm.2026-01-02T00-00-00Z.yml").write_text( + "entries: []\n", encoding="utf-8" + ) + result = await web_server.get_phrasebook_backups() + assert [b["filename"] for b in result["backups"]] == [ + "slack_dm.2026-01-02T00-00-00Z.yml", + "slack_dm.2026-01-01T00-00-00Z.yml", + ] + assert isinstance(result["rotation_keep"], int) + + +# --------------------------------------------------------------------------- +# 33. Audit seam Literal +# --------------------------------------------------------------------------- + + +def test_seam_name_literal_includes_phrasebook_updated(): + """The audit seam Literal at kora_cli/audit/jsonl_sink.py must + declare phrasebook.updated as a valid seam — otherwise + emit_audit raises ValidationError and the audit silently + drops (only the structured-log line survives).""" + src = _JSONL_SINK.read_text() + assert '"phrasebook.updated"' in src + + +# --------------------------------------------------------------------------- +# Bonus — full round-trip integration test +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_round_trip_put_then_revert_restores_seed(env): + """Operator scenario: write a phrasebook, then revert it. + Reverted content should match what was there pre-write.""" + from kora_cli import web_server + from kora_cli.short_circuit import phrasebook_editor + + # Seed an existing override + override = phrasebook_editor._override_path() + override.parent.mkdir(parents=True, exist_ok=True) + seed_yaml = ( + "entries:\n" + " - pattern: '^seed$'\n" + " category: c\n description: d\n" + " reply_template: seed_reply\n" + ) + override.write_text(seed_yaml, encoding="utf-8") + + # PUT new content (triggers backup of seed) + new_payload = { + "entries": [ + _valid_entry( + pattern="^new$", + category="c", + description="d", + reply_template="new_reply", + ) + ] + } + put_result = await web_server.put_phrasebook(new_payload) + assert isinstance(put_result, dict) + backup_name = put_result["backup_filename"] + assert backup_name is not None + + # Revert (no filename → most-recent backup, which is the seed) + revert_result = await web_server.revert_phrasebook_endpoint(None) + assert isinstance(revert_result, dict) + assert revert_result["reverted_to"] == backup_name + assert "seed_reply" in override.read_text() diff --git a/tests/kora_cli/test_phrasebook_editor_fe_pins.py b/tests/kora_cli/test_phrasebook_editor_fe_pins.py new file mode 100644 index 000000000000..3a83fabdd0a9 --- /dev/null +++ b/tests/kora_cli/test_phrasebook_editor_fe_pins.py @@ -0,0 +1,258 @@ +"""FE source-pin tests for KR-FE-PHRASEBOOK-EDITOR-AND-CRUD. + +The endpoint + module behavior is covered in +test_phrasebook_editor.py (44 tests). This file pins the FE +wiring that's hard to test without a browser: + + 1. api wrappers: putSlackDmPhrasebook + revertSlackDmPhrasebook + + getSlackDmPhrasebookBackups all exist + post to the right + URLs with the right verbs + 2. TS types declared: PhrasebookEntryWrite / PhrasebookPutResponse + / PhrasebookValidationErrorEntry / PhrasebookValidationErrorBody + / PhrasebookRevertResponse / PhrasebookBackupItem / + PhrasebookBackupsResponse + 3. PhrasebookEditor.tsx file exists with the expected exports + 4. PhrasebookPage delegates to the editor in edit-mode + 5. Edit-mode controls wired: Edit button + Backups button + + Save / Cancel / Add visible only in edit-mode + 6. Validation-error display: 422 body parsing branch present + 7. ClientSidePreview mirrors the backend regex + walk semantics + (case-insensitive regex + "unknown" sentinel triggers + fall-through) + 8. SnapshotResponse TS type already declares the v4 sections + PhrasebookEditor's static validation references +""" + +import re +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_API_TS = _REPO_ROOT / "web" / "src" / "lib" / "api.ts" +_PAGE = _REPO_ROOT / "web" / "src" / "pages" / "PhrasebookPage.tsx" +_EDITOR = _REPO_ROOT / "web" / "src" / "pages" / "PhrasebookEditor.tsx" + + +# --------------------------------------------------------------------------- +# 1. api wrappers +# --------------------------------------------------------------------------- + + +def test_put_wrapper_exists(): + src = _API_TS.read_text() + assert re.search( + r'putSlackDmPhrasebook:\s*\(entries:\s*PhrasebookEntryWrite\[\]\)', + src, + ) + # Verb + URL pin — without this, the wrapper would GET (which + # would 405) or hit a different path. + assert ( + '"/api/phrasebook/slack_dm"' in src + and '"PUT"' in src + ) + + +def test_revert_wrapper_exists(): + src = _API_TS.read_text() + assert "revertSlackDmPhrasebook:" in src + assert '"/api/phrasebook/slack_dm/revert"' in src + assert '"POST"' in src + + +def test_backups_list_wrapper_exists(): + src = _API_TS.read_text() + assert "getSlackDmPhrasebookBackups:" in src + assert '"/api/phrasebook/slack_dm/backups"' in src + + +# --------------------------------------------------------------------------- +# 2. TS types declared +# --------------------------------------------------------------------------- + + +def test_write_types_declared(): + src = _API_TS.read_text() + for ts_type in ( + "PhrasebookEntryWrite", + "PhrasebookPutResponse", + "PhrasebookValidationErrorEntry", + "PhrasebookValidationErrorBody", + "PhrasebookRevertResponse", + "PhrasebookBackupItem", + "PhrasebookBackupsResponse", + ): + assert f"export interface {ts_type}" in src or ( + f"export type {ts_type}" in src + ), f"missing TS type: {ts_type}" + + +def test_validation_error_shape_matches_backend(): + """PhrasebookValidationErrorEntry must have entry_index + + field + error to match the backend's EntryValidationError. + as_dict() shape. Drift here makes the editor render wrong + fields' errors.""" + src = _API_TS.read_text() + # Find the interface block + check the 3 fields appear within + # ~20 lines of the declaration. + m = re.search( + r"export interface PhrasebookValidationErrorEntry\s*\{([^}]+)\}", + src, + ) + assert m is not None + body = m.group(1) + assert "entry_index" in body + assert "field" in body + assert "error" in body + + +# --------------------------------------------------------------------------- +# 3. PhrasebookEditor.tsx +# --------------------------------------------------------------------------- + + +def test_editor_file_exists_and_exports(): + assert _EDITOR.is_file() + src = _EDITOR.read_text() + for export in ( + "EntryEditorRow", + "EditModeControls", + "BackupsDialog", + "ClientSidePreview", + "makeEmptyEntry", + "toEditableEntries", + "clientSidePreview", + ): + assert f"export function {export}" in src or ( + f"export const {export}" in src + or f"export interface {export}" in src + or f"export type {export}" in src + ), f"missing export: {export}" + + +# --------------------------------------------------------------------------- +# 4. PhrasebookPage delegates to editor in edit-mode +# --------------------------------------------------------------------------- + + +def test_page_imports_editor_components(): + src = _PAGE.read_text() + for import_name in ( + "BackupsDialog", + "ClientSidePreview", + "EditModeControls", + "EntryEditorRow", + "makeEmptyEntry", + "toEditableEntries", + ): + assert import_name in src, f"PhrasebookPage missing import: {import_name}" + + +def test_page_has_edit_mode_state(): + src = _PAGE.read_text() + # editingEntries === null ≡ view mode (a discriminator the + # render branch checks). Pin the literal so a refactor doesn't + # change the discriminator and silently break the render. + assert "editingEntries" in src + assert "editingEntries === null" in src + + +def test_page_renders_editor_rows_in_edit_mode(): + src = _PAGE.read_text() + assert "", + src, + ), "Edit/Backups buttons must be inside an editingEntries===null guard" + + +# --------------------------------------------------------------------------- +# 6. Validation-error display: 422 body parsing branch present +# --------------------------------------------------------------------------- + + +def test_page_parses_422_validation_body(): + """fetchJSON throws Error('STATUS: BODY') on non-2xx. The + Save handler must parse 422 specially to surface per-field + errors back to the editor rows. Without this branch, the + user sees a generic 'Save failed' toast and no inline errors.""" + src = _PAGE.read_text() + # Pin the 422 detection + JSON parse + assert '"422"' in src or "'422'" in src + assert "validation_failed" in src + assert "setValidationErrors" in src + + +# --------------------------------------------------------------------------- +# 7. ClientSidePreview mirrors backend semantics +# --------------------------------------------------------------------------- + + +def test_client_side_preview_uses_case_insensitive_regex(): + """dm_phrasebook compiles with re.IGNORECASE; the FE preview + must mirror so what the operator sees in the preview matches + what the live handler will do.""" + src = _EDITOR.read_text() + assert re.search(r'new RegExp\([^,]+,\s*"i"\)', src), ( + "ClientSidePreview must use 'i' flag (case-insensitive) " + "to match dm_phrasebook's re.IGNORECASE" + ) + + +def test_client_side_preview_falls_through_on_unknown_sentinel(): + """Mirrors render_reply at dm_phrasebook.py:293 — value == + "unknown" triggers fall-through. Pin the literal so a typo + later doesn't silently change the FE preview from the runtime.""" + src = _EDITOR.read_text() + assert ( + 'value === "unknown"' in src + or "value === 'unknown'" in src + ) + + +def test_client_side_preview_falls_through_on_null_snapshot(): + """Mirrors render_reply at dm_phrasebook.py:285-286 — null + snapshot triggers UNIVERSAL fall-through (even for templates + with no placeholders). Without this branch, the FE preview + would falsely show "matched + rendered" for a null snapshot + when the live handler would actually fall through.""" + src = _EDITOR.read_text() + assert "snapshot === null" in src + assert "snapshot unavailable" in src.lower() + + +# --------------------------------------------------------------------------- +# 8. SnapshotResponse type already covers v4 — referenced by the +# static SNAPSHOT_SCALAR_PATHS allow-list on the BE side; if the +# TS type drifts the FE preview won't be able to read the same +# paths the backend validation accepts. +# --------------------------------------------------------------------------- + + +def test_snapshot_response_includes_daemon_health_for_template_paths(): + """The static SNAPSHOT_SCALAR_PATHS allow-list on the BE + includes daemon_health.* paths. SnapshotResponse TS type must + have a corresponding daemon_health section so the FE preview + can walk these paths against the live snapshot.""" + src = _API_TS.read_text() + assert "daemon_health" in src + assert "overall_status" in src + assert "uptime_seconds" in src diff --git a/web/docs/phrasebook-editor/_styles.css b/web/docs/phrasebook-editor/_styles.css new file mode 100644 index 000000000000..3f0a9ce64369 --- /dev/null +++ b/web/docs/phrasebook-editor/_styles.css @@ -0,0 +1,53 @@ +:root { + --bg: #0a0a0a; --fg: #ededed; --muted: #8b8b8b; + --card: #131313; --border: #2a2a2a; + --success: #4ade80; --warning: #facc15; --destructive: #f87171; + --primary: #60a5fa; +} +body { background: var(--bg); color: var(--fg); + font-family: -apple-system, ui-sans-serif, system-ui, sans-serif; + padding: 28px; 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: 14px; } +.card { background: var(--card); border: 1px solid var(--border); + border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; } +.card.destructive { border-color: rgba(248, 113, 113, 0.4); } +.card.warning { border-color: rgba(250, 204, 21, 0.4); } +.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; } +.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); } +.btn { padding: 5px 12px; 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 { border-color: var(--primary); background: var(--primary); color: #001; } +.btn.destructive { border-color: var(--destructive); color: var(--destructive); background: rgba(248,113,113,0.08); } +.btn.outlined { border-color: var(--border); } +.input, .textarea { width: 100%; padding: 4px 8px; font-size: 11px; + border: 1px solid var(--border); border-radius: 4px; + background: var(--card); color: var(--fg); + font-family: ui-monospace, monospace; } +.input.error, .textarea.error { border-color: rgba(248,113,113,0.6); } +.textarea { min-height: 44px; resize: vertical; } +.label-tiny { font-size: 9px; color: var(--muted); text-transform: uppercase; + letter-spacing: 0.04em; margin-bottom: 2px; display: block; } +.field-error { font-size: 10px; color: var(--destructive); font-style: italic; margin-top: 2px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.page-header { display: flex; justify-content: space-between; gap: 12px; + align-items: flex-start; margin-bottom: 14px; flex-wrap: wrap; } +.page-header .title { font-size: 18px; font-weight: 600; } +.page-header .desc { font-size: 12px; color: var(--muted); max-width: 600px; margin-top: 4px; } +.preview-result { padding: 6px 8px; border-radius: 6px; border: 1px solid; font-size: 11px; } +.preview-result.success { border-color: rgba(74,222,128,0.4); background: rgba(74,222,128,0.05); } +.preview-render { padding: 6px 8px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); + border-radius: 4px; font-family: ui-monospace, monospace; font-size: 11px; + margin-top: 4px; white-space: pre-wrap; } +.modal-mock { padding: 16px; border-radius: 8px; max-width: 720px; background: var(--card); + border: 1px solid var(--border); margin: 24px auto; box-shadow: 0 6px 32px rgba(0,0,0,0.5); } +.modal-mock .backup-row { padding: 8px; border: 1px solid var(--border); border-radius: 6px; + margin-bottom: 4px; display: flex; gap: 8px; align-items: center; font-size: 11px; } +.modal-mock .backup-row.corrupt { opacity: 0.5; } diff --git a/web/docs/phrasebook-editor/edit_mode.html b/web/docs/phrasebook-editor/edit_mode.html new file mode 100644 index 000000000000..7b1ed87d637f --- /dev/null +++ b/web/docs/phrasebook-editor/edit_mode.html @@ -0,0 +1,106 @@ + +Edit mode + + +

Phrasebook editor — edit mode active

+

+ Operator clicked "Edit phrasebook". Entries become editable; live tester swaps to + client-side preview (mirrors dm_phrasebook match + render). Save / Cancel / Add entry + buttons appear above and below the entries list. +

+ + + +
+
+ Source: + 📝 operator override + /Users/Apple/.kora/phrasebook/slack_dm.yml +
+
+ +
+
↻ Preview against in-progress edits (client-side)
+
+ + +
+
+ ✓ Matched · category burn_query · $0 reply +
2.5% of monthly budget used. Tier: normal.
+
+
+ Mirrors dm_phrasebook.match_message + render_reply; preview uses in-progress edits, not the saved phrasebook. +
+
+ +
+ + + +
+ +
+
+
+
+ Pattern (Python regex, case-insensitive) + +
+
+ Category + +
+
+ Description + +
+
+ Reply template (supports {snapshot.X.Y} placeholders) + +
+
+ +
+
+ +
+
+
+
+ Pattern + +
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
+
+ +
+
+ +
+ + + +
+ + diff --git a/web/docs/phrasebook-editor/edit_mode.png b/web/docs/phrasebook-editor/edit_mode.png new file mode 100644 index 000000000000..718c59fa78ed Binary files /dev/null and b/web/docs/phrasebook-editor/edit_mode.png differ diff --git a/web/docs/phrasebook-editor/preview.html b/web/docs/phrasebook-editor/preview.html new file mode 100644 index 000000000000..d6fb85d7bbc1 --- /dev/null +++ b/web/docs/phrasebook-editor/preview.html @@ -0,0 +1,313 @@ + + + + + + Kora — PhrasebookEditor flows (KR-FE-PHRASEBOOK-EDITOR-AND-CRUD) + + + + + +

Phrasebook editor — state 1: edit mode active

+

+ Operator clicked "Edit phrasebook". Entries become editable; live tester swaps to + client-side preview (mirrors dm_phrasebook match + render). Save / Cancel / Add entry buttons appear. +

+ + + + +
+
+ Source: + 📝 operator override + /Users/Apple/.kora/phrasebook/slack_dm.yml +
+
+ + +
+
↻ Preview against in-progress edits (client-side)
+
+ + +
+
+ ✓ Matched · category burn_query · $0 reply +
2.5% of monthly budget used. Tier: normal.
+
+
+ Mirrors dm_phrasebook.match_message + render_reply; preview uses in-progress edits, not the saved phrasebook. +
+
+ + +
+ + + +
+ + +
+
+
+
+ Pattern (Python regex, case-insensitive) + +
+
+ Category + +
+
+ Description + +
+
+ Reply template (supports {snapshot.X.Y} placeholders) + +
+
+ +
+
+ +
+
+
+
+ Pattern + +
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
+
+ +
+
+ +
+ + +

Phrasebook editor — state 2: validation error (422)

+

+ Operator clicked Save with a bad regex + an unknown snapshot path. Server returned 422 with structured + per-entry errors; FE renders each next to the offending field. Top-level errors render at the top. +

+ +
+
+ ⚠ 1 top-level error: +
+
+ • duplicate (pattern, category) — already declared at index 0 +
+
+ +
+ + + +
+ + +
+
+
+
+ Pattern (Python regex) + +
invalid regex: missing ), unterminated subpattern at position 0
+
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
+
+ +
+
+ + +
+
+
+
+ Pattern + +
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
snapshot path 'totally.not.a.real.field' not in known scalar schema (see SNAPSHOT_SCALAR_PATHS)
+
+
+ +
+
+ +
+ + +

Phrasebook editor — state 3: backups + revert dialog

+

+ Operator clicked "Backups". Modal shows newest-first list; per-row Revert with confirm. Corrupt + backups (entry_count null) are greyed out. Bottom note explains revert semantics. +

+ + + + + + + diff --git a/web/docs/phrasebook-editor/revert.html b/web/docs/phrasebook-editor/revert.html new file mode 100644 index 000000000000..acc852e043c6 --- /dev/null +++ b/web/docs/phrasebook-editor/revert.html @@ -0,0 +1,65 @@ + +Revert dialog + + +

Phrasebook editor — backups + revert dialog

+

+ Operator clicked "Backups". Modal shows newest-first list; per-row Revert with confirm. Corrupt + backups (entry_count null) are greyed out and Revert is disabled. Bottom note explains revert + semantics. +

+ + + + + + diff --git a/web/docs/phrasebook-editor/revert.png b/web/docs/phrasebook-editor/revert.png new file mode 100644 index 000000000000..a35f30bd0f87 Binary files /dev/null and b/web/docs/phrasebook-editor/revert.png differ diff --git a/web/docs/phrasebook-editor/validation.html b/web/docs/phrasebook-editor/validation.html new file mode 100644 index 000000000000..e55feca51bb2 --- /dev/null +++ b/web/docs/phrasebook-editor/validation.html @@ -0,0 +1,103 @@ + +Validation error + + +

Phrasebook editor — validation error (422)

+

+ Operator clicked Save with a bad regex + an unknown snapshot path + a duplicate (pattern, category) + pair. Server returned 422 with structured per-entry errors; FE renders each next to the offending field. + Top-level errors (e.g. duplicates) render at the top. +

+ +
+
+ ⚠ 1 top-level error: +
+
+ • duplicate (pattern, category) — already declared at index 0 +
+
+ +
+ + + +
+ +
+
+
+
+ Pattern (Python regex) + +
invalid regex: missing ), unterminated subpattern at position 0
+
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
+
+ +
+
+ +
+
+
+
+ Pattern + +
+
+ Category + +
+
+ Description + +
+
+ Reply template + +
snapshot path 'totally.not.a.real.field' not in known scalar schema (see SNAPSHOT_SCALAR_PATHS in kora_cli/short_circuit/phrasebook_editor.py)
+
+
+ +
+
+ +
+
+
+
+ Pattern + +
nested unbounded quantifier (possible catastrophic backtracking) — rewrite to use bounded counts or anchored alternations
+
+
+ Category + +
+
+ Description + +
required field 'description' missing or empty
+
+
+ Reply template + +
+
+ +
+
+ + diff --git a/web/docs/phrasebook-editor/validation.png b/web/docs/phrasebook-editor/validation.png new file mode 100644 index 000000000000..f94a8c987dac Binary files /dev/null and b/web/docs/phrasebook-editor/validation.png differ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7bf0fef110a2..8f1e3abe82a1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -154,6 +154,27 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }), + // KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — write path (PUT + revert + // + backups list). Server returns 422 with per-entry errors + // when validation fails; fetchJSON throws on 422, callers + // catch + parse the JSON body for the structured errors. + putSlackDmPhrasebook: (entries: PhrasebookEntryWrite[]) => + fetchJSON("/api/phrasebook/slack_dm", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entries }), + }), + revertSlackDmPhrasebook: (filename?: string | null) => + fetchJSON( + "/api/phrasebook/slack_dm/revert", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(filename ? { filename } : {}), + }, + ), + getSlackDmPhrasebookBackups: () => + fetchJSON("/api/phrasebook/slack_dm/backups"), // KR-FE-PROBE-INVESTIGATION-VIEWER: joined wake → reasoning → DM // xref panel. Window: 24h | 7d | all. limit: 1-200 (server caps). getProbeInvestigations: (opts?: { @@ -1956,6 +1977,64 @@ export type PhrasebookTestResponse = snapshot_present: boolean; }; +// KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — write-path types. +// The PUT request body's per-entry shape (operator's draft — +// `referenced_snapshot_fields` is derived server-side and not +// part of the write). +export interface PhrasebookEntryWrite { + pattern: string; + category: string; + description: string; + reply_template: string; +} + +// PUT response: echoes the saved entries + reports backup + +// rotation outcome so the cockpit can refresh local state from +// the response without a follow-up GET. +export interface PhrasebookPutResponse { + source_path: string; + entry_count: number; + backup_filename: string | null; + rotated_backup_count: number; + entries: PhrasebookEntryDto[]; +} + +// PUT 422 body — when validation fails, server returns this +// structured shape per offending field. The cockpit unmarshals +// it from the thrown fetchJSON error to render per-row errors. +export interface PhrasebookValidationErrorEntry { + entry_index: number; // -1 for root-level errors + field: string; // field name or "_root" + error: string; +} + +export interface PhrasebookValidationErrorBody { + error: "validation_failed"; + errors: PhrasebookValidationErrorEntry[]; +} + +// POST /revert response. reverted_to is the backup filename +// restored, OR the literal "bundled_default" when no backup +// existed (override was removed so the live handler falls back +// to the bundled phrasebook). +export interface PhrasebookRevertResponse { + reverted_to: string; + source_path: string | null; +} + +// GET /backups list item. +export interface PhrasebookBackupItem { + filename: string; // "slack_dm.YYYY-MM-DDTHH-MM-SSZ.yml" + timestamp: string; // ISO-like, dashes-only (filename-safe) + size_bytes: number; + entry_count: number | null; // null when backup can't be parsed +} + +export interface PhrasebookBackupsResponse { + backups: PhrasebookBackupItem[]; + rotation_keep: number; +} + // KR-FE-PROBE-INVESTIGATION-VIEWER — joined wake event + downstream // reasoning + current health. Source-of-truth shape pinned by the // backend endpoint at /api/probe-investigations. diff --git a/web/src/pages/PhrasebookEditor.tsx b/web/src/pages/PhrasebookEditor.tsx new file mode 100644 index 000000000000..7d014f7aedf1 --- /dev/null +++ b/web/src/pages/PhrasebookEditor.tsx @@ -0,0 +1,639 @@ +// KR-FE-PHRASEBOOK-EDITOR-AND-CRUD — editor UI for the operator. +// +// Hosts components that PhrasebookPage delegates to in edit mode: +// +// * EntryEditorRow — one editable row (4 inputs + delete button) +// * EditModeControls — Save / Cancel / Add Entry buttons +// * BackupsDialog — modal for revert flow +// * ClientSidePreview — simulates the live tester against +// in-progress edits (so operator can preview before saving) +// +// Validation feedback shape mirrors the backend's +// PhrasebookValidationErrorEntry: per-(entry_index, field) error +// messages rendered inline next to the offending input. Root +// errors (entry_index = -1) render at the top of the editor. +// +// Client-side preview implements just enough of dm_phrasebook's +// match + render_reply semantics to give an accurate preview — +// regex.test() (case-insensitive) + walkSnapshotField (mirrors +// _walk_snapshot). Pinned by tests that this matches the backend. + +import { useCallback, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + History, + Plus, + RefreshCw, + Save, + Trash2, + X, + XCircle, +} 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 { Card, CardContent } from "@/components/ui/card"; +import type { + PhrasebookBackupItem, + PhrasebookEntryWrite, + PhrasebookValidationErrorEntry, + SnapshotResponse, +} from "@/lib/api"; + +// EditableEntry adds a stable per-row React key. The id is +// generated client-side (crypto.randomUUID) so adding rows in +// edit mode doesn't collide with array indices on re-renders. +export interface EditableEntry extends PhrasebookEntryWrite { + _localId: string; +} + +export function makeEmptyEntry(): EditableEntry { + return { + _localId: crypto.randomUUID(), + pattern: "", + category: "", + description: "", + reply_template: "", + }; +} + +export function toEditableEntries( + entries: PhrasebookEntryWrite[], +): EditableEntry[] { + return entries.map((e) => ({ + _localId: crypto.randomUUID(), + pattern: e.pattern, + category: e.category, + description: e.description, + reply_template: e.reply_template, + })); +} + +// --------------------------------------------------------------- +// Per-row editor +// --------------------------------------------------------------- + +interface EntryEditorRowProps { + entry: EditableEntry; + index: number; + errors: PhrasebookValidationErrorEntry[]; + onChange: (next: EditableEntry) => void; + onDelete: () => void; +} + +function errorFor( + errors: PhrasebookValidationErrorEntry[], + index: number, + field: string, +): string | null { + const hit = errors.find( + (e) => e.entry_index === index && e.field === field, + ); + return hit ? hit.error : null; +} + +export function EntryEditorRow({ + entry, + index, + errors, + onChange, + onDelete, +}: EntryEditorRowProps) { + const patternErr = errorFor(errors, index, "pattern"); + const categoryErr = errorFor(errors, index, "category"); + const descriptionErr = errorFor(errors, index, "description"); + const replyErr = errorFor(errors, index, "reply_template"); + const rootErr = errorFor(errors, index, "_root"); + + return ( + + +
+
+ onChange({ ...entry, pattern: v })} + error={patternErr} + mono + /> + onChange({ ...entry, category: v })} + error={categoryErr} + /> + onChange({ ...entry, description: v })} + error={descriptionErr} + /> + + onChange({ ...entry, reply_template: v }) + } + error={replyErr} + mono + textarea + /> +
+ +
+ {rootErr && ( +
+ + {rootErr} +
+ )} +
+
+ ); +} + +interface FieldEditorProps { + label: string; + value: string; + onChange: (v: string) => void; + error: string | null; + mono?: boolean; + textarea?: boolean; +} + +function FieldEditor({ + label, + value, + onChange, + error, + mono, + textarea, +}: FieldEditorProps) { + const baseInputClass = `w-full px-2 py-1 text-xs rounded border bg-card ${ + mono ? "font-mono" : "" + } ${ + error + ? "border-destructive/60 focus:outline-destructive" + : "border-border focus:outline-primary" + }`; + return ( +