Skip to content

Commit 5648f64

Browse files
committed
fix(settings): homeassistant-ai#1431 review pass — address 13 verified findings (homeassistant-ai#1164)
Round-3 review pass landed 13 verified findings; the cosmetic "persisted-value visible in UI after F5" item was explicitly skipped per user direction (UI shows post-gate value when master off; user accepts this since beta tools are actually disabled at runtime — the preserve-across-master-cycle UX is at the data layer, not the visual). Source fixes: - **Save button copy is now source-blind** — the previous "your feature-flag toggles already saved on click" claim only held when ``saveFeatureFlag`` raised ``restartNotice``; tool-config pin saves, backup-config saves, and cross-tab ``restart-required`` broadcasts also raise it. New copy: "a restart is pending. Click Restart above to apply your prior changes." (#2) - **Stale F.37 test docstring** describing the deleted server-side cascade rewritten. (#3) - **Beta-gate INFO log noise** — cascade-clear removal meant the gate could fire its "forcing %s=False" line every Settings rebuild, spamming addon logs once a user had truthy sub-flags persisted. Dedup via ``_BETA_GATE_LOGGED`` set per process, cleared on ``_reset_global_settings``. (#9) - **Lazy-lock docstring** updated to reflect Python 3.13 semantics (``asyncio.Lock()`` no longer takes a loop arg; the lazy pattern still serves test fixtures and single-loop deployment, with the invariant documented). (#10) - **Addon-mode carve-out comment** clarified to distinguish dev (master in schema) from stable (master web-UI-only). (homeassistant-ai#14) - **probe-div null branch** in F.37 now writes ``data-error`` so a failing test points at "selector missed" vs "value flipped" unambiguously. (homeassistant-ai#17) Tests added: - ``test_translations_cover_every_schema_key`` — parity check that every ``schema:`` key has a non-empty translation ``name`` and ``description``. Parameterised across stable + dev addons. Pins the class of silent gap that this PR's ``advanced_debug_logging`` fix addressed. (#4) - ``test_save_features_acquires_override_file_lock`` + ``test_save_advanced_acquires_override_file_lock`` — counting-lock wrapper asserts ``async with _get_override_file_lock()`` runs exactly once in each file-mode write path. Pin against a regression that silently bypasses concurrent-save serialisation. (#5) - ``test_dual_save_buttons_mirror_disabled_and_status_on_post_failure`` — exercises the 500-response branch of the dual-save mirror so a regression that broke ``_setAdvSaveStatus``/``_setAdvSaveDisabled`` for error paths only would still fail. (#6) - ``test_save_features_master_on_restores_subflag_values_in_addon_mode`` — addon-mode round-trip mirror of the existing standalone restore test; asserts the Supervisor merge-and-post call carries only the master flip-on and never zeroes out sub-flag values. (#7) - ``test_save_button_nothing_to_save_when_no_dirty_and_no_restart`` + ``test_save_button_restart_pending_hint_when_dirty_empty_but_restart_showing`` — both branches of the empty-dirty Save click are exercised; the restart-pending branch asserts the copy is source-blind. (#8) Deferred per user direction: - #1 (file-vs-Settings visual after F5): user accepts the current behavior (UI shows post-gate value; runtime tools actually disabled when master off; data-layer preserve still works end-to-end). - #11/#12/homeassistant-ai#13 (code-simplifier helper extractions): skipped as complicated to implement without behavior risk. - homeassistant-ai#15 (pre-homeassistant-ai#1164 users with already-cleared sub-flags): release-note concern, not a code change. - homeassistant-ai#16 (lock fragility under future thread-pool dispatch): speculative future-risk; not actionable today.
1 parent c030148 commit 5648f64

5 files changed

Lines changed: 497 additions & 31 deletions

File tree

src/ha_mcp/config.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,12 +814,20 @@ def _apply_feature_flag_overrides(settings: "Settings") -> None:
814814
)
815815
continue
816816
current = getattr(settings, sub, False)
817-
if current:
817+
if current and sub not in _BETA_GATE_LOGGED:
818+
# Dedup per-process: cascade-clear was removed so the
819+
# file now holds truthy sub-flag values long-term and
820+
# this gate runs on every Settings rebuild. Logging
821+
# the force-False line every time would spam addon
822+
# logs (#1164 follow-up review). First-time-per-process
823+
# is enough to leave an audit trail for operators
824+
# debugging "why is my beta tool off?".
818825
logger.info(
819826
"Beta master toggle is off; forcing %s=False "
820827
"(was True via env/file).",
821828
sub,
822829
)
830+
_BETA_GATE_LOGGED.add(sub)
823831
try:
824832
setattr(settings, sub, False)
825833
except (ValueError, TypeError) as err:
@@ -954,6 +962,14 @@ def _apply_advanced_overrides(settings: "Settings") -> None:
954962
# Global settings instance
955963
_settings: Settings | None = None
956964

