diff --git a/py/api/lora_integration.py b/py/api/lora_integration.py index bb6a146..430d6ce 100644 --- a/py/api/lora_integration.py +++ b/py/api/lora_integration.py @@ -168,6 +168,9 @@ class LoraIntegrationMixin: async def lora_enable(self, request): """Enable or disable LoraManager integration and save it to config.json. + ``expected_revision`` (optional, GET /settings' ``revision``) refuses + a save over a config.json changed meanwhile (``settings-changed``). + The values are committed through the config store first (#220) and published at runtime only once config.json holds them; a save that cannot be written reports ``persisted: false`` and changes nothing. @@ -180,6 +183,11 @@ class LoraIntegrationMixin: for flag in _LORA_FLAGS: if flag in data and not isinstance(data[flag], bool): return self._bad_lora_request(f"{flag} must be true or false") + # The System Save row sends the revision it read (#220); the + # legacy page sends none and merges into what is on disk. + expected_revision = data.get("expected_revision") + if expected_revision is not None and not isinstance(expected_revision, str): + return self._bad_lora_request("expected_revision must be a string") enabled = data.get("enabled", False) path = data.get("path", "") trigger_words = data.get("trigger_words_enabled", False) @@ -226,7 +234,7 @@ class LoraIntegrationMixin: _saved, revision = await self._run_in_executor( get_config_store().commit, _lora_settings_mutation(values), - expected_revision=None, + expected_revision=expected_revision, publish=publish, ) except (ConfigUnreadable, RevisionConflict, OSError) as exc: diff --git a/tests/test_api_lora.py b/tests/test_api_lora.py index 3d0d1e4..f7809c9 100644 --- a/tests/test_api_lora.py +++ b/tests/test_api_lora.py @@ -528,6 +528,48 @@ class TestLoraEnableRoute(LoraAPITestCase): self.assertEqual(self.config_file.read_bytes(), b"{not json") self.assertFalse(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED) + async def test_enable_with_a_stale_revision_changes_nothing(self): + # System S10: the Save row sends the revision it read; a file changed + # since is a settings-changed conflict and nothing is written. + self.config_file.write_text(json.dumps({"web_ui": {"result_timeout": 9}})) + resp = await self.client.request("GET", "/prompt_manager/settings") + revision = (await resp.json())["revision"] + self.config_file.write_text(json.dumps({"web_ui": {"result_timeout": 10}})) + before = self.config_file.read_bytes() + + resp = await self._post_json( + "/prompt_manager/lora/enable", + { + "enabled": False, + "trigger_words_enabled": True, + "expected_revision": revision, + }, + ) + body = await resp.json() + + self.assertEqual((resp.status, body["code"]), (409, "settings-changed")) + self.assertEqual(self.config_file.read_bytes(), before) + self.assertFalse(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED) + + resp = await self._post_json( + "/prompt_manager/lora/enable", + { + "enabled": False, + "trigger_words_enabled": True, + "expected_revision": body["revision"], + }, + ) + saved = await resp.json() + self.assertEqual(resp.status, 200) + self.assertTrue(saved["revision"].startswith("sha256:")) + self.assertTrue(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED) + + resp = await self._post_json( + "/prompt_manager/lora/enable", + {"enabled": False, "expected_revision": 7}, + ) + self.assertEqual(resp.status, 400) + async def test_enable_requires_boolean_flags_and_body_cap(self): for body in ( {"enabled": "yes"},