Files
hermes-agent/tests/hermes_cli/test_config_migration_nested_guards.py
ethernet f3b1399211 fix: round-5 CI backlog after the 779-commit main merge
Merge fallout (my resolution errors, all caught by CI):
- hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had
  already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line
  duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the
  systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher /
  _pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's
  utf-8-sig read. gateway_service_unit.py is dropped.
- gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping
  ordering test pins the scope/drain sequence).
- pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no
  pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml.
- tests re-seamed onto pm-clean's shape: residency admission (installed_engine),
  supervisor child env (binary is a constructor argument), update import guard
  (update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants
  pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped
  tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion).
- tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction
  commit dropped and the blocklist import.

Real fixes:
- pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment,
  stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10
  bootstrap python that streams uv output in the Docker arm64 image.
- tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on
  the frozen old-updater surface, and the revert-scheduled compat pointer does not count.
- hermes_cli/memory_setup: the dashboard's pip row uses pm.environments.
  running_from_selected_environment for installed vs restart_required.
- scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the
  primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64
  compiling ruamel-yaml-clib); run_tests.sh forwards them.
- tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the
  toolchain variables the runner job already exports.
- tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host);
  tests/home_io_guard.py treats sys.path site-packages under the real home as the
  interpreter's installation (PM-activated developer shell).
- tests-js: four `curly` lint errors from main's new scripts.
2026-09-21 02:47:48 -04:00

87 lines
4.3 KiB
Python

"""Config migrations survive malformed nested values (#116593).
Hand-edited or legacy config.yaml files hold scalars where a migration step expects a
mapping. Each step must guard the shapes it indexes, and ``run_migrations`` must isolate
a step that still raises so one bad value cannot wedge ``hermes config migrate`` /
``hermes update`` and leave the config unversioned.
"""
import logging
import os
from unittest.mock import patch
import pytest
import hermes_yaml as yaml
def _write_config(tmp_path, config):
(tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
def _read_config(tmp_path):
return yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
@pytest.mark.parametrize(
("current_ver", "config", "path", "expected"),
[
# _migrate_to_12: a non-string custom_providers name falls back to the hostname key.
(11, {"custom_providers": [{"name": 5, "base_url": "https://api.example.com/v1"}]},
("providers", "api-example-com", "api"), "https://api.example.com/v1"),
# _migrate_to_14: a mapping stt.provider is treated as the "local" default.
(13, {"stt": {"model": "tiny", "provider": {"nested": True}}},
("stt", "local", "model"), "tiny"),
# _migrate_to_14: a scalar stt.<section> is replaced, not indexed.
(13, {"stt": {"model": "base", "provider": "openai", "openai": 5}},
("stt", "openai", "model"), "base"),
# _migrate_to_16: a scalar display.platforms.<plat> slot is replaced, not indexed.
(15, {"display": {"tool_progress_overrides": {"telegram": "all"}, "platforms": {"telegram": 5}}},
("display", "platforms", "telegram", "tool_progress"), "all"),
# _migrate_to_17: a scalar auxiliary / auxiliary.compression is rebuilt as a mapping.
(16, {"compression": {"summary_model": "fast-model"}, "auxiliary": 5},
("auxiliary", "compression", "model"), "fast-model"),
(16, {"compression": {"summary_model": "fast-model"}, "auxiliary": {"compression": "x"}},
("auxiliary", "compression", "model"), "fast-model"),
],
ids=["v12-name-int", "v14-provider-map", "v14-section-scalar", "v16-platform-scalar",
"v17-auxiliary-scalar", "v17-compression-scalar"],
)
def test_malformed_nested_value_is_migrated_not_crashed(tmp_path, current_ver, config, path, expected):
"""Each cited step replaces the malformed slot and still lands the migrated value."""
from hermes_cli.config_migrations import run_migrations
_write_config(tmp_path, {"_config_version": current_ver, **config})
results = {"env_added": [], "config_added": [], "warnings": []}
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
run_migrations(current_ver, results, quiet=True)
node = _read_config(tmp_path)
for key in path:
node = node[key]
assert node == expected
assert not results["warnings"], "a guarded shape must migrate cleanly, not be skipped"
def test_failing_step_is_skipped_with_warning_and_config_still_migrates(tmp_path, caplog):
"""``migrate_config`` (the ``hermes config migrate`` / ``hermes update`` path) keeps going
past a raising step, records the skip in ``warnings`` and stamps the latest version. The
quiet path (profile creation, unattended update) discards ``results``, so the skip must
also reach the log or it is silent and, once stamped, permanent."""
from hermes_cli import config_migrations
from hermes_cli.config import migrate_config
def _boom(results, quiet):
raise RuntimeError("boom")
_write_config(tmp_path, {"_config_version": 12, "model": {"default": "x/y"}})
ladder = tuple((v, _boom if v == 13 else fn) for v, fn in config_migrations.MIGRATIONS)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}), \
patch.object(config_migrations, "MIGRATIONS", ladder), \
caplog.at_level(logging.WARNING, logger="hermes_cli.config_migrations"):
results = migrate_config(interactive=False, quiet=True)
assert any(w.startswith("config migration to v13 failed and was skipped") for w in results["warnings"])
assert _read_config(tmp_path)["_config_version"] == config_migrations.MIGRATIONS[-1][0]
assert any("config migration to v13 failed and was skipped" in r.getMessage()
and r.levelno == logging.WARNING for r in caplog.records)