965+
# Names of beta sub-flags the master gate has already logged a
966+
# force-False line for in this process. Used to dedup the gate's
967+
# INFO log so we don't spam addon logs on every Settings rebuild
968+
# now that the cascade-clear is gone and the file may carry truthy
969+
# sub-flag values long-term (#1164 follow-up review). Reset alongside
970+
# the Settings singleton in ``_reset_global_settings``.
971+
_BETA_GATE_LOGGED: set[str] = set()
972+
957973

958974
# Auto-backup runtime-editable fields (#1288 web UI editor). Each entry
959975
# is (field_name, env_var_name, python_type). The web UI's
@@ -1231,6 +1247,11 @@ def _reset_global_settings() -> None:
12311247
"""
12321248
global _settings
12331249
_settings = None
1250+
# Drop the gate-log dedup set too — once Settings has been
1251+
# rebuilt, an operator who's re-investigating "why is my beta
1252+
# tool off?" should see the next gate fire logged. This keeps
1253+
# the dedup tight to the lifetime of one cached Settings.
1254+
_BETA_GATE_LOGGED.clear()
12341255

12351256

12361257
# Import-time validator for cross-registry invariants (#1164).

src/ha_mcp/settings_ui.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3547,18 +3547,17 @@ def apply_tool_visibility(
35473547
if (Object.keys(_advancedDirty).length === 0) {
35483548
// Feature-flag toggles (master beta, Tool Search, etc.) auto-save
35493549
// on click via ``saveFeatureFlag`` — they don't pass through
3550-
// ``_advancedDirty``. If a feature-flag save just landed,
3551-
// ``restartNotice`` is showing and the user should click Restart,
3552-
// not Save again. Tell them that explicitly so the big Save
3553-
// button doesn't look broken when they were toggling beta flags
3554-
// (#1164 follow-up).
3550+
// ``_advancedDirty``. Tool-config pins and backup-config edits
3551+
// also auto-save. Any of those raise ``restartNotice``, but this
3552+
// tab can't tell which one. Keep the hint source-blind: just
3553+
// point at the Restart button (#1164 follow-up review).
35553554
const restartNotice = document.getElementById('restartNotice');
35563555
const restartShowing =
35573556
restartNotice && restartNotice.classList.contains('show');
35583557
if (restartShowing) {
35593558
_setAdvSaveStatus(
3560-
'No advanced changes to save — your feature-flag toggles already ' +
3561-
'saved on click. Click Restart above to apply them.'
3559+
'No advanced changes to save — a restart is pending. Click ' +
3560+
'Restart above to apply your prior changes.'
35623561
);
35633562
} else {
35643563
_setAdvSaveStatus('Nothing to save.');
@@ -3945,10 +3944,15 @@ async def _supervisor_merge_and_post_options(
39453944

39463945

39473946
def _get_override_file_lock() -> asyncio.Lock:
3948-
"""Lazy lock construction — ``asyncio.Lock()`` at module load
3949-
binds the event loop that's current AT IMPORT, which doesn't exist
3950-
yet for handlers invoked under uvicorn/starlette. Construct on
3951-
first use under the live loop instead.
3947+
"""Lazy lock construction. ``asyncio.Lock()`` on Python 3.10+ no
3948+
longer takes a ``loop=`` argument and only binds to a loop on
3949+
first ``acquire()`` via ``asyncio.get_event_loop()``. Either eager
3950+
or lazy module-level construction works for this project's
3951+
single-uvicorn-loop deployment; we keep the lazy pattern so a
3952+
future test fixture that spins up its own loop doesn't lock in
3953+
the import-time loop. Assumes a single asyncio event loop for the
3954+
process lifetime — a multi-loop deployment (e.g. threaded handler
3955+
dispatch) would race here.
39523956
"""
39533957
global _OVERRIDE_FILE_LOCK
39543958
if _OVERRIDE_FILE_LOCK is None:
@@ -4466,8 +4470,13 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
44664470
# Applied in BOTH standalone and addon mode (#1164 follow-up).
44674471
# The earlier "skip in addon mode" carve-out existed because
44684472
# start.py used to auto-write ENABLE_BETA_FEATURES=true from
4469-
# any beta sub-flag presence; now start.py writes the master
4470-
# env from its own options key, so the gate applies uniformly.
4473+
# any beta sub-flag presence; that path is now demoted to a
4474+
# one-cycle legacy bridge. On dev addon, start.py writes the
4475+
# master env var from the schema-bound options key. On stable
4476+
# addon, the master is not in schema and the standalone
4477+
# web-UI master path remains the gate (the gate read below
4478+
# falls through to the override-file value). Either way the
4479+
# gate is sound to apply uniformly.
44714480
from .config import (
44724481
BETA_FEATURE_FIELDS as _BETA_SUB,
44734482
)

