fix(config): integrate emit_config_changed() helper with scoped set_value()
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 2s
CI / integration_tests (pull_request) Failing after 1s
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 2s
CI / lint (pull_request) Successful in 21s
CI / typecheck (pull_request) Successful in 45s
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled

Resolve merge conflict between master's scoped config (ConfigScope,
write_scoped_config, scoped set_value) and PR's atomicity helpers
(emit_config_changed, snapshot/restore, compensating events).

- Keep master's ConfigScope enum, scoped set_value(key, value, *, scope)
- Add emit_config_changed() helper with scope parameter for audit events
- Refactor set_value() to delegate event emission to emit_config_changed()
- Preserve scope in CONFIG_CHANGED event details per master's convention

ISSUES CLOSED: #993
This commit is contained in:
2026-04-02 08:31:24 +00:00
committed by Forgejo
parent 62a30ba363
commit d614ba2fc0
@@ -40,11 +40,20 @@ class ConfigLevel(Enum):
CLI_FLAG = "cli_flag"
ENV_VAR = "env_var"
LOCAL = "local"
PROJECT = "project"
GLOBAL = "global"
DEFAULT = "default"
class ConfigScope(Enum):
"""File-based configuration scopes for ``--scope`` CLI flag."""
GLOBAL = "global"
PROJECT = "project"
LOCAL = "local"
@dataclass(frozen=True, slots=True)
class ConfigEntry:
"""Metadata for a registered configuration key."""
@@ -1095,6 +1104,40 @@ _build_catalog()
_DEFAULT_CONFIG_DIR: Path = Path.home() / ".cleveragents"
_DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
# Markers that identify a CleverAgents project root directory.
_PROJECT_ROOT_MARKERS: tuple[str, ...] = ("cleveragents.toml", ".cleveragents")
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge *override* into *base* (override wins on conflict).
Both *base* and *override* remain unmodified; a new dict is returned.
"""
merged: dict[str, Any] = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def discover_project_root(start: Path | None = None) -> Path | None:
"""Walk up from *start* (default ``Path.cwd()``) to find the project root.
The project root is the nearest ancestor directory that contains one
of the marker files/directories defined in ``_PROJECT_ROOT_MARKERS``
(``cleveragents.toml`` or ``.cleveragents/``).
Returns ``None`` when no project root is found.
"""
current = (start or Path.cwd()).resolve()
for directory in (current, *current.parents):
for marker in _PROJECT_ROOT_MARKERS:
if (directory / marker).exists():
return directory
return None
class ConfigService:
"""Service providing multi-level config resolution with TOML persistence.
@@ -1105,6 +1148,9 @@ class ConfigService:
Override for the configuration directory (default ``~/.cleveragents``).
config_path:
Override for the TOML file path (default ``config_dir / config.toml``).
project_root:
Override for the project root directory. When ``None`` the service
will auto-discover the project root by walking up from ``Path.cwd()``.
"""
def __init__(
@@ -1113,10 +1159,16 @@ class ConfigService:
config_dir: Path | None = None,
config_path: Path | None = None,
event_bus: EventBus | None = None,
project_root: Path | None = ..., # type: ignore[assignment]
) -> None:
self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR
self._config_path: Path = config_path or (self._config_dir / "config.toml")
self._event_bus = event_bus
# ``...`` means "auto-discover"; ``None`` means "no project root".
if project_root is ...:
self._project_root: Path | None = discover_project_root()
else:
self._project_root = project_root
# -- public registry API --------------------------------------------------
@@ -1135,6 +1187,27 @@ class ConfigService:
"""Return all registered key names sorted alphabetically."""
return sorted(_REGISTRY.keys())
# -- path accessors -------------------------------------------------------
@property
def project_root(self) -> Path | None:
"""Return the discovered (or overridden) project root directory."""
return self._project_root
@property
def project_config_path(self) -> Path | None:
"""Return the path to the project-scoped ``config.toml``."""
if self._project_root is None:
return None
return self._project_root / "config.toml"
@property
def local_config_path(self) -> Path | None:
"""Return the path to the local-scoped ``config.local.toml``."""
if self._project_root is None:
return None
return self._project_root / "config.local.toml"
# -- TOML file management -------------------------------------------------
def read_config(self) -> dict[str, Any]:
@@ -1144,8 +1217,35 @@ class ConfigService:
with open(self._config_path, "rb") as fh:
return tomllib.load(fh)
def read_project_config(self) -> dict[str, Any]:
"""Read the project-scoped ``config.toml`` from the project root."""
path = self.project_config_path
if path is None or not path.exists():
return {}
with open(path, "rb") as fh:
return tomllib.load(fh)
def read_local_config(self) -> dict[str, Any]:
"""Read the local-scoped ``config.local.toml`` from the project root."""
path = self.local_config_path
if path is None or not path.exists():
return {}
with open(path, "rb") as fh:
return tomllib.load(fh)
def read_merged_config(self) -> dict[str, Any]:
"""Return the three-scope deep-merged configuration.
Merge order (lowest to highest priority):
``global < project < local``
"""
merged = self.read_config()
merged = _deep_merge(merged, self.read_project_config())
merged = _deep_merge(merged, self.read_local_config())
return merged
def write_config(self, data: dict[str, Any]) -> None:
"""Write *data* to the TOML config file, creating dirs if needed."""
"""Write *data* to the global TOML config file, creating dirs if needed."""
self._config_dir.mkdir(parents=True, exist_ok=True)
if self._config_path.exists():
@@ -1160,12 +1260,58 @@ class ConfigService:
with open(self._config_path, "w") as fh:
tomlkit.dump(doc, fh)
def write_scoped_config(self, data: dict[str, Any], scope: ConfigScope) -> None:
"""Write *data* to the config file for the given *scope*.
Parameters
----------
data:
Key/value pairs to persist.
scope:
Which scope file to write to.
Raises
------
ValueError
If the project root is not discovered and scope is
``PROJECT`` or ``LOCAL``.
"""
if scope == ConfigScope.GLOBAL:
self.write_config(data)
return
if self._project_root is None:
msg = (
f"Cannot write to {scope.value} scope: "
"no project root discovered. "
"Run from within a CleverAgents project directory."
)
raise ValueError(msg)
if scope == ConfigScope.PROJECT:
target = self._project_root / "config.toml"
else:
target = self._project_root / "config.local.toml"
if target.exists():
with open(target) as fh:
doc = tomlkit.load(fh)
else:
doc = tomlkit.document()
for key, value in data.items():
doc[key] = value
with open(target, "w") as fh:
tomlkit.dump(doc, fh)
def emit_config_changed(
self,
*,
key: str,
old_value: Any,
new_value: Any,
scope: str | None = None,
compensating: bool = False,
reason: str | None = None,
) -> None:
@@ -1185,6 +1331,8 @@ class ConfigService:
"old_value": emitted_old,
"new_value": emitted_new,
}
if scope is not None:
details["scope"] = scope
if compensating:
details["compensating"] = True
if reason is not None:
@@ -1201,13 +1349,38 @@ class ConfigService:
except Exception:
_logger.warning("audit_emit_failed", event_type="CONFIG_CHANGED")
def set_value(self, key: str, value: Any) -> None:
"""Persist a single key-value pair into the global TOML config."""
data = self.read_config()
def set_value(
self, key: str, value: Any, *, scope: ConfigScope | None = None
) -> None:
"""Persist a single key-value pair into a scoped TOML config.
Parameters
----------
key:
Configuration key.
value:
Value to persist.
scope:
Target scope. Defaults to ``ConfigScope.GLOBAL``.
"""
effective_scope = scope or ConfigScope.GLOBAL
if effective_scope == ConfigScope.GLOBAL:
data = self.read_config()
elif effective_scope == ConfigScope.PROJECT:
data = self.read_project_config()
else:
data = self.read_local_config()
old_value = data.get(key)
data[key] = value
self.write_config(data)
self.emit_config_changed(key=key, old_value=old_value, new_value=value)
self.write_scoped_config(data, effective_scope)
self.emit_config_changed(
key=key,
old_value=old_value,
new_value=value,
scope=effective_scope.value,
)
# -- validation -----------------------------------------------------------
@@ -1291,7 +1464,7 @@ class ConfigService:
project_name: str | None = None,
verbose: bool = False,
) -> ResolvedValue:
"""Resolve *key* through the five-level precedence chain.
"""Resolve *key* through the six-level precedence chain.
Parameters
----------
@@ -1309,6 +1482,11 @@ class ConfigService:
ResolvedValue
Contains the winning value, its source level, and (if
*verbose*) the full resolution chain for display.
Precedence (highest → lowest)::
CLI flag > env var > local (config.local.toml)
> project (config.toml) > global > default
"""
entry = self.validate_key(key)
chain: list[dict[str, Any]] = []
@@ -1349,25 +1527,71 @@ class ConfigService:
}
)
# Level 3: Project-scoped config
# Level 3: Local config (config.local.toml in project root)
local_val: Any | None = None
if winner_source is None:
local_data = self.read_local_config()
local_val = local_data.get(key)
if local_val is not None and winner_source is None:
coerced = self.validate_type(key, local_val)
if verbose:
chain.append(
{
"source": ConfigLevel.LOCAL.value,
"value": coerced,
"path": str(self.local_config_path or ""),
}
)
winner_value = coerced
winner_source = ConfigLevel.LOCAL
elif verbose:
chain.append(
{
"source": ConfigLevel.LOCAL.value,
"value": None,
"path": str(self.local_config_path or ""),
}
)
# Level 4: Project-scoped config (config.toml in project root,
# or legacy per-project section in global config)
project_val: Any | None = None
if project_name and entry.project_scopable and winner_source is None:
config_data = self.read_config()
project_section = config_data.get("project", {})
if isinstance(project_section, dict):
project_overrides = project_section.get(project_name, {})
if isinstance(project_overrides, dict):
project_val = project_overrides.get(key)
if winner_source is None:
# First try file-based project config
proj_file_data = self.read_project_config()
project_val = proj_file_data.get(key)
# Fall back to legacy per-project section in global config
if project_val is None and project_name and entry.project_scopable:
config_data = self.read_config()
project_section = config_data.get("project", {})
if isinstance(project_section, dict):
project_overrides = project_section.get(project_name, {})
if isinstance(project_overrides, dict):
project_val = project_overrides.get(key)
if project_val is not None and winner_source is None:
coerced = self.validate_type(key, project_val)
if verbose:
chain.append({"source": ConfigLevel.PROJECT.value, "value": coerced})
chain.append(
{
"source": ConfigLevel.PROJECT.value,
"value": coerced,
"path": str(self.project_config_path or ""),
}
)
winner_value = coerced
winner_source = ConfigLevel.PROJECT
elif verbose:
chain.append({"source": ConfigLevel.PROJECT.value, "value": None})
chain.append(
{
"source": ConfigLevel.PROJECT.value,
"value": None,
"path": str(self.project_config_path or ""),
}
)
# Level 4: Global config file
# Level 5: Global config file
global_val: Any | None = None
if winner_source is None:
config_data = self.read_config()
@@ -1393,7 +1617,7 @@ class ConfigService:
}
)
# Level 5: Default
# Level 6: Default
if verbose:
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
if winner_source is None: