mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user