The FailedConfigRead save refusal always said "could not be read ... Try again", including when the fallback came from a YAML parse error that no retry fixes. The refusal now matches the cause: bad YAML gets the same "has a formatting error ... `hermes config edit`" guidance as require_readable_config_before_write; a PermissionError gets the fix-permissions hint; only other read errors (EMFILE/EIO) say try again.
154 lines
7.3 KiB
Python
154 lines
7.3 KiB
Python
"""What Hermes does when an existing ``config.yaml`` cannot be read or parsed.
|
|
|
|
Readers fail open (``{}`` / defaults / last-known-good) so a broken file never takes the process
|
|
down; this module makes that fallback loud (one warning per file signature, a ``corrupt`` backup),
|
|
remembers the failure for :func:`get_active_config_parse_failure`, and marks every fallback as a
|
|
:class:`FailedConfigRead` so the writers refuse to persist it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from utils import file_signature
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# (config_path, mtime_ns, size) tuples already warned about, so concurrent CLI/gateway
|
|
# loads of a broken config.yaml don't spam stderr. A changed file (new mtime) warns again.
|
|
_CONFIG_PARSE_WARNED: set = set()
|
|
|
|
# path -> (mtime_ns, size, error message) of active parse failures. Written by
|
|
# _warn_config_parse_failure() (the single funnel for every load-path parse failure) and
|
|
# probed by get_active_config_parse_failure() so provider auto-resolution can refuse to
|
|
# adopt a paid provider from env keys while the user's REAL config is unreadable.
|
|
_CONFIG_PARSE_FAILURES: dict = {}
|
|
|
|
_PARSE_FAILURE_FALLBACK_MSG = {
|
|
"last-known-good": "Hermes is running on the settings it loaded before the edit until it is fixed, so recent changes are not applied.",
|
|
"last-known-good-backup": "Hermes is running on your last good settings until it is fixed, so recent changes are not applied.",
|
|
"refuse-write": "Nothing was written, so the existing file is preserved."}
|
|
_PARSE_FAILURE_DEFAULTS_MSG = (
|
|
"Hermes is running on default settings until it is fixed, so none of your saved settings are applied.")
|
|
_PARSE_FAILURE_REPAIR_MSG = "Open it with `hermes config edit`, fix {where}, then run `hermes config check`."
|
|
_FIX_PERMS = "Fix the file permissions or move it aside first."
|
|
_FIX_YAML = (
|
|
"Fix it with `hermes config edit` and check with `hermes config check`, or copy the newest good "
|
|
"file from {backups} over config.yaml.")
|
|
|
|
|
|
def _yaml_error_location(exc: Exception) -> str:
|
|
"""``"line 12"`` from a PyYAML problem mark (1-based), else ``""``."""
|
|
mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
|
|
line = getattr(mark, "line", None)
|
|
return f"line {line + 1}" if isinstance(line, int) else ""
|
|
|
|
|
|
def _yaml_error_details(exc: Exception) -> str:
|
|
"""Single-line ``Details:`` text: the PyYAML problem, or the exception's first line."""
|
|
problem = getattr(exc, "problem", None)
|
|
text = f"{problem}" if problem else str(exc).strip()
|
|
return " ".join(text.split())
|
|
|
|
|
|
def format_config_parse_failure(config_path: Path, exc: Exception, *, fallback: str = "defaults") -> str:
|
|
"""User copy for an unparseable config.yaml: what happened, what Hermes is doing, how to fix.
|
|
Only the problem line/column is printed; the raw PyYAML text goes to a ``Details:`` line."""
|
|
where = _yaml_error_location(exc)
|
|
at = f" at {where}" if where else ""
|
|
fallback_msg = _PARSE_FAILURE_FALLBACK_MSG.get(fallback, _PARSE_FAILURE_DEFAULTS_MSG)
|
|
if isinstance(exc, OSError): # the file is intact; EMFILE/EIO/sharing violation, not a YAML problem
|
|
return f"Your settings file ({config_path}) could not be read. {fallback_msg} {_read_error_fix(exc)}"
|
|
repair = _PARSE_FAILURE_REPAIR_MSG.format(where=where or "the problem")
|
|
return f"Your settings file ({config_path}) has a formatting error{at}. {fallback_msg} {repair}"
|
|
|
|
|
|
def _warn_config_parse_failure(
|
|
config_path: Path, exc: Exception, *, fallback: str = "defaults") -> None:
|
|
"""Surface a config.yaml parse failure to log and stderr (once per file signature).
|
|
Silent fallback to ``DEFAULT_CONFIG`` drops every user override, so this must be loud.
|
|
|
|
``fallback`` selects the message wording: ``"defaults"`` (fresh process, nothing else to serve) or
|
|
``"last-known-good"`` (in-process retention of the previously loaded config — see the codex#31188 port
|
|
in ``hermes_cli.config._load_config_impl``).
|
|
"""
|
|
try:
|
|
st = config_path.stat()
|
|
sig = file_signature(st)
|
|
key = (str(config_path), *sig)
|
|
_CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc))
|
|
except OSError:
|
|
key = (str(config_path), 0, 0, 0, 0)
|
|
if key in _CONFIG_PARSE_WARNED:
|
|
return
|
|
_CONFIG_PARSE_WARNED.add(key)
|
|
from hermes_cli.config_backups import backup_config
|
|
# A read error leaves an intact file behind: no "corrupt" copy of a good file.
|
|
backup_path = None if isinstance(exc, OSError) else backup_config(config_path, "corrupt")
|
|
msg = format_config_parse_failure(config_path, exc, fallback=fallback)
|
|
if backup_path is not None:
|
|
msg += f" A copy of the broken file was saved to {backup_path}."
|
|
logger.warning("%s Details: %s", msg, _yaml_error_details(exc))
|
|
try:
|
|
sys.stderr.write(f"⚠️ hermes config: {msg}\n Details: {_yaml_error_details(exc)}\n")
|
|
sys.stderr.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def get_active_config_parse_failure() -> Optional[str]:
|
|
"""Return the recorded parse error while the ACTIVE config.yaml is still byte-identical
|
|
(mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None."""
|
|
from hermes_cli.config import get_config_path
|
|
try:
|
|
record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())]
|
|
st = path.stat()
|
|
return record[4] if file_signature(st) == record[:4] else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class FailedConfigRead(dict):
|
|
"""The fail-open fallback a config reader serves when an existing config.yaml could not be read
|
|
or parsed (``{}``, defaults or last-known-good). Readers use it like any dict; ``save_config`` /
|
|
``atomic_config_write`` refuse to persist it. The writer merges by deletion, so saving a fallback
|
|
after one transient EMFILE/EIO replaced the whole file with the fallback plus the caller's edit.
|
|
A dict subclass so the refusal survives the load→mutate→save round trip at every call site."""
|
|
|
|
def __init__(self, data: Any = (), *, error: Exception):
|
|
super().__init__(data)
|
|
self.read_error = error
|
|
|
|
|
|
def _read_error_fix(exc: OSError) -> str:
|
|
return _FIX_PERMS if isinstance(exc, PermissionError) else (
|
|
"Try again; run `hermes config check` if it keeps failing.")
|
|
|
|
|
|
def _refuse_failed_read(config_path: Path, data: Any) -> None:
|
|
"""Refuse to save a fallback; only a read error is worth retrying, bad YAML needs an edit."""
|
|
if not isinstance(data, FailedConfigRead):
|
|
return
|
|
exc = data.read_error
|
|
if isinstance(exc, OSError):
|
|
raise _refuse_overwrite(config_path, "could not be read", exc, _read_error_fix(exc))
|
|
raise _refuse_overwrite(
|
|
config_path, "has a formatting error", exc, _FIX_YAML.format(backups=_backups_dir_display()))
|
|
|
|
|
|
def _refuse_overwrite(config_path: Path, reason: str, exc: Exception, fix: str) -> RuntimeError:
|
|
"""Error for a write that must not replace an existing config.yaml. Plain lead + ``Details:``."""
|
|
where = _yaml_error_location(exc)
|
|
at = f" ({where})" if where else ""
|
|
return RuntimeError(
|
|
f"Your settings file ({config_path}) {reason}{at}, so this change was not saved. {fix} "
|
|
f"Details: {_yaml_error_details(exc)}")
|
|
|
|
|
|
def _backups_dir_display() -> str:
|
|
from hermes_constants import display_hermes_home
|
|
return f"{display_hermes_home()}/backups/config/"
|