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
@@ -27,6 +27,7 @@ import {
import { toast } from "sonner";
import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -241,6 +242,10 @@ export default function SettingsPage() {
</Card>
</div>
{/* Feature Flags — master switches for optional subsystems (full width;
persisted server-side, applied on next restart). */}
<FeatureFlagsCard />
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave}>
@@ -0,0 +1,100 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { settingsApi } from "@/lib/api";
import type { FeatureFlag } from "@/lib/api/settings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Flag } from "lucide-react";
import { toast } from "sonner";
// One-line blurb per flag so the operator knows what each master switch gates.
const FLAG_DESCRIPTIONS: Record<string, string> = {
external_pr_enabled: "Discover and review inbound external/fork pull requests.",
internal_pr_enabled: "Run the read-only safety reviewer on internal branch PRs.",
research_enabled: "Let the Board and PMs run web research.",
strategy_engine_enabled: "Generate and maintain company strategy artifacts.",
provisioning_enabled: "Auto-provision projects from approved pitches.",
rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.",
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
};
export function FeatureFlagsCard() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["feature-flags"],
queryFn: settingsApi.getFeatureFlags,
});
const toggleMutation = useMutation({
mutationFn: ({ key, enabled }: { key: string; enabled: boolean }) =>
settingsApi.setFeatureFlag(key, enabled),
onSuccess: (_data, { enabled }) => {
queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
toast.success(
`Feature ${enabled ? "enabled" : "disabled"} — takes effect on next restart`,
);
},
onError: (error) => {
toast.error(
`Failed to update: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const flags: FeatureFlag[] = data?.flags ?? [];
const note = data?.note ?? "Changes take effect on the next backend restart.";
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flag className="h-5 w-5" />
Feature Flags
</CardTitle>
<CardDescription>
Master switches for optional subsystems. Unset flags fall back to the
environment default. {note}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading feature flags</p>
)}
{!isLoading && flags.length === 0 && (
<p className="text-sm text-muted-foreground">No feature flags available.</p>
)}
{flags.map((flag, i) => (
<div key={flag.key}>
{i > 0 && <Separator className="mb-4" />}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
<p className="text-sm text-muted-foreground">
{FLAG_DESCRIPTIONS[flag.key] ?? flag.key}
</p>
</div>
<Switch
id={`flag-${flag.key}`}
checked={flag.enabled}
disabled={toggleMutation.isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({ key: flag.key, enabled: checked })
}
/>
</div>
</div>
))}
</CardContent>
</Card>
);
}
+20
View File
@@ -4,6 +4,17 @@ export interface SettingsResponse {
settings: Record<string, string>;
}
export interface FeatureFlag {
key: string;
label: string;
enabled: boolean;
}
export interface FeatureFlagsResponse {
flags: FeatureFlag[];
note: string;
}
export const settingsApi = {
// GET /api/settings — all runtime-editable settings as a flat key→value map.
getAll: async (): Promise<Record<string, string>> => {
@@ -15,4 +26,13 @@ export const settingsApi = {
const { data } = await api.put<SettingsResponse>(`/settings/${key}`, { value });
return data.settings;
},
// GET /api/settings/feature-flags — effective flag values (override, else env).
getFeatureFlags: async (): Promise<FeatureFlagsResponse> => {
const { data } = await api.get<FeatureFlagsResponse>("/settings/feature-flags");
return data;
},
// PUT /api/settings/{key} — persist a feature flag as "true"/"false".
setFeatureFlag: async (key: string, enabled: boolean): Promise<void> => {
await api.put<SettingsResponse>(`/settings/${key}`, { value: enabled ? "true" : "false" });
},
};
+14 -1
View File
@@ -56,11 +56,12 @@ from roboco.api.routes.v1 import flow_qa as flow_qa_module
from roboco.api.routes.work_session import router as work_session_router
from roboco.api.websocket import router as ws_router
from roboco.config import settings
from roboco.db.base import close_db, init_db
from roboco.db.base import close_db, get_session_factory, init_db
from roboco.logging import get_logger, setup_logging
from roboco.services.extraction import ExtractionPipeline, ExtractionService
from roboco.services.learning import get_learning_service
from roboco.services.optimal import close_optimal_service, get_optimal_service
from roboco.services.settings import apply_persisted_feature_flags
from roboco.services.transcription import TranscriptionService
# Setup logging before anything else
@@ -105,6 +106,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
await init_db()
logger.info("Database initialized")
# Overlay panel-persisted feature-flag overrides onto the live config so the
# rest of startup (and the dispatch loops) read the panel's choices; unset
# flags keep their env/config default. Best-effort — a failure here must not
# block startup, the env defaults still apply.
try:
async with get_session_factory()() as _flags_db:
applied_flags = await apply_persisted_feature_flags(_flags_db)
if applied_flags:
logger.info("Applied persisted feature-flag overrides", flags=applied_flags)
except Exception as e:
logger.warning("Feature-flag overlay failed; using env defaults", error=str(e))
# Initialize Phase 2 services
_AppServices.transcription = TranscriptionService()
await _AppServices.transcription.start()
+24 -2
View File
@@ -8,8 +8,18 @@ and are read by the backend (e.g. the transcript-retention prune sweep) with a
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import DbSession
from roboco.api.schemas.settings import SettingsResponse, SettingUpdate
from roboco.services.settings import SettingValidationError, get_settings_service
from roboco.api.schemas.settings import (
FeatureFlag,
FeatureFlagsResponse,
SettingsResponse,
SettingUpdate,
)
from roboco.services.settings import (
FEATURE_FLAGS,
SettingValidationError,
feature_flag_effective_values,
get_settings_service,
)
router = APIRouter()
@@ -20,6 +30,18 @@ async def list_settings(db: DbSession) -> SettingsResponse:
return SettingsResponse(settings=await get_settings_service(db).all())
@router.get("/feature-flags", response_model=FeatureFlagsResponse)
async def get_feature_flags(db: DbSession) -> FeatureFlagsResponse:
"""Effective feature-flag values (stored override, else the env default)."""
effective = await feature_flag_effective_values(db)
return FeatureFlagsResponse(
flags=[
FeatureFlag(key=key, label=label, enabled=effective.get(key, False))
for key, label in FEATURE_FLAGS
]
)
@router.put("/{key}", response_model=SettingsResponse)
async def update_setting(
key: str, data: SettingUpdate, db: DbSession
+15
View File
@@ -15,3 +15,18 @@ class SettingsResponse(BaseModel):
"""All runtime-editable settings as a flat key→value map."""
settings: dict[str, str] = Field(default_factory=dict)
class FeatureFlag(BaseModel):
"""One panel-tunable feature flag and its current effective value."""
key: str
label: str
enabled: bool
class FeatureFlagsResponse(BaseModel):
"""Effective feature-flag values for the Settings panel (override, else env)."""
flags: list[FeatureFlag] = Field(default_factory=list)
note: str = "Changes take effect on the next backend restart."
+66
View File
@@ -34,10 +34,32 @@ def _validate_retention_days(value: str) -> None:
raise SettingValidationError("transcript_retention_days must be >= 1")
def _validate_bool(value: str) -> None:
if value.strip().lower() not in ("true", "false"):
raise SettingValidationError("value must be 'true' or 'false'")
# Panel-tunable feature flags (master switches). The stored value overrides the
# config/env default at startup via ``apply_persisted_feature_flags`` — i.e. a
# toggle takes effect on the next restart, replacing hand-editing env. Each maps
# to a ``roboco.config.Settings`` bool attribute of the same name.
FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("external_pr_enabled", "External-PR review"),
("internal_pr_enabled", "Internal-PR safety reviewer"),
("research_enabled", "Web research (Board + PM)"),
("strategy_engine_enabled", "Strategy engine"),
("provisioning_enabled", "Pitch auto-provisioning"),
("rag_auto_update_enabled", "RAG auto-update"),
("transcript_prune_enabled", "Transcript pruning"),
)
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
# Writable settings: key -> validator. Keys absent here are rejected on write so
# the panel can only persist values the backend understands.
_VALIDATORS = {
"transcript_retention_days": _validate_retention_days,
**dict.fromkeys(_FEATURE_FLAG_KEYS, _validate_bool),
}
@@ -69,6 +91,13 @@ class SettingsService(BaseService):
except ValueError:
return default
async def get_bool(self, key: str, default: bool) -> bool:
"""Return ``key`` parsed as a bool ('true'/'false'), or ``default``."""
raw = await self.get(key)
if raw is None:
return default
return raw.strip().lower() == "true"
async def set(self, key: str, value: str) -> None:
"""Validate then upsert ``key`` = ``value``. Caller commits."""
validate_setting(key, value)
@@ -88,3 +117,40 @@ class SettingsService(BaseService):
def get_settings_service(session: AsyncSession) -> SettingsService:
"""Construct a SettingsService bound to ``session``."""
return SettingsService(session)
async def feature_flag_effective_values(session: AsyncSession) -> dict[str, bool]:
"""Effective value of each panel-tunable flag: stored override, else env default.
Backs the Settings panel's feature-flag card so it shows what's actually in
force (the env/config default unless the panel has persisted an override).
"""
from roboco.config import settings as _settings
service = get_settings_service(session)
return {
key: await service.get_bool(key, bool(getattr(_settings, key, False)))
for key in _FEATURE_FLAG_KEYS
}
async def apply_persisted_feature_flags(session: AsyncSession) -> list[str]:
"""Overlay panel-persisted feature-flag overrides onto the live config.
Called once at startup, after the DB is ready: for each known flag with a
stored value, set the matching attribute on the ``roboco.config.settings``
singleton so the rest of the app reads the panel's choice. No per-consumer
re-routing a toggle simply takes effect on the next restart. Returns the
keys that were overridden.
"""
from roboco.config import settings as _settings
service = get_settings_service(session)
applied: list[str] = []
for key in _FEATURE_FLAG_KEYS:
raw = await service.get(key)
if raw is None:
continue
setattr(_settings, key, raw.strip().lower() == "true")
applied.append(key)
return applied
+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