feat(settings): panel-tunable feature flags

Add a Feature Flags card to the Settings page that toggles env-gated
subsystems (external/internal PR review, web research, strategy engine,
pitch provisioning, RAG auto-update, transcript pruning) directly from
the panel instead of hand-editing environment variables.

Flags persist in system_settings as 'true'/'false' and are overlaid onto
the live config singleton at startup; an unset flag keeps its
environment/config default. A toggle takes effect on the next backend
restart — no per-consumer re-routing.

Backend: FEATURE_FLAGS registry + bool validator + get_bool accessor on
SettingsService; feature_flag_effective_values and
apply_persisted_feature_flags; GET /settings/feature-flags; best-effort
startup overlay in the app lifespan.

Frontend: settingsApi.getFeatureFlags / setFeatureFlag and a
FeatureFlagsCard rendered full-width below the settings grid.
This commit is contained in:
Renn F
2026-06-17 16:50:11 +02:00
parent eec35c0357
commit f27a9f9447
8 changed files with 328 additions and 3 deletions
+84
View File
@@ -0,0 +1,84 @@
"""Panel-tunable feature flags — validation, bool read, and startup overlay (#9).
Flags persist in system_settings as 'true'/'false' and are overlaid onto the
config singleton at startup; an unset flag keeps its env/config default.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.services import settings as settings_mod
from roboco.services.settings import (
SettingsService,
SettingValidationError,
validate_setting,
)
def test_feature_flags_are_writable_as_bool() -> None:
validate_setting("external_pr_enabled", "true")
validate_setting("research_enabled", "FALSE") # case-insensitive
validate_setting("internal_pr_enabled", " true ") # whitespace tolerated
def test_non_bool_value_rejected() -> None:
with pytest.raises(SettingValidationError):
validate_setting("external_pr_enabled", "yes")
def test_unknown_key_rejected() -> None:
with pytest.raises(SettingValidationError):
validate_setting("not_a_real_flag", "true")
@pytest.mark.asyncio
async def test_get_bool_parses_and_defaults() -> None:
svc = SettingsService(MagicMock())
object.__setattr__(svc, "get", AsyncMock(return_value="true"))
assert await svc.get_bool("k", default=False) is True
object.__setattr__(svc, "get", AsyncMock(return_value="false"))
assert await svc.get_bool("k", default=True) is False
object.__setattr__(svc, "get", AsyncMock(return_value=None))
assert await svc.get_bool("k", default=True) is True # unset → default
@pytest.mark.asyncio
async def test_apply_overrides_stored_flags_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Baseline env defaults.
monkeypatch.setattr(cfg, "external_pr_enabled", False)
monkeypatch.setattr(cfg, "research_enabled", True)
# Only external_pr_enabled has a stored override; research_enabled is unset.
stored = {"external_pr_enabled": "true"}
async def fake_get(_self: SettingsService, key: str) -> str | None:
return stored.get(key)
monkeypatch.setattr(SettingsService, "get", fake_get)
applied = await settings_mod.apply_persisted_feature_flags(MagicMock())
assert "external_pr_enabled" in applied
assert cfg.external_pr_enabled is True # stored override applied
assert cfg.research_enabled is True # unset → env default untouched
assert "research_enabled" not in applied
@pytest.mark.asyncio
async def test_effective_values_use_env_default_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "strategy_engine_enabled", True)
async def fake_get(_self: SettingsService, _key: str) -> str | None:
return None # nothing stored
monkeypatch.setattr(SettingsService, "get", fake_get)
effective = await settings_mod.feature_flag_effective_values(MagicMock())
assert effective["strategy_engine_enabled"] is True # falls back to env