tests/addon/test_addon_structure.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,44 +45,58 @@ def test_config_yaml_valid(self):
4545
with open("pyproject.toml", "rb") as f:
4646
pyproject = tomllib.load(f)
4747
expected_version = pyproject["project"]["version"]
48-
assert config["version"] == expected_version, \
48+
assert config["version"] == expected_version, (
4949
f"Add-on version {config['version']} should match package version {expected_version}"
50+
)
5051

5152
# Verify essential configurations
5253
assert config["hassio_api"] is True, "hassio_api required for Supervisor"
5354
assert config["homeassistant_api"] is True, "homeassistant_api required"
5455

5556
# Verify image field uses per-architecture naming
56-
assert config["image"] == "ghcr.io/homeassistant-ai/ha-mcp-addon-{arch}", \
57+
assert config["image"] == "ghcr.io/homeassistant-ai/ha-mcp-addon-{arch}", (
5758
"image field must use per-architecture naming with {arch} placeholder"
59+
)
5860

5961
# Verify port configuration (fixed internal port)
6062
assert "ports" in config, "ports section required for HTTP transport"
6163
assert "9583/tcp" in config["ports"], "port 9583/tcp must be exposed"
6264

6365
# Verify secret_path configuration (optional advanced override)
64-
assert "secret_path" not in config["options"], \
66+
assert "secret_path" not in config["options"], (
6567
"secret_path should be optional and omitted so Supervisor treats it as advanced"
66-
assert "secret_path" in config["schema"], "schema must include secret_path field"
67-
assert config["schema"]["secret_path"] == "str?", \
68+
)
69+
assert "secret_path" in config["schema"], (
70+
"schema must include secret_path field"
71+
)
72+
assert config["schema"]["secret_path"] == "str?", (
6873
"secret_path schema should be optional string (str?)"
74+
)
6975

7076
# Verify backup_hint configuration
71-
assert "backup_hint" in config["options"], "options must include backup_hint field"
72-
assert config["options"]["backup_hint"] == "normal", "default backup_hint should be normal"
73-
assert config["schema"]["backup_hint"] == "list(strong|normal|weak|auto)", \
77+
assert "backup_hint" in config["options"], (
78+
"options must include backup_hint field"
79+
)
80+
assert config["options"]["backup_hint"] == "normal", (
81+
"default backup_hint should be normal"
82+
)
83+
assert config["schema"]["backup_hint"] == "list(strong|normal|weak|auto)", (
7484
"backup_hint schema must enumerate allowed values"
85+
)
7586

