bug(cli): server_connect writes three config values non-atomically — partial update on failure #1203
+13
-52
@@ -26,7 +26,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Trusted Automation Profile Description** (#9156): Corrected the `trusted` built-in automation profile description from `"Auto for most, human for apply and revert"` to `"Auto-exec, manual apply. Day-to-day development"` to match the specification at line 17197.
|
||||
- **Atomic `server_connect` config writes** (#993): Fixed `server_connect` in
|
||||
`cli/commands/server.py` to write all three config values (`server.url`,
|
||||
`server.namespace`, `server.tls-verify`) atomically. A snapshot of the config
|
||||
file is taken before any writes; if any `set_value()` call fails, the snapshot is
|
||||
restored and compensating `CONFIG_CHANGED` events are emitted for already-applied
|
||||
keys so the audit trail reflects the rollback. Added `emit_config_changed()` helper
|
||||
to `ConfigService` for decoupled event emission in rollback flows. Added
|
||||
`close()` method to `ReactiveEventBus` for proper resource cleanup in tests.
|
||||
Resolved merge conflict in `config_service.py` integrating the PR's
|
||||
`emit_config_changed()` helper with master's scoped config infrastructure.
|
||||
Removed `# type: ignore[assignment]` by introducing a typed `_AutoDiscover`
|
||||
sentinel class. BDD regression coverage in
|
||||
`features/tdd_server_connect_atomic_writes.feature`.
|
||||
|
||||
- **Atomic `load_from_metadata` for Autonomy Guardrails** (#7504): Fixed
|
||||
`AutonomyGuardrailService.load_from_metadata()` to validate both
|
||||
@@ -47,16 +59,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
strict `is True or == 1` instead of `bool()` to prevent truthy non-boolean
|
||||
YAML values (e.g. `unsafe: "no"`) from being treated as unsafe.
|
||||
|
||||
- **Checkpoint Trigger Names and Config Key Path** (#5163): Aligned
|
||||
`docs/specification.md` and `docs/reference/checkpointing.md` with the
|
||||
implementation. Trigger names `on_tool_write` and `on_tool_write_complete`
|
||||
are renamed to `before_tool_execute` and `after_tool_execute` to match
|
||||
`src/cleveragents/tool/runner.py` (`DEFAULT_AUTO_TRIGGERS`) and
|
||||
`src/cleveragents/application/services/config_service.py`. The configuration
|
||||
reference table entry is moved from `sandbox.checkpoint.auto-create-on` to
|
||||
`core.checkpoints.auto-create-on` to match the implementation's
|
||||
`_register("core", "checkpoints.auto_create_on", ...)` call.
|
||||
|
||||
- **UKO Runtime Layer 2 (Paradigm) Indexing** (#9351): Added missing `rdf:type
|
||||
uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
|
||||
Python class definitions are now correctly classified at layer 2 (paradigm/OO)
|
||||
@@ -118,22 +120,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
(#828)
|
||||
|
||||
|
||||
- **Implementation Worker PR Inspection Permissions** (#8175): Added
|
||||
`forgejo_list_pull_request_files` and `forgejo_get_pull_request_diff` permissions
|
||||
to `implementation-worker.md`. These read-only permissions enable the worker to
|
||||
directly inspect PR changes in PR fix mode, reducing unnecessary full-repo clones
|
||||
and aligning permissions with the `pr-reviewer` agent. Also added `curl *`,
|
||||
`printenv *`, and `echo $*` bash permissions needed for Forgejo API calls,
|
||||
environment variable checks, and basic shell operations.
|
||||
|
||||
- Added TDD bug-capture test for bug #991 — AuditService._ensure_session()
|
||||
TOCTOU race. Behave BDD scenario (`@tdd_bug @tdd_bug_991
|
||||
@tdd_expected_fail`) launches 10 concurrent threads through a
|
||||
`threading.Barrier` to prove that `_ensure_session()` creates multiple
|
||||
engines when called without a lock. The test asserts `create_engine` is
|
||||
called exactly once — which currently fails, confirming the race. The
|
||||
`@tdd_expected_fail` tag inverts this to a CI pass until the fix is
|
||||
merged. (#1095)
|
||||
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
|
||||
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
|
||||
@tdd_issue_<N>` tag system. Scenarios whose referenced bugs were already fixed
|
||||
@@ -236,13 +222,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Changed
|
||||
|
||||
- **PR Merge Supervisor Criteria Documentation** (#8107): Updated `pr-merge-pool-supervisor.md` to
|
||||
explicitly document all ten merge criteria that the supervisor applies at runtime. Replaces the
|
||||
implicit six-criteria description with a dedicated "Ten Merge Criteria" section listing all
|
||||
requirements: approval, CI passing, no conflicts, not stale, no `needs feedback` label, not
|
||||
blocked, has milestone, has `Type/` label, has `Closes` reference, and changelog updated.
|
||||
Eliminates the inconsistency between the agent definition and actual runtime behaviour.
|
||||
|
||||
- **Decision Tree Full ULID Display** (#5825): The `agents plan tree` command now
|
||||
displays full 26-character ULIDs for all decisions instead of truncating them to
|
||||
8 characters. This enables users to copy decision IDs directly from tree output
|
||||
@@ -302,16 +281,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
are also protected. The DI container registration as `providers.Singleton`
|
||||
is now correct and safe.
|
||||
|
||||
- **Migration Prompt Safe Default on Failure** (#7503): Fixed
|
||||
`MigrationRunner._default_prompt_for_migration` silently auto-approving destructive
|
||||
database migrations when the interactive prompt raised any exception. The handler now
|
||||
catches only `(IOError, OSError, EOFError)` (broken stdin / non-interactive pipe),
|
||||
logs a `WARNING` instead of a `DEBUG` message, and returns `False` (reject) so that
|
||||
migrations are never applied without explicit user consent. `KeyboardInterrupt` is
|
||||
re-raised so Ctrl-C always works. Non-interactive environments (stdin not a TTY) also
|
||||
now return `False` by default; use `CLEVERAGENTS_AUTO_APPLY_MIGRATIONS=true` or the
|
||||
`--yes` CLI flag to approve automatically.
|
||||
|
||||
- **Validation Gate Empty-Run Guard** (#7508): Fixed `ApplyValidationSummary.all_required_passed`
|
||||
returning `True` when zero validations were run, silently bypassing the apply gate. The property
|
||||
now returns `False` when the validation result set is empty (`is_empty` is `True`), ensuring
|
||||
@@ -374,14 +343,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
a `sqlite3.IntegrityError: UNIQUE constraint failed` crash when `agents plan use` was
|
||||
called on an action that already had arguments registered via `action create`. (#4197)
|
||||
|
||||
- Updated `resource_dag.robot` to use `StaticPool`-based SQLite connections
|
||||
(`poolclass=StaticPool`, `connect_args={"check_same_thread": False}`) in
|
||||
all three test cases, preventing connection sharing issues in the test
|
||||
environment. Updated cycle detection test to use distinct resource types
|
||||
(`robot/cycle-a` and `robot/cycle-b`) instead of a single shared type,
|
||||
improving coverage of cross-type cycle detection. Split from PR #1204
|
||||
per reviewer request for atomic commits. (#1226)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] -- 2026-04-05
|
||||
|
||||
+2
-5
@@ -14,17 +14,14 @@ Below are some of the specific details of various contributions.
|
||||
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed the trusted automation profile description correction (#9156): updated the `trusted` built-in profile description to match the specification.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix: updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed automated bug fixes, security improvements, and migration safety enhancements including the migration prompt safe-default fix (#7503).
|
||||
* HAL 9000 has contributed CONTRIBUTING.md compliance improvements to the agent-evolution-worker (#8370): replaced hardcoded milestone references with dynamic Forgejo API queries and standardised label usage to `Type/Task`.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
* HAL 9000 has contributed the pr-merge-pool-supervisor documentation sync (#8107): updated the agent definition to explicitly list all ten merge criteria that the supervisor applies at runtime, eliminating the inconsistency between the documented six criteria and the actual ten criteria checked.
|
||||
* HAL 9000 has contributed the resource_dag.robot SQLite pool and cycle detection fix (PR #1228 / issue #1226): updated all three test cases to use StaticPool-based SQLite connections and improved cycle detection test coverage with distinct resource types.
|
||||
* HAL 9000 has contributed the atomic `server_connect` config write fix (PR #1203 / issue #993): resolved merge conflict in `config_service.py`, added `emit_config_changed()` helper for decoupled audit event emission, introduced typed `_AutoDiscover` sentinel to eliminate `# type: ignore[assignment]`, added `ReactiveEventBus.close()` for proper test teardown, and fixed hardcoded config path in `server_connect` rollback path.
|
||||
|
||||
@@ -7,8 +7,7 @@ If a middle call fails (e.g. disk full, permissions error), the earlier values
|
||||
are already persisted while the later ones retain their old values, leaving the
|
||||
configuration in a half-written state.
|
||||
|
||||
The test uses the ``@tdd_expected_fail`` tag until the fix in #993 is merged.
|
||||
See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
|
||||
These scenarios now run as normal regression coverage for #993.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +23,9 @@ from unittest.mock import patch
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.config_service import ConfigService
|
||||
from cleveragents.application.services.config_service import ConfigScope, ConfigService
|
||||
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -61,11 +62,14 @@ class _FailingConfigService(ConfigService):
|
||||
config_dir=config_dir,
|
||||
config_path=config_path,
|
||||
event_bus=event_bus,
|
||||
project_root=None,
|
||||
)
|
||||
self._fail_on_call = fail_on_call_number
|
||||
self._call_count = 0
|
||||
|
||||
def set_value(self, key: str, value: Any) -> None:
|
||||
def set_value(
|
||||
self, key: str, value: Any, *, scope: ConfigScope | None = None
|
||||
) -> None:
|
||||
"""Override set_value to fail on a specific call number."""
|
||||
self._call_count += 1
|
||||
if self._call_count == self._fail_on_call:
|
||||
@@ -73,7 +77,7 @@ class _FailingConfigService(ConfigService):
|
||||
f"Simulated disk failure on set_value call #{self._call_count} "
|
||||
f"(key={key!r})"
|
||||
)
|
||||
super().set_value(key, value)
|
||||
super().set_value(key, value, scope=scope)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -96,11 +100,15 @@ def step_fresh_config_dir(context: Context) -> None:
|
||||
context.atomic_config_dir = Path(context.atomic_test_home) / ".cleveragents"
|
||||
context.atomic_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
context.atomic_config_path = context.atomic_config_dir / "config.toml"
|
||||
context.atomic_event_bus = ReactiveEventBus()
|
||||
context.atomic_old_home = os.environ.get("HOME")
|
||||
os.environ["HOME"] = context.atomic_test_home
|
||||
|
||||
# Register cleanup
|
||||
def _cleanup() -> None:
|
||||
event_bus = getattr(context, "atomic_event_bus", None)
|
||||
if event_bus is not None:
|
||||
event_bus.close()
|
||||
old_home = getattr(context, "atomic_old_home", None)
|
||||
if old_home is not None:
|
||||
os.environ["HOME"] = old_home
|
||||
@@ -121,6 +129,7 @@ def step_pre_existing_config(context: Context) -> None:
|
||||
svc = ConfigService(
|
||||
config_dir=context.atomic_config_dir,
|
||||
config_path=context.atomic_config_path,
|
||||
project_root=None,
|
||||
)
|
||||
svc.set_value("server.url", _ORIGINAL_URL)
|
||||
svc.set_value("server.namespace", _ORIGINAL_NAMESPACE)
|
||||
@@ -162,6 +171,7 @@ def step_invoke_with_second_call_failure(context: Context) -> None:
|
||||
config_dir=context.atomic_config_dir,
|
||||
config_path=context.atomic_config_path,
|
||||
fail_on_call_number=2,
|
||||
event_bus=context.atomic_event_bus,
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -197,6 +207,7 @@ def step_invoke_with_third_call_failure(context: Context) -> None:
|
||||
config_dir=context.atomic_config_dir,
|
||||
config_path=context.atomic_config_path,
|
||||
fail_on_call_number=3,
|
||||
event_bus=context.atomic_event_bus,
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -313,3 +324,33 @@ def step_check_no_url(context: Context) -> None:
|
||||
f"The config should be empty (all-or-nothing atomicity) but a "
|
||||
f"partial write occurred."
|
||||
)
|
||||
|
||||
|
||||
@then("the config event trail should include a compensating rollback for server.url")
|
||||
def step_check_compensating_event(context: Context) -> None:
|
||||
"""Assert rollback emits a compensating CONFIG_CHANGED event for server.url."""
|
||||
config_events = [
|
||||
event
|
||||
for event in context.atomic_event_bus.audit_log
|
||||
if event.event_type == EventType.CONFIG_CHANGED
|
||||
and event.details.get("key") == "server.url"
|
||||
]
|
||||
|
||||
assert len(config_events) == 2, (
|
||||
"Expected two server.url CONFIG_CHANGED events (forward write + "
|
||||
f"compensating rollback), got {len(config_events)}."
|
||||
)
|
||||
|
||||
forward_event = config_events[0]
|
||||
rollback_event = config_events[1]
|
||||
|
||||
assert forward_event.details.get("old_value") == _ORIGINAL_URL
|
||||
assert forward_event.details.get("new_value") == _NEW_URL
|
||||
|
||||
assert rollback_event.details.get("old_value") == _NEW_URL
|
||||
assert rollback_event.details.get("new_value") == _ORIGINAL_URL
|
||||
assert rollback_event.details.get("compensating") is True
|
||||
assert (
|
||||
rollback_event.details.get("reason")
|
||||
== "rollback_after_partial_server_connect_failure"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@tdd_expected_fail @tdd_issue @tdd_issue_993
|
||||
@tdd_issue @tdd_issue_993
|
||||
Feature: TDD Issue #993 — server_connect writes three config values non-atomically
|
||||
As a developer
|
||||
I want to verify that server_connect writes all three config values atomically
|
||||
@@ -14,9 +14,7 @@ Feature: TDD Issue #993 — server_connect writes three config values non-atomic
|
||||
# Expected behavior: all three config values are written atomically — either
|
||||
# all succeed or all fail (rollback to original state).
|
||||
#
|
||||
# This test uses the @tdd_expected_fail tag until the fix in #993 is merged.
|
||||
# The tag inverts the result so CI passes while the bug is still unfixed.
|
||||
# See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
|
||||
# This scenario now runs as a normal regression test after the fix for #993.
|
||||
|
||||
Scenario: Config remains unchanged when second set_value fails during server_connect
|
||||
Given a fresh config directory for atomic write test
|
||||
@@ -26,6 +24,7 @@ Feature: TDD Issue #993 — server_connect writes three config values non-atomic
|
||||
And the config should have rolled back server.url to its original value
|
||||
And the config should still have the original server.namespace
|
||||
And the config should still have the original server.tls-verify
|
||||
And the config event trail should include a compensating rollback for server.url
|
||||
|
||||
Scenario: Config remains unchanged when third set_value fails during server_connect
|
||||
Given a fresh config directory for atomic write test
|
||||
|
||||
@@ -1122,6 +1122,13 @@ _DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
|
||||
_PROJECT_ROOT_MARKERS: tuple[str, ...] = ("cleveragents.toml", ".cleveragents")
|
||||
|
||||
|
||||
class _AutoDiscover:
|
||||
"""Sentinel type for auto-discovery of the project root in ``ConfigService``."""
|
||||
|
||||
|
||||
_AUTO_DISCOVER: _AutoDiscover = _AutoDiscover()
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge *override* into *base* (override wins on conflict).
|
||||
|
||||
@@ -1173,13 +1180,13 @@ class ConfigService:
|
||||
config_dir: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
event_bus: EventBus | None = None,
|
||||
project_root: Path | None = ..., # type: ignore[assignment]
|
||||
project_root: Path | _AutoDiscover | None = _AUTO_DISCOVER,
|
||||
) -> 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 ...:
|
||||
# ``_AUTO_DISCOVER`` means "auto-discover"; ``None`` means "no project root".
|
||||
if isinstance(project_root, _AutoDiscover):
|
||||
self._project_root: Path | None = discover_project_root()
|
||||
else:
|
||||
self._project_root = project_root
|
||||
@@ -1319,6 +1326,51 @@ class ConfigService:
|
||||
with open(target, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
def emit_config_changed(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
compensating: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Emit a ``CONFIG_CHANGED`` domain event without mutating config state.
|
||||
|
||||
This helper is used by :meth:`set_value` and rollback flows that need
|
||||
compensating audit events after restoring a prior config snapshot.
|
||||
"""
|
||||
emitted_old = old_value
|
||||
emitted_new = new_value
|
||||
if is_sensitive_key(key):
|
||||
emitted_old = REDACTED
|
||||
emitted_new = REDACTED
|
||||
|
||||
details: dict[str, Any] = {
|
||||
"key": key,
|
||||
"old_value": emitted_old,
|
||||
"new_value": emitted_new,
|
||||
}
|
||||
if compensating:
|
||||
details["compensating"] = True
|
||||
if reason is not None:
|
||||
details["reason"] = reason
|
||||
|
||||
if self._event_bus is not None:
|
||||
try:
|
||||
self._event_bus.emit(
|
||||
DomainEvent(
|
||||
event_type=EventType.CONFIG_CHANGED,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"audit_emit_failed",
|
||||
event_type="CONFIG_CHANGED",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def set_value(
|
||||
self, key: str, value: Any, *, scope: ConfigScope | None = None
|
||||
) -> None:
|
||||
@@ -1346,24 +1398,7 @@ class ConfigService:
|
||||
data[key] = value
|
||||
self.write_scoped_config(data, effective_scope)
|
||||
|
||||
if is_sensitive_key(key):
|
||||
old_value = REDACTED
|
||||
value = REDACTED
|
||||
if self._event_bus is not None:
|
||||
try:
|
||||
self._event_bus.emit(
|
||||
DomainEvent(
|
||||
event_type=EventType.CONFIG_CHANGED,
|
||||
details={
|
||||
"key": key,
|
||||
"old_value": old_value,
|
||||
"new_value": value,
|
||||
"scope": effective_scope.value,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_logger.warning("audit_emit_failed", event_type="CONFIG_CHANGED")
|
||||
self.emit_config_changed(key=key, old_value=old_value, new_value=value)
|
||||
|
||||
# -- validation -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -74,6 +74,35 @@ def _get_config_service() -> ConfigService:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_config_file(config_path: Path) -> bytes | None:
|
||||
"""Capture the current config file bytes, if present."""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
return config_path.read_bytes()
|
||||
|
||||
|
||||
def _restore_config_file(config_path: Path, snapshot: bytes | None) -> None:
|
||||
"""Restore config.toml to a previously captured snapshot."""
|
||||
if snapshot is None:
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
return
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_bytes(snapshot)
|
||||
|
||||
|
||||
def _server_connect_updates(
|
||||
config: ServerConnectionConfig,
|
||||
) -> tuple[tuple[str, Any], ...]:
|
||||
"""Return ordered key updates performed by ``server_connect``."""
|
||||
return (
|
||||
("server.url", config.server_url),
|
||||
("server.namespace", config.namespace),
|
||||
("server.tls-verify", config.tls_verify),
|
||||
)
|
||||
|
||||
|
||||
def resolve_server_mode() -> str:
|
||||
"""Determine the current server mode.
|
||||
|
||||
@@ -140,14 +169,32 @@ def server_connect(
|
||||
console.print(f"[red]Invalid server configuration:[/red] {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
|
||||
# Persist to config file via set_value() so that each change emits
|
||||
# a CONFIG_CHANGED audit event. Using write_config() would bypass
|
||||
# the EventBus and leave no audit trail for these security-relevant
|
||||
# settings (server URL, namespace, TLS verification).
|
||||
# Persist via set_value() so each key produces a CONFIG_CHANGED audit
|
||||
# event. If any write fails, restore the original snapshot and emit
|
||||
# compensating CONFIG_CHANGED events so the audit trail reflects the
|
||||
# rollback (no stale partial-change trail).
|
||||
svc = _get_config_service()
|
||||
svc.set_value("server.url", config.server_url)
|
||||
svc.set_value("server.namespace", config.namespace)
|
||||
svc.set_value("server.tls-verify", config.tls_verify)
|
||||
config_path = svc._config_path
|
||||
|
|
||||
config_snapshot = _snapshot_config_file(config_path)
|
||||
update_items = _server_connect_updates(config)
|
||||
existing_config = svc.read_config()
|
||||
original_values = {key: existing_config.get(key) for key, _ in update_items}
|
||||
applied_updates: list[tuple[str, Any]] = []
|
||||
try:
|
||||
for key, new_value in update_items:
|
||||
svc.set_value(key, new_value)
|
||||
applied_updates.append((key, new_value))
|
||||
except Exception:
|
||||
_restore_config_file(config_path, config_snapshot)
|
||||
for key, attempted_value in reversed(applied_updates):
|
||||
svc.emit_config_changed(
|
||||
key=key,
|
||||
old_value=attempted_value,
|
||||
new_value=original_values.get(key),
|
||||
compensating=True,
|
||||
reason="rollback_after_partial_server_connect_failure",
|
||||
)
|
||||
raise
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"server_url": config.server_url,
|
||||
|
||||
@@ -18,6 +18,7 @@ Based on:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
|
||||
@@ -172,5 +173,17 @@ class ReactiveEventBus:
|
||||
"""
|
||||
return self._stream
|
||||
|
||||
def close(self) -> None:
|
||||
"""Signal completion on the reactive stream and clear all subscriptions.
|
||||
|
||||
Call this when the bus is no longer needed (e.g. in test teardown)
|
||||
to release any RxPY Subject resources and prevent subscription leaks
|
||||
between test scenarios.
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
self._subject.on_completed()
|
||||
self._subscriptions.clear()
|
||||
self._audit_log.clear()
|
||||
|
||||
|
||||
__all__ = ["ReactiveEventBus"]
|
||||
|
||||
Reference in New Issue
Block a user
Hardcoded config path: This duplicates the path computation already done inside
_get_config_service()(lines 67-68). If the config path logic changes in one place but not the other, the snapshot/restore will target a different file thanConfigServiceis writing to.Consider exposing
config_pathas a read-only property onConfigServiceand usingsvc._config_path(or a public accessor) here instead.