diff --git a/meshai/config_loader.py b/meshai/config_loader.py index ffb8392..0a5d019 100644 --- a/meshai/config_loader.py +++ b/meshai/config_loader.py @@ -595,10 +595,14 @@ def save_section( cleaned[key] = value return cleaned - data = check_secrets(data) - - # Extract local fields - domain_data, local_updates = _extract_local_fields(section_name, data) + # List sections (e.g. mesh_sources) have no top-level dict to scan for + # local fields; clean each item for secrets and write the list directly. + if isinstance(data, list): + domain_data = [check_secrets(item) if isinstance(item, dict) else item for item in data] + local_updates = {} + else: + data = check_secrets(data) + domain_data, local_updates = _extract_local_fields(section_name, data) # Load existing target file if target_path.exists(): diff --git a/meshai/dashboard/api/config_routes.py b/meshai/dashboard/api/config_routes.py index bebea1a..142943d 100644 --- a/meshai/dashboard/api/config_routes.py +++ b/meshai/dashboard/api/config_routes.py @@ -11,6 +11,7 @@ from meshai.config import ( load_config, save_config, ) +from meshai.config_loader import save_section, get_config_dir_from_path logger = logging.getLogger(__name__) @@ -102,9 +103,6 @@ async def update_config_section(section: str, request: Request): raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") try: - # Load fresh config from file to avoid conflicts - config = load_config(config_path) - # Get the section's dataclass type field_info = Config.__dataclass_fields__.get(section) if not field_info: @@ -112,31 +110,39 @@ async def update_config_section(section: str, request: Request): field_type = field_info.type - # Handle list types (mesh_sources) + # Validate by coercing to the dataclass (runs __post_init__ validators), + # then persist via the multi-file / !include-aware save_section. The + # monolithic save_config cannot parse the !include orchestrator and blew + # up on every save in the prod layout (v0.4 C.2.1 fix). if section == "mesh_sources": from meshai.config import MeshSourceConfig new_value = [ _dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item for item in body ] - # Handle dataclass types + data_to_save = [ + _dataclass_to_dict(v) if hasattr(v, "__dataclass_fields__") else v + for v in new_value + ] elif hasattr(field_type, "__dataclass_fields__"): new_value = _dict_to_dataclass(field_type, body) + data_to_save = _dataclass_to_dict(new_value) else: new_value = body + data_to_save = body - # Set the section on config - setattr(config, section, new_value) - - # Save config to file - save_config(config, config_path) + config_dir = get_config_dir_from_path(config_path) + save_section(section, data_to_save, config_dir) # Determine if restart is required restart_required = section in RESTART_REQUIRED_SECTIONS - # Update live config if restart not required - if not restart_required: - request.app.state.config = config + # Keep the live config in sync (no disk reload needed) when no restart is required + if not restart_required and getattr(request.app.state, "config", None) is not None: + try: + setattr(request.app.state.config, section, new_value) + except Exception: + pass logger.info(f"Config section '{section}' updated, restart_required={restart_required}") diff --git a/tests/test_dashboard_config_save.py b/tests/test_dashboard_config_save.py new file mode 100644 index 0000000..4ee84f0 --- /dev/null +++ b/tests/test_dashboard_config_save.py @@ -0,0 +1,40 @@ +"""v0.4 C.2.1: dashboard config save routes through the multi-file save_section +(the !include-aware writer), not the monolithic save_config.""" + +import yaml + +from meshai.config_loader import save_section + + +def test_save_section_writes_env_feeds(tmp_path): + (tmp_path / "env_feeds.yaml").write_text("enabled: true\nnws:\n enabled: true\n") + res = save_section("environmental", { + "enabled": True, + "nws": {"enabled": True, "feed_source": "native"}, + "usgs_quake": {"enabled": False, "feed_source": "native"}, + }, tmp_path) + assert res["saved"] is True + written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text()) + assert written["nws"]["feed_source"] == "native" + assert written["usgs_quake"]["feed_source"] == "native" + # only env_feeds.yaml was written; no config.yaml / orchestrator touched + assert not (tmp_path / "config.yaml").exists() + + +def test_save_section_strips_secret_fields(tmp_path): + res = save_section("environmental", { + "enabled": True, + "traffic": {"enabled": True, "api_key": "SECRET123"}, + }, tmp_path) + written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text()) + assert "api_key" not in written.get("traffic", {}) + assert "traffic.api_key" in res["rejected_secrets"] + + +def test_save_section_handles_list_section(tmp_path): + # mesh_sources is a top-level list section -- must not crash (C.2.1 guard) + res = save_section("mesh_sources", [{"name": "a", "type": "mqtt", "enabled": True}], tmp_path) + assert res["saved"] is True + written = yaml.safe_load((tmp_path / "mesh_sources.yaml").read_text()) + assert isinstance(written, list) + assert written[0]["name"] == "a"