7687
# Verify architectures (only 64-bit platforms supported by uv image)
7788
expected_archs = ["amd64", "aarch64"]
7889
assert all(arch in config["arch"] for arch in expected_archs)
7990

8091
# Verify 32-bit platforms are not included
8192
unsupported_archs = ["armhf", "armv7", "i386"]
82-
assert not any(arch in config["arch"] for arch in unsupported_archs), \
93+
assert not any(arch in config["arch"] for arch in unsupported_archs), (
8394
"32-bit platforms not supported by uv base image"
95+
)
8496

85-
@pytest.mark.skipif(sys.platform == "win32", reason="Unix permissions not applicable on Windows")
97+
@pytest.mark.skipif(
98+
sys.platform == "win32", reason="Unix permissions not applicable on Windows"
99+
)
86100
def test_start_script_executable(self):
87101
"""Verify start.py has executable permissions."""
88102
start_py = f"{ADDON_DIR}/start.py"
@@ -95,3 +109,43 @@ def test_start_script_has_shebang(self):
95109
first_line = f.readline()
96110
assert first_line.startswith("#!"), "start.py missing shebang"
97111
assert "python" in first_line.lower(), "start.py shebang must reference python"
112+
113+
@pytest.mark.parametrize(
114+
"addon_dir", ["homeassistant-addon", "homeassistant-addon-dev"]
115+
)
116+
def test_translations_cover_every_schema_key(self, addon_dir):
117+
"""Every key declared in ``config.yaml``'s ``schema:`` must have a
118+
matching ``configuration.<key>`` entry in ``translations/en.yaml``
119+
with both ``name`` and ``description`` populated. Pre-#1164 the
120+
``advanced_debug_logging`` schema field was added on stable but
121+
the translation was forgotten — the addon Configuration UI
122+
then showed an unlabelled checkbox. Lock the parity so the
123+
same class of silent gap can't recur.
124+
"""
125+
with open(f"{addon_dir}/config.yaml") as f:
126+
cfg = yaml.safe_load(f)
127+
with open(f"{addon_dir}/translations/en.yaml") as f:
128+
translations = yaml.safe_load(f)
129+
schema_keys = set(cfg.get("schema", {}).keys())
130+
# ``secret_path`` is intentionally undocumented in user-facing
131+
# translations (it's an advanced/hidden override the wizard
132+
# handles, not a user-set option).
133+
schema_keys.discard("secret_path")
134+
configuration = translations.get("configuration", {})
135+
for key in sorted(schema_keys):
136+
entry = configuration.get(key)
137+
assert entry is not None, (
138+
f"{addon_dir}/translations/en.yaml is missing a "
139+
f"`configuration.{key}` entry for the schema field "
140+
f"declared in config.yaml"
141+
)
142+
assert entry.get("name"), (
143+
f"{addon_dir}/translations/en.yaml `configuration.{key}` "
144+
"needs a non-empty `name` (Supervisor renders it as the "
145+
"user-facing toggle label)"
146+
)
147+
assert entry.get("description"), (
148+
f"{addon_dir}/translations/en.yaml `configuration.{key}` "
149+
"needs a non-empty `description` (Supervisor renders it "
150+
"as the help tooltip under the toggle)"
151+
)

