Files
roboco/roboco/services/settings.py
T
5612375cba Feat/v0.13.0 (#270)
* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-26 01:43:08 +02:00

166 lines
6.3 KiB
Python

"""System settings service — runtime-editable, panel-tunable config.
Reads/writes the ``system_settings`` key-value table. Values are stored as text
and parsed by typed accessors. Code defaults in ``roboco.config`` are the
fallback used when a key has no row yet. Only keys in ``KNOWN_SETTINGS`` are
writable, each with a validator, so the panel can't persist junk.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import select
from roboco.db.tables import SystemSettingTable
from roboco.services.base import BaseService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
class SettingValidationError(ValueError):
"""Raised when a setting key is unknown or its value is invalid."""
def _validate_retention_days(value: str) -> None:
try:
days = int(value)
except ValueError as exc:
raise SettingValidationError(
"transcript_retention_days must be an integer"
) from exc
if days < 1:
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"),
("self_heal_enabled", "Self-healing (detect + notify)"),
("self_heal_originate_enabled", "Self-healing — open fix tasks"),
("provisioning_enabled", "Pitch auto-provisioning"),
("toolchain_match_enabled", "Agent runtime toolchain matching"),
("conventions_enabled", "Architectural conventions standard"),
("rag_auto_update_enabled", "RAG auto-update"),
("transcript_prune_enabled", "Transcript pruning"),
("gateway_health_enabled", "Gateway-health recovery"),
("ci_watch_enabled", "Multi-repo CI-watch"),
("dep_update_enabled", "Dependency-update bot"),
("release_manager_enabled", "Gated release manager"),
("org_memory_enabled", "Organizational memory loop"),
)
_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),
}
def validate_setting(key: str, value: str) -> None:
"""Raise SettingValidationError if ``key`` is not writable or ``value`` invalid."""
validator = _VALIDATORS.get(key)
if validator is None:
raise SettingValidationError(f"Unknown or read-only setting: {key}")
validator(value)
class SettingsService(BaseService):
"""CRUD for the ``system_settings`` key-value store."""
async def get(self, key: str) -> str | None:
"""Return the stored value for ``key``, or None if unset."""
result = await self.session.execute(
select(SystemSettingTable.value).where(SystemSettingTable.key == key)
)
return result.scalar_one_or_none()
async def get_int(self, key: str, default: int) -> int:
"""Return ``key`` parsed as int, or ``default`` if unset/unparseable."""
raw = await self.get(key)
if raw is None:
return default
try:
return int(raw)
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)
existing = await self.session.get(SystemSettingTable, key)
if existing is None:
self.session.add(SystemSettingTable(key=key, value=value))
else:
existing.value = value
await self.session.flush()
async def all(self) -> dict[str, str]:
"""Return every stored setting as a ``{key: value}`` map."""
result = await self.session.execute(select(SystemSettingTable))
return {row.key: row.value for row in result.scalars().all()}
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