tests/src/unit/test_settings_ui.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2928,6 +2928,174 @@ async def test_save_features_payload_master_false_sub_true_rejected_by_gate(
29282928
get_data_dir.cache_clear()
29292929
_reset_global_settings()
29302930

2931+
@pytest.mark.asyncio
2932+
async def test_save_features_acquires_override_file_lock(
2933+
self, monkeypatch, tmp_path
2934+
):
2935+
"""F.5 — pin the lock-acquire site so a regression that removes
2936+
``async with _get_override_file_lock():`` from the file-mode
2937+
write path lands as a test failure. The lock is the only thing
2938+
preventing two concurrent saves from clobbering each other's
2939+
persisted state, but the protection is invisible to other
2940+
tests — they'd still pass without it.
2941+
"""
2942+
from ha_mcp.config import FEATURE_FLAG_FIELDS, _reset_global_settings
2943+
from ha_mcp.settings_ui import build_settings_handlers
2944+
from ha_mcp.utils.data_paths import get_data_dir
2945+
2946+
get_data_dir.cache_clear()
2947+
monkeypatch.setenv("HA_MCP_CONFIG_DIR", str(tmp_path))
2948+
get_data_dir.cache_clear()
2949+
for _fname, ename, _ftype in FEATURE_FLAG_FIELDS:
2950+
monkeypatch.delenv(ename, raising=False)
2951+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
2952+
# Wrap _get_override_file_lock so we can count entries.
2953+
import ha_mcp.settings_ui as ui_mod
2954+
2955+
real_get_lock = ui_mod._get_override_file_lock
2956+
entries = {"count": 0}
2957+
2958+
class CountingLock:
2959+
def __init__(self, inner):
2960+
self._inner = inner
2961+
2962+
async def __aenter__(self):
2963+
entries["count"] += 1
2964+
return await self._inner.__aenter__()
2965+
2966+
async def __aexit__(self, *args):
2967+
return await self._inner.__aexit__(*args)
2968+
2969+
def patched_get_lock():
2970+
return CountingLock(real_get_lock())
2971+
2972+
monkeypatch.setattr(ui_mod, "_get_override_file_lock", patched_get_lock)
2973+
_reset_global_settings()
2974+
handlers = build_settings_handlers(server=None)
2975+
req = MagicMock()
2976+
req.json = AsyncMock(return_value={"flags": {"enable_tool_search": True}})
2977+
resp = await handlers["save_feature_flags"](req)
2978+
assert resp.status_code == 200, json.loads(resp.body)
2979+
assert entries["count"] == 1, (
2980+
"save_feature_flags must acquire _OVERRIDE_FILE_LOCK exactly once "
2981+
"in the file-mode write path — regression would silently bypass "
2982+
"concurrent-save serialisation"
2983+
)
2984+
get_data_dir.cache_clear()
2985+
_reset_global_settings()
2986+
2987+
@pytest.mark.asyncio
2988+
async def test_save_advanced_acquires_override_file_lock(
2989+
self, monkeypatch, tmp_path
2990+
):
2991+
"""F.5 — same lock-acquire pin for the advanced-settings file
2992+
write path. Both handlers must share the same lock so they
2993+
can't race against each other on the shared
2994+
``feature_flags.json``.
2995+
"""
2996+
from ha_mcp.config import _reset_global_settings
2997+
from ha_mcp.settings_ui import build_settings_handlers
2998+
from ha_mcp.utils.data_paths import get_data_dir
2999+
3000+
get_data_dir.cache_clear()
3001+
monkeypatch.setenv("HA_MCP_CONFIG_DIR", str(tmp_path))
3002+
monkeypatch.delenv("HA_TIMEOUT", raising=False)
3003+
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
3004+
get_data_dir.cache_clear()
3005+
import ha_mcp.settings_ui as ui_mod
3006+
3007+
real_get_lock = ui_mod._get_override_file_lock
3008+
entries = {"count": 0}
3009+
3010+
class CountingLock:
3011+
def __init__(self, inner):
3012+
self._inner = inner
3013+
3014+
async def __aenter__(self):
3015+
entries["count"] += 1
3016+
return await self._inner.__aenter__()
3017+
3018+
async def __aexit__(self, *args):
3019+
return await self._inner.__aexit__(*args)
3020+
3021+
def patched_get_lock():
3022+
return CountingLock(real_get_lock())
3023+
3024+
monkeypatch.setattr(ui_mod, "_get_override_file_lock", patched_get_lock)
3025+
_reset_global_settings()
3026+
handlers = build_settings_handlers(server=None)
3027+
req = MagicMock()
3028+
req.json = AsyncMock(return_value={"timeout": 90})
3029+
resp = await handlers["save_advanced_settings"](req)
3030+
assert resp.status_code == 200, json.loads(resp.body)
3031+
assert entries["count"] == 1, (
3032+
"save_advanced_settings must acquire _OVERRIDE_FILE_LOCK in the "
3033+
"file-mode write path — regression would silently bypass "
3034+
"concurrent-save serialisation"
3035+
)
3036+
get_data_dir.cache_clear()
3037+
_reset_global_settings()
3038+
3039+
@pytest.mark.asyncio
3040+
async def test_save_features_master_on_restores_subflag_values_in_addon_mode(
3041+
self, monkeypatch, tmp_path
3042+
):
3043+
"""F.7 — round-trip restore is exercised in standalone mode by
3044+
``test_save_features_master_on_restores_runtime_subflag_values``.
3045+
Mirror for addon mode: the Supervisor merge-and-post must NOT
3046+
zero out sub-flag values when the user POSTs only the master
3047+
flip-on. The preserve-on-master-off → restore-on-master-on UX
3048+
only works in addon mode if Supervisor keeps the merged options
3049+
intact.
3050+
"""
3051+
from ha_mcp.config import _reset_global_settings
3052+
from ha_mcp.settings_ui import build_settings_handlers
3053+
3054+
monkeypatch.setenv("SUPERVISOR_TOKEN", "fake")
3055+
# Dev-addon options.json shape: master + sub-flag both true,
3056+
# then user flipped master off (sub-flag value preserved).
3057+
# Supervisor's current options state at fetch time reflects
3058+
# that shape.
3059+
current_options = {
3060+
"enable_beta_features": False,
3061+
"enable_yaml_config_editing": True,
3062+
"enable_filesystem_tools": True,
3063+
"backup_hint": "normal",
3064+
}
3065+
# Mock the fetch+post path so we can inspect what gets POSTed.
3066+
fetch_mock = AsyncMock(return_value=(current_options, None))
3067+
merge_mock = AsyncMock(return_value=(True, None))
3068+
monkeypatch.setattr(
3069+
"ha_mcp.settings_ui._supervisor_fetch_current_options", fetch_mock
3070+
)
3071+
monkeypatch.setattr(
3072+
"ha_mcp.settings_ui._supervisor_merge_and_post_options",
3073+
merge_mock,
3074+
)
3075+
# Mark the master env var as set so get_feature_flag_origin
3076+
# returns 'addon' for it and the save handler picks the
3077+
# addon-route branch.
3078+
monkeypatch.setenv("ENABLE_BETA_FEATURES", "false")
3079+
_reset_global_settings()
3080+
server = MagicMock()
3081+
server.settings.verify_ssl = True
3082+
handlers = build_settings_handlers(server=server)
3083+
req = MagicMock()
3084+
req.json = AsyncMock(return_value={"flags": {"enable_beta_features": True}})
3085+
resp = await handlers["save_feature_flags"](req)
3086+
assert resp.status_code == 200, json.loads(resp.body)
3087+
# The Supervisor POST got only the master flip. Sub-flag
3088+
# values are NOT in the call — Supervisor's merge layer
3089+
# preserves the existing options.json entries for keys not
3090+
# mentioned in the POST body.
3091+
merge_mock.assert_awaited_once()
3092+
posted_args = merge_mock.await_args.args
3093+
assert posted_args[1] == {"enable_beta_features": True}, (
3094+
f"addon-route POST must NOT clobber sub-flag values; "
3095+
f"posted: {posted_args[1]}"
3096+
)
3097+
_reset_global_settings()
3098+
29313099
@pytest.mark.asyncio
29323100
async def test_save_features_master_off_applied_dict_contains_only_master(
29333101
self, monkeypatch, tmp_path

0 commit comments

Comments
 (0)