fix(cli): handle missing database in session list command #723
@@ -2,6 +2,19 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed `agents session list`, `agents session create`, and other session
|
||||
subcommands raising `AttributeError: 'DynamicContainer' object has no
|
||||
attribute 'db'` after `agents init`. Root cause: `_get_session_service()`
|
||||
called `container.db()` but no `db` provider existed. Added a
|
||||
`session_service` DI provider in `container.py` that builds the engine,
|
||||
sessionmaker, and auto-committing repositories. Rewrote
|
||||
`_get_session_service()` to resolve via the container with module-level
|
||||
caching. Added `auto_commit` parameter to `SessionRepository` and
|
||||
`SessionMessageRepository` to prevent resource leaks in CLI context while
|
||||
preserving Unit-of-Work semantics. Unified error handling across all 7
|
||||
session subcommands. Includes Behave BDD regression scenarios, Robot
|
||||
Framework integration smoke tests, and structlog isolation for parallel
|
||||
test execution. (#554, #570, #680)
|
||||
- Added dedicated E2E test infrastructure: new `nox -s e2e_tests` session
|
||||
running Robot Framework with `--include E2E` tag filter against `robot/e2e/`
|
||||
directory, dedicated CI job with real LLM API key secrets, graceful skip
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# TDD tests for bug #570 — expected to fail until the DI container fix lands.
|
||||
# The @tdd_expected_fail tag causes the test framework to invert pass/fail so
|
||||
# these scenarios pass CI while the bug is unfixed. The bug-fix developer
|
||||
# removes @tdd_expected_fail (keeping @tdd_bug and @tdd_bug_570) once the fix
|
||||
# is applied.
|
||||
# Regression tests for bug #570 — verifies that the DI container fix for
|
||||
# session create command works correctly. The @tdd_expected_fail tags have
|
||||
# been removed now that the fix has landed.
|
||||
Feature: Session create command resolves DI container wiring
|
||||
As a developer using the agents CLI
|
||||
I want "agents session create" to work after a fresh init
|
||||
@@ -11,27 +9,27 @@ Feature: Session create command resolves DI container wiring
|
||||
Background:
|
||||
Given a session-create-error CLI runner using the real DI path
|
||||
|
||||
@tdd_bug @tdd_bug_570 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_570
|
||||
Scenario: Session create produces a new session
|
||||
When I invoke session-create-error create with no arguments
|
||||
Then the session-create-error command should exit successfully
|
||||
And the session-create-error output should contain "session_id:"
|
||||
|
||||
@tdd_bug @tdd_bug_570 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_570
|
||||
Scenario: Created session persists and can be retrieved
|
||||
When I invoke session-create-error create with no arguments
|
||||
Then the session-create-error command should exit successfully
|
||||
When I invoke session-create-error list to verify persistence
|
||||
Then the session-create-error list should show at least one session
|
||||
|
||||
@tdd_bug @tdd_bug_570 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_570
|
||||
Scenario: Session create with custom actor succeeds
|
||||
When I invoke session-create-error create with actor "openai/gpt-4"
|
||||
Then the session-create-error command should exit successfully
|
||||
And the session-create-error output should contain "openai/gpt-4"
|
||||
And the session-create-error output should contain "session_id:"
|
||||
|
||||
@tdd_bug @tdd_bug_570 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_570
|
||||
Scenario: Session create with arbitrary actor name succeeds
|
||||
When I invoke session-create-error create with actor "nonexistent/bogus-actor-999"
|
||||
Then the session-create-error command should exit successfully
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TDD tests for bug #554 — expected to fail until the DI container fix lands.
|
||||
# Once the fix is applied, remove the @tdd_expected_fail tags and verify all
|
||||
# scenarios pass.
|
||||
# Regression tests for bug #554 — verifies that the DI container fix for
|
||||
# session list command works correctly. The @tdd_expected_fail tags have
|
||||
# been removed now that the fix has landed.
|
||||
Feature: Session list command handles missing database gracefully
|
||||
As a developer using the agents CLI
|
||||
I want "agents session list" to work after a fresh init
|
||||
@@ -9,28 +9,28 @@ Feature: Session list command handles missing database gracefully
|
||||
Background:
|
||||
Given a session-list-error CLI runner using the real DI path
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list returns empty list when no sessions exist
|
||||
When I invoke session-list-error list with default format
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should contain "No sessions found"
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list after init does not raise DI error
|
||||
When I invoke session-list-error list with default format
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
And the session-list-error output should not contain "INTERNAL"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list returns sessions after creation via service
|
||||
Given a session-list-error service with a pre-populated session
|
||||
When I invoke session-list-error list with default format
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should contain "Sessions ("
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list works with rich output format
|
||||
Given a session-list-error service with a pre-populated session
|
||||
When I invoke session-list-error list with format "rich"
|
||||
@@ -38,21 +38,21 @@ Feature: Session list command handles missing database gracefully
|
||||
And the session-list-error output should contain "Sessions ("
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list works with JSON output format
|
||||
Given a session-list-error service with a pre-populated session
|
||||
When I invoke session-list-error list with format "json"
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should be valid JSON containing "sessions"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list works with plain output format
|
||||
Given a session-list-error service with a pre-populated session
|
||||
When I invoke session-list-error list with format "plain"
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should contain "Sessions ("
|
||||
And the session-list-error output should contain "total:"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Session list works with YAML output format
|
||||
Given a session-list-error service with a pre-populated session
|
||||
When I invoke session-list-error list with format "yaml"
|
||||
@@ -63,23 +63,23 @@ Feature: Session list command handles missing database gracefully
|
||||
# with explicit output formats. The production code currently bypasses
|
||||
# --format for empty lists, so these document the expected behaviour.
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Empty session list with JSON format produces valid JSON
|
||||
When I invoke session-list-error list with format "json"
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should be valid JSON containing "sessions"
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Empty session list with YAML format produces valid YAML
|
||||
When I invoke session-list-error list with format "yaml"
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should be valid YAML containing "sessions"
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
|
||||
@tdd_bug @tdd_bug_554 @tdd_expected_fail
|
||||
@tdd_bug @tdd_bug_554
|
||||
Scenario: Empty session list with plain format does not error
|
||||
When I invoke session-list-error list with format "plain"
|
||||
Then the session-list-error command should exit successfully
|
||||
And the session-list-error output should contain "No sessions found"
|
||||
And the session-list-error output should contain "total: 0"
|
||||
And the session-list-error output should not contain "AttributeError"
|
||||
|
||||
@@ -102,48 +102,24 @@ def step_service_is_none(context):
|
||||
def step_call_get_session_service(context):
|
||||
import cleveragents.cli.commands.session as mod
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_service_instance = MagicMock()
|
||||
mock_container = MagicMock()
|
||||
mock_container.db.return_value = mock_db
|
||||
mock_container.session_service.return_value = mock_service_instance
|
||||
context._mock_persistent_instance = mock_service_instance
|
||||
|
||||
mock_session_repo_cls = MagicMock()
|
||||
mock_message_repo_cls = MagicMock()
|
||||
mock_persistent_cls = MagicMock()
|
||||
context._mock_persistent_instance = mock_persistent_cls.return_value
|
||||
import sys
|
||||
|
||||
with patch("cleveragents.cli.commands.session.get_container", create=True):
|
||||
# We need to mock the imports that happen inside the function.
|
||||
# The function does:
|
||||
# from cleveragents.application.container import get_container
|
||||
# from cleveragents.application.services.session_service import PersistentSessionService
|
||||
# from cleveragents.infrastructure.database.repositories import SessionRepository, SessionMessageRepository
|
||||
# We mock these at the module import level inside the function.
|
||||
mock_container_mod = MagicMock()
|
||||
mock_container_mod.get_container = MagicMock(return_value=mock_container)
|
||||
|
||||
import sys
|
||||
|
||||
# Create mock modules
|
||||
mock_container_mod = MagicMock()
|
||||
mock_container_mod.get_container = MagicMock(return_value=mock_container)
|
||||
|
||||
mock_service_mod = MagicMock()
|
||||
mock_service_mod.PersistentSessionService = mock_persistent_cls
|
||||
|
||||
mock_repo_mod = MagicMock()
|
||||
mock_repo_mod.SessionRepository = mock_session_repo_cls
|
||||
mock_repo_mod.SessionMessageRepository = mock_message_repo_cls
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"cleveragents.application.container": mock_container_mod,
|
||||
"cleveragents.application.services.session_service": mock_service_mod,
|
||||
"cleveragents.infrastructure.database.repositories": mock_repo_mod,
|
||||
},
|
||||
):
|
||||
# Force re-import by deleting cached names if any
|
||||
# The function uses local imports so they run every time
|
||||
result = mod._get_session_service()
|
||||
context._get_service_result = result
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"cleveragents.application.container": mock_container_mod,
|
||||
},
|
||||
):
|
||||
result = mod._get_session_service()
|
||||
context._get_service_result = result
|
||||
|
||||
|
||||
@then("session cli branch a PersistentSessionService is returned")
|
||||
|
||||
@@ -24,6 +24,7 @@ wiring that triggers the bug, so we must bypass the cache to exercise it.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -32,7 +33,7 @@ import yaml
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.application.container import reset_container
|
||||
@@ -41,6 +42,7 @@ from cleveragents.application.services.session_service import (
|
||||
)
|
||||
from cleveragents.cli.commands import session as session_mod
|
||||
from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
SessionMessageRepository,
|
||||
@@ -55,6 +57,37 @@ runner = CliRunner()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _suppress_structlog_stdout() -> tuple[int, bool]:
|
||||
"""Prevent structlog debug lines from contaminating CLI stdout.
|
||||
|
||||
See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the
|
||||
full rationale.
|
||||
"""
|
||||
import structlog
|
||||
|
||||
root = logging.getLogger()
|
||||
prev_level = root.level
|
||||
root.setLevel(logging.WARNING)
|
||||
|
||||
prev_config = structlog.get_config()
|
||||
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
|
||||
structlog.configure(
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=False,
|
||||
)
|
||||
|
||||
return prev_level, prev_cache
|
||||
|
||||
|
||||
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
|
||||
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
|
||||
import structlog
|
||||
|
||||
logging.getLogger().setLevel(prev_level)
|
||||
structlog.configure(cache_logger_on_first_use=prev_cache)
|
||||
|
||||
|
||||
def _setup_real_di_path(context: Context) -> None:
|
||||
"""Prepare a temp dir with a fresh SQLite DB and override container."""
|
||||
# Store original _service so cleanup can restore it.
|
||||
@@ -63,6 +96,11 @@ def _setup_real_di_path(context: Context) -> None:
|
||||
# Reset any stale DI container singleton before configuring the env var
|
||||
# so that cached providers don't carry over from a prior test suite (F17).
|
||||
reset_container()
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
|
||||
# Suppress structlog debug output so CliRunner captures clean output.
|
||||
prev_level, prev_cache = _suppress_structlog_stdout()
|
||||
context.sle_log_prev = (prev_level, prev_cache)
|
||||
|
||||
context.sle_tmpdir = tempfile.mkdtemp(prefix="session_list_err_554_")
|
||||
|
||||
@@ -99,6 +137,11 @@ def _cleanup_sle(context: Context) -> None:
|
||||
|
||||
# Reset the DI container singleton to avoid polluting later scenarios.
|
||||
reset_container()
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
|
||||
# Restore structlog / logging state.
|
||||
prev_level, prev_cache = context.sle_log_prev
|
||||
_restore_structlog(prev_level, prev_cache)
|
||||
|
||||
shutil.rmtree(context.sle_tmpdir, ignore_errors=True)
|
||||
|
||||
@@ -124,16 +167,11 @@ def step_pre_populate_session(context: Context) -> None:
|
||||
db_url = f"sqlite:///{context.sle_db_path}"
|
||||
engine = create_engine(db_url, echo=False)
|
||||
try:
|
||||
factory = scoped_session(
|
||||
sessionmaker(bind=engine, expire_on_commit=False),
|
||||
)
|
||||
repo = SessionRepository(session_factory=factory)
|
||||
msg_repo = SessionMessageRepository(session_factory=factory)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
repo = SessionRepository(session_factory=factory, auto_commit=True)
|
||||
msg_repo = SessionMessageRepository(session_factory=factory, auto_commit=True)
|
||||
svc = PersistentSessionService(repo, msg_repo)
|
||||
svc.create(actor_name="openai/gpt-4")
|
||||
# Commit via the scoped session so the data is visible to later queries.
|
||||
factory().commit()
|
||||
factory.remove()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Step definitions for TDD Bug #570 — session create DI error.
|
||||
"""Step definitions for TDD Bug #570 — session create DI regression tests.
|
||||
|
||||
These steps exercise the *real* DI path in ``_get_session_service()`` without
|
||||
mocking, so the ``container.db()`` ``AttributeError`` is triggered. The
|
||||
``@tdd_expected_fail`` tag on the scenarios inverts the result.
|
||||
These steps exercise the *real* DI path in ``_get_session_service()``.
|
||||
Bug #570 has been fixed (same root cause as #554): the DI container
|
||||
now provides a ``session_service`` provider that builds the service
|
||||
correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Step definitions for TDD Bug #554 — session list DI error.
|
||||
"""Step definitions for TDD Bug #554 — session list DI regression tests.
|
||||
|
||||
These steps exercise the *real* DI path in ``_get_session_service()`` without
|
||||
mocking, so the ``container.db()`` ``AttributeError`` is triggered. The
|
||||
``@tdd_expected_fail`` tag on the scenarios inverts the result: these tests
|
||||
**pass** CI while the bug is present and will **fail** once the bug is fixed
|
||||
(signalling that the tag should be removed).
|
||||
These steps exercise the *real* DI path in ``_get_session_service()``.
|
||||
Bug #554 has been fixed: the DI container now provides a
|
||||
``session_service`` provider that builds the service correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,7 +15,9 @@ Container provides a ``db`` Singleton that auto-creates the database via
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +31,37 @@ from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
|
||||
def _suppress_structlog_stdout() -> tuple[int, bool]:
|
||||
"""Prevent structlog debug lines from contaminating CLI stdout.
|
||||
|
||||
See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the
|
||||
full rationale.
|
||||
"""
|
||||
import structlog
|
||||
|
||||
root = logging.getLogger()
|
||||
prev_level = root.level
|
||||
root.setLevel(logging.WARNING)
|
||||
|
||||
prev_config = structlog.get_config()
|
||||
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
|
||||
structlog.configure(
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=False,
|
||||
)
|
||||
|
||||
return prev_level, prev_cache
|
||||
|
||||
|
||||
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
|
||||
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
|
||||
import structlog
|
||||
|
||||
logging.getLogger().setLevel(prev_level)
|
||||
structlog.configure(cache_logger_on_first_use=prev_cache)
|
||||
|
||||
|
||||
@given("a CLI runner with no database file present")
|
||||
def step_runner_no_db(context: Context) -> None:
|
||||
"""Set up a CLI runner pointing at a database path that does not exist.
|
||||
@@ -38,6 +71,14 @@ def step_runner_no_db(context: Context) -> None:
|
||||
auto-create the database or fail with an appropriate error.
|
||||
"""
|
||||
context.runner = CliRunner()
|
||||
|
||||
# Reset singletons before setup so stale state does not leak in.
|
||||
reset_container()
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
|
||||
# Suppress structlog debug output so CliRunner captures clean output.
|
||||
prev_level, prev_cache = _suppress_structlog_stdout()
|
||||
|
||||
context.missing_db_tmpdir = tempfile.mkdtemp(prefix="tdd_missing_db_680_")
|
||||
# Point at a file that does NOT exist — this is the key difference from
|
||||
# the shared step ``a CLI runner using the real session DI path`` which
|
||||
@@ -53,13 +94,12 @@ def step_runner_no_db(context: Context) -> None:
|
||||
session_mod._service = None
|
||||
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
|
||||
reset_container()
|
||||
Settings._instance = None
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
# The fix may auto-create the database file, so clean it up.
|
||||
Path(db_path).unlink(missing_ok=True)
|
||||
# Remove the temp directory.
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(context.missing_db_tmpdir, ignore_errors=True)
|
||||
_restore_structlog(prev_level, prev_cache)
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ shared steps live in clearly named reusable modules.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -23,25 +24,87 @@ from cleveragents.cli.commands import session as session_mod
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
|
||||
def _suppress_structlog_stdout() -> tuple[int, bool]:
|
||||
"""Prevent structlog debug lines from contaminating CLI stdout.
|
||||
|
||||
When ``behave-parallel`` runs multiple features in one process,
|
||||
another feature may (re)configure structlog with a ``ConsoleRenderer``
|
||||
or leave it at the default ``PrintLogger`` — both write to
|
||||
``sys.stdout``. The ``@database_retry`` decorator logs a *debug*
|
||||
event on every attempt, and ``CliRunner.invoke()`` captures
|
||||
``sys.stdout``, so those debug lines end up in the captured output,
|
||||
breaking JSON/YAML assertions.
|
||||
|
||||
Returns the previous root-logger level and ``cache_logger_on_first_use``
|
||||
flag so that the caller's cleanup function can restore them.
|
||||
"""
|
||||
import structlog
|
||||
|
||||
root = logging.getLogger()
|
||||
prev_level = root.level
|
||||
root.setLevel(logging.WARNING)
|
||||
|
||||
# Force structlog to route through stdlib logging (not PrintLogger)
|
||||
# and disable logger caching so the level change takes effect for
|
||||
# loggers that were already instantiated.
|
||||
prev_config = structlog.get_config()
|
||||
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
|
||||
structlog.configure(
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=False,
|
||||
)
|
||||
|
||||
return prev_level, prev_cache
|
||||
|
||||
|
||||
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
|
||||
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
|
||||
import structlog
|
||||
|
||||
logging.getLogger().setLevel(prev_level)
|
||||
structlog.configure(cache_logger_on_first_use=prev_cache)
|
||||
|
||||
|
||||
@given("a CLI runner using the real session DI path")
|
||||
def step_real_di_runner(context: Context) -> None:
|
||||
"""Set up a CLI runner that does NOT mock the session service.
|
||||
|
||||
By ensuring ``session_mod._service`` is ``None``, the CLI will call
|
||||
``_get_session_service()`` which hits the real DI container and
|
||||
triggers the ``container.db()`` bug.
|
||||
resolves the ``session_service`` provider.
|
||||
"""
|
||||
context.runner = CliRunner()
|
||||
|
||||
# Ensure we go through the real DI path — no mock service.
|
||||
session_mod._service = None
|
||||
|
||||
# Provide a database URL so the container can be constructed (the bug
|
||||
# triggers before the URL is actually used).
|
||||
# Reset singletons before setup so stale state from a parallel
|
||||
# scenario does not leak in.
|
||||
reset_container()
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
|
||||
# Suppress structlog debug output so CliRunner captures clean output.
|
||||
prev_level, prev_cache = _suppress_structlog_stdout()
|
||||
|
||||
# Provide a database URL so the container can be constructed.
|
||||
fd, db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
context._tdd_db_path = db_path
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url
|
||||
|
||||
# Create the schema so the database file is ready for use.
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
|
||||
engine = create_engine(db_url, echo=False)
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def _cleanup() -> None:
|
||||
session_mod._service = None
|
||||
@@ -49,8 +112,9 @@ def step_real_di_runner(context: Context) -> None:
|
||||
reset_container()
|
||||
# Reset the Settings singleton so stale database URLs from this
|
||||
# scenario do not leak into subsequent scenarios.
|
||||
Settings._instance = None
|
||||
Settings._instance = None # type: ignore[attr-defined]
|
||||
Path(db_path).unlink(missing_ok=True)
|
||||
_restore_structlog(prev_level, prev_cache)
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@@ -9,19 +9,16 @@ Feature: TDD Bug #570 — session create DI container missing db provider
|
||||
session.py calls `container.db()`, but the Container class has no `db`
|
||||
provider, causing an AttributeError at runtime.
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session create command succeeds via DI container
|
||||
Given a CLI runner using the real session DI path
|
||||
When I invoke the session create command
|
||||
Then the session create command should exit successfully
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session create with actor succeeds via DI container
|
||||
Given a CLI runner using the real session DI path
|
||||
When I invoke the session create command with actor "openai/gpt-4"
|
||||
Then the session create command should exit successfully
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session create command produces structured output via DI
|
||||
Given a CLI runner using the real session DI path
|
||||
When I invoke the session create command with format json
|
||||
|
||||
@@ -9,19 +9,16 @@ Feature: TDD Bug #554 — session list DI container missing db provider
|
||||
`container.db()`, but the Container class has no `db` provider, causing
|
||||
an AttributeError at runtime.
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list command succeeds via DI container
|
||||
Given a CLI runner using the real session DI path
|
||||
When I invoke the session list command
|
||||
Then the session list command should exit successfully
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list DI path resolves a SessionService
|
||||
Given a CLI runner using the real session DI path
|
||||
When I request the session service from the DI container
|
||||
Then the session service should be a valid SessionService instance
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list command produces structured output via DI
|
||||
Given a CLI runner using the real session DI path
|
||||
When I invoke the session list command with format json
|
||||
|
||||
@@ -12,21 +12,18 @@ Feature: TDD Bug #680 — session list with missing database
|
||||
database file itself must be auto-created so the command returns an
|
||||
empty list rather than crashing.
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list with missing database exits successfully
|
||||
Given a CLI runner with no database file present
|
||||
When I invoke session list with missing db
|
||||
Then the session list missing db command should exit successfully
|
||||
And the session list missing db output should not contain "AttributeError"
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list with missing database produces valid JSON
|
||||
Given a CLI runner with no database file present
|
||||
When I invoke session list with missing db and format json
|
||||
Then the session list missing db command should exit successfully
|
||||
And the session list missing db output should be valid JSON
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Session list with missing database shows empty list
|
||||
Given a CLI runner with no database file present
|
||||
When I invoke session list with missing db
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
*** Settings ***
|
||||
Documentation Integration smoke test for session create DI error (bug #570).
|
||||
... TDD-style tests — expected to FAIL until the DI container fix
|
||||
... lands. The bug is that ``_get_session_service()`` calls
|
||||
... ``container.db()`` but the DI container has no ``db`` provider,
|
||||
... causing an ``AttributeError``. Same root cause as bug #554.
|
||||
... Regression tests verifying that the DI container fix for
|
||||
... session create works correctly. Bug #570 is now fixed
|
||||
... (same root cause as bug #554).
|
||||
Resource ${CURDIR}/common.resource
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
@@ -15,7 +14,7 @@ Session Create After Init Should Not Error
|
||||
[Documentation] After agents init, session create --format plain should
|
||||
... exit 0 and produce a new session rather than a DI
|
||||
... AttributeError.
|
||||
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_570
|
||||
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sce_570_')
|
||||
${init}= Run Process ${PYTHON} -m cleveragents init sce-test
|
||||
... timeout=60s cwd=${tmpdir}
|
||||
@@ -31,7 +30,7 @@ Session Create After Init Should Not Error
|
||||
|
||||
Session Create Then List Shows Created Session
|
||||
[Documentation] After creating a session, listing should show it.
|
||||
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_570
|
||||
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sce_570_list_')
|
||||
${init}= Run Process ${PYTHON} -m cleveragents init sce-list
|
||||
... timeout=60s cwd=${tmpdir}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
*** Settings ***
|
||||
Documentation Integration smoke test for session list DI error (bug #554).
|
||||
... TDD-style tests — expected to FAIL until the DI container fix
|
||||
... lands. The bug is that ``_get_session_service()`` calls
|
||||
... ``container.db()`` but the DI container has no ``db`` provider,
|
||||
... causing an ``AttributeError``.
|
||||
... Regression tests verifying that the DI container fix for
|
||||
... session list works correctly. Bug #554 is now fixed.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
@@ -22,7 +20,7 @@ Suite Teardown Cleanup Test Environment
|
||||
Session List After Init Should Not Error
|
||||
[Documentation] After agents init, session list should exit 0 and show
|
||||
... "No sessions found" rather than a DI AttributeError.
|
||||
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_554
|
||||
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sle_554_')
|
||||
${init}= Run Process ${PYTHON} -m cleveragents init sle-test
|
||||
... timeout=60s cwd=${tmpdir}
|
||||
@@ -41,7 +39,7 @@ Session List After Init Should Not Error
|
||||
Session List JSON Format Does Not Error
|
||||
[Documentation] session list --format json should exit 0 without raising
|
||||
... a DI AttributeError.
|
||||
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_554
|
||||
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sle_554_json_')
|
||||
${init}= Run Process ${PYTHON} -m cleveragents init sle-json
|
||||
... timeout=60s cwd=${tmpdir}
|
||||
|
||||
@@ -12,7 +12,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_create_di.py
|
||||
*** Test Cases ***
|
||||
TDD Session Create DI Error Via CLI
|
||||
[Documentation] Verify that ``session create`` triggers the DI db error
|
||||
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_570
|
||||
${result}= Run Process ${PYTHON} ${HELPER} create-di-error cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -21,7 +21,7 @@ TDD Session Create DI Error Via CLI
|
||||
|
||||
TDD Session Create With Actor DI Error
|
||||
[Documentation] Verify that ``session create --actor`` triggers the DI db error
|
||||
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_570
|
||||
${result}= Run Process ${PYTHON} ${HELPER} create-actor cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -30,7 +30,7 @@ TDD Session Create With Actor DI Error
|
||||
|
||||
TDD Session Create DI JSON Output
|
||||
[Documentation] Verify that ``session create --format json`` fails due to DI db error
|
||||
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_570
|
||||
${result}= Run Process ${PYTHON} ${HELPER} create-json cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Documentation TDD Bug #554 — session list DI container missing db provider
|
||||
... Integration smoke tests verifying that the session list command
|
||||
... succeeds once the DI container has a proper ``db`` provider.
|
||||
... Tagged ``tdd_expected_fail`` until bug #554 is resolved.
|
||||
... Bug #554 is now fixed; these tests serve as regression tests.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
@@ -13,7 +13,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_list_di.py
|
||||
*** Test Cases ***
|
||||
TDD Session List DI Error Via CLI
|
||||
[Documentation] Verify that ``session list`` succeeds via the real DI path
|
||||
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_554
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-di-error cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -22,7 +22,7 @@ TDD Session List DI Error Via CLI
|
||||
|
||||
TDD Session List DI Service Resolution
|
||||
[Documentation] Verify that ``_get_session_service()`` resolves a valid service
|
||||
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_554
|
||||
${result}= Run Process ${PYTHON} ${HELPER} service-resolution cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -31,7 +31,7 @@ TDD Session List DI Service Resolution
|
||||
|
||||
TDD Session List DI JSON Output
|
||||
[Documentation] Verify that ``session list --format json`` succeeds via the real DI path
|
||||
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_554
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-json cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -4,7 +4,7 @@ Documentation TDD Bug #680 — session list with missing database
|
||||
... handles a missing database gracefully. The database URL points
|
||||
... to a non-existent file; the command should auto-create the DB
|
||||
... and return an empty list rather than crashing.
|
||||
... Tagged ``tdd_expected_fail`` until bug #680 is resolved.
|
||||
... Bug #680 is now fixed; these tests serve as regression tests.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
@@ -15,7 +15,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_list_missing_db.py
|
||||
*** Test Cases ***
|
||||
TDD Session List Missing DB Via CLI
|
||||
[Documentation] Verify that ``session list`` succeeds when no database file exists
|
||||
[Tags] tdd_bug tdd_bug_680 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_680
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-missing-db cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -24,7 +24,7 @@ TDD Session List Missing DB Via CLI
|
||||
|
||||
TDD Session List Missing DB JSON Output
|
||||
[Documentation] Verify that ``session list --format json`` succeeds when no database file exists
|
||||
[Tags] tdd_bug tdd_bug_680 tdd_expected_fail
|
||||
[Tags] tdd_bug tdd_bug_680
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-missing-db-json cwd=${WORKSPACE} timeout=30s
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -47,6 +47,9 @@ from cleveragents.application.services.resource_file_watcher import (
|
||||
from cleveragents.application.services.resource_registry_service import (
|
||||
ResourceRegistryService,
|
||||
)
|
||||
from cleveragents.application.services.session_service import (
|
||||
PersistentSessionService,
|
||||
)
|
||||
from cleveragents.application.services.skeleton_compressor import (
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
@@ -74,6 +77,8 @@ from cleveragents.infrastructure.database.repositories import (
|
||||
CheckpointRepository,
|
||||
NamespacedProjectRepository,
|
||||
ProjectResourceLinkRepository,
|
||||
SessionMessageRepository,
|
||||
SessionRepository,
|
||||
)
|
||||
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
||||
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
|
||||
@@ -281,6 +286,49 @@ def _build_trace_service(
|
||||
return TraceService(settings=resolved_settings, repository=repository)
|
||||
|
||||
|
||||
def _build_session_service(
|
||||
database_url: str,
|
||||
) -> PersistentSessionService:
|
||||
"""Build a PersistentSessionService with auto-committing repositories.
|
||||
|
||||
The CLI operates outside a ``UnitOfWork``, so repositories are
|
||||
constructed with ``auto_commit=True`` to ensure each operation
|
||||
commits and closes its own database session.
|
||||
|
||||
Session and session-message tables are created if they do not yet
|
||||
exist. Only the two session-related tables are touched — this
|
||||
avoids the broad ``Base.metadata.create_all()`` that would bypass
|
||||
Alembic for every other model (review finding M3/M8).
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
Base,
|
||||
SessionMessageModel,
|
||||
SessionModel,
|
||||
)
|
||||
|
||||
engine = create_engine(database_url, echo=False)
|
||||
|
||||
# Targeted table creation — only session tables, never the full schema.
|
||||
inspector = sa_inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
tables_to_create = [
|
||||
model.__table__
|
||||
for model in (SessionModel, SessionMessageModel)
|
||||
if model.__tablename__ not in existing_tables
|
||||
]
|
||||
if tables_to_create:
|
||||
Base.metadata.create_all(engine, tables=tables_to_create)
|
||||
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
session_repo = SessionRepository(session_factory=factory, auto_commit=True)
|
||||
message_repo = SessionMessageRepository(session_factory=factory, auto_commit=True)
|
||||
return PersistentSessionService(session_repo, message_repo)
|
||||
|
||||
|
||||
class Container(containers.DeclarativeContainer):
|
||||
"""Dependency injection container using dependency-injector.
|
||||
|
||||
@@ -446,6 +494,12 @@ class Container(containers.DeclarativeContainer):
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Session Service - database-backed session management (Forgejo #554, #570, #680)
|
||||
session_service = providers.Factory(
|
||||
_build_session_service,
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
# Metrics Emitter - structured metric emission (Forgejo #579)
|
||||
metrics_emitter = providers.Singleton(
|
||||
MetricsEmitter.from_settings,
|
||||
|
||||
@@ -10,10 +10,11 @@ task A7.cli.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
@@ -21,6 +22,7 @@ from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
@@ -34,6 +36,8 @@ from cleveragents.domain.models.core.session import (
|
||||
app = typer.Typer(help="Manage interactive sessions.")
|
||||
console = Console()
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Reusable --format option description
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
@@ -46,27 +50,19 @@ _service: SessionService | None = None
|
||||
def _get_session_service() -> SessionService:
|
||||
"""Get or create the SessionService instance.
|
||||
|
||||
Production usage goes through the DI container; tests can patch
|
||||
``_service`` or this function directly.
|
||||
Production usage goes through the DI container's ``session_service``
|
||||
provider; tests can patch ``_service`` or this function directly.
|
||||
"""
|
||||
global _service
|
||||
if _service is not None:
|
||||
return _service
|
||||
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.application.services.session_service import (
|
||||
PersistentSessionService,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
SessionMessageRepository,
|
||||
SessionRepository,
|
||||
)
|
||||
|
||||
container = get_container()
|
||||
db = container.db()
|
||||
session_repo = SessionRepository(db)
|
||||
message_repo = SessionMessageRepository(db)
|
||||
return PersistentSessionService(session_repo, message_repo)
|
||||
svc = cast(SessionService, container.session_service())
|
||||
|
|
||||
_service = svc
|
||||
return _service
|
||||
|
||||
|
||||
def _reset_session_service() -> None:
|
||||
@@ -141,7 +137,7 @@ def create(
|
||||
|
||||
data = _session_summary_dict(session)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
|
||||
typer.echo(format_output(dict(data), fmt))
|
||||
return
|
||||
|
||||
@@ -157,6 +153,13 @@ def create(
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session create failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@app.command("list")
|
||||
@@ -175,17 +178,30 @@ def list_sessions(
|
||||
agents session list --format json
|
||||
agents session list --format table
|
||||
"""
|
||||
service = _get_session_service()
|
||||
sessions = service.list()
|
||||
try:
|
||||
service = _get_session_service()
|
||||
sessions = service.list()
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session list failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
if not sessions:
|
||||
# For machine-readable formats, always emit a structured empty list
|
||||
# so that callers parsing JSON/YAML receive valid output.
|
||||
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
|
||||
typer.echo(format_output({"sessions": [], "total": 0}, fmt))
|
||||
return
|
||||
console.print("[yellow]No sessions found.[/yellow]")
|
||||
console.print("Create one with 'agents session create'")
|
||||
return
|
||||
|
||||
data = _session_list_dict(sessions)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
|
||||
typer.echo(format_output(data, fmt))
|
||||
return
|
||||
|
||||
@@ -239,7 +255,7 @@ def show(
|
||||
session = service.get(session_id)
|
||||
data = session.as_cli_dict()
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
|
||||
typer.echo(format_output(dict(data), fmt))
|
||||
return
|
||||
|
||||
@@ -311,6 +327,13 @@ def show(
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session show failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -350,6 +373,13 @@ def delete(
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session delete failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@app.command("export")
|
||||
@@ -402,6 +432,13 @@ def export_session(
|
||||
except SessionExportError as exc:
|
||||
console.print(f"[red]Export error:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session export failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@app.command("import")
|
||||
@@ -446,6 +483,13 @@ def import_session(
|
||||
except SessionImportError as exc:
|
||||
console.print(f"[red]Import error:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session import failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -514,3 +558,10 @@ def tell(
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
raise typer.Exit(1) from exc
|
||||
except DatabaseError as exc:
|
||||
_log.debug("session tell failed", exc_info=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Database unavailable: {exc}\n"
|
||||
"Hint: run 'agents init' to initialise the database."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
@@ -3869,13 +3869,28 @@ class SessionRepository:
|
||||
Uses a session-factory pattern: each public method obtains its own
|
||||
session from the factory, ensuring proper session lifecycle management.
|
||||
|
||||
All mutating methods flush (but do NOT commit); the caller or a
|
||||
``UnitOfWork`` wrapper is responsible for committing the transaction.
|
||||
All mutating methods flush (but do NOT commit) by default; the caller
|
||||
or a ``UnitOfWork`` wrapper is responsible for committing the
|
||||
transaction. When ``auto_commit`` is ``True`` (e.g. CLI usage outside
|
||||
a UoW), each method commits and closes its own session.
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session."""
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
*,
|
||||
auto_commit: bool = False,
|
||||
) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session.
|
||||
|
||||
Args:
|
||||
session_factory: Factory returning a new SQLAlchemy ``Session``.
|
||||
auto_commit: When ``True``, each public method commits and
|
||||
closes its session automatically. Useful for CLI commands
|
||||
that operate outside a ``UnitOfWork``.
|
||||
"""
|
||||
self._session_factory = session_factory
|
||||
self._auto_commit = auto_commit
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""Convenience helper to obtain a session."""
|
||||
@@ -3899,6 +3914,8 @@ class SessionRepository:
|
||||
db_model = SessionModel.from_domain(session)
|
||||
db_session.add(db_model)
|
||||
db_session.flush()
|
||||
if self._auto_commit:
|
||||
db_session.commit()
|
||||
return session
|
||||
except IntegrityError as exc:
|
||||
db_session.rollback()
|
||||
@@ -3906,6 +3923,9 @@ class SessionRepository:
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
db_session.rollback()
|
||||
raise DatabaseError(f"Failed to create session: {exc}") from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def get_by_id(self, session_id: str) -> Any | None:
|
||||
@@ -3924,6 +3944,9 @@ class SessionRepository:
|
||||
return row.to_domain()
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to get session {session_id}: {exc}") from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def list_all(self, actor_name: str | None = None) -> list[Any]:
|
||||
@@ -3944,6 +3967,9 @@ class SessionRepository:
|
||||
return [row.to_domain() for row in rows]
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to list sessions: {exc}") from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def delete(self, session_id: str) -> bool:
|
||||
@@ -3964,12 +3990,17 @@ class SessionRepository:
|
||||
return False
|
||||
db_session.delete(row)
|
||||
db_session.flush()
|
||||
if self._auto_commit:
|
||||
db_session.commit()
|
||||
return True
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
db_session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to delete session {session_id}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def update(self, session: Any) -> Any:
|
||||
@@ -4015,12 +4046,17 @@ class SessionRepository:
|
||||
row.updated_at = session.updated_at.isoformat() # type: ignore[assignment]
|
||||
|
||||
db_session.flush()
|
||||
if self._auto_commit:
|
||||
db_session.commit()
|
||||
return session
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
db_session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to update session {session.session_id}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4032,12 +4068,25 @@ class SessionMessageRepository:
|
||||
"""Repository for session message persistence.
|
||||
|
||||
Uses a session-factory pattern matching ``ActionRepository``.
|
||||
All mutating methods flush but do NOT commit.
|
||||
All mutating methods flush but do NOT commit by default. When
|
||||
``auto_commit`` is ``True``, each method commits and closes its
|
||||
own session (for CLI usage outside a ``UnitOfWork``).
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session."""
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
*,
|
||||
auto_commit: bool = False,
|
||||
) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session.
|
||||
|
||||
Args:
|
||||
session_factory: Factory returning a new SQLAlchemy ``Session``.
|
||||
auto_commit: When ``True``, commit and close after each op.
|
||||
"""
|
||||
self._session_factory = session_factory
|
||||
self._auto_commit = auto_commit
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""Convenience helper to obtain a session."""
|
||||
@@ -4060,12 +4109,17 @@ class SessionMessageRepository:
|
||||
db_model.session_id = session_id # type: ignore[assignment]
|
||||
db_session.add(db_model)
|
||||
db_session.flush()
|
||||
if self._auto_commit:
|
||||
db_session.commit()
|
||||
return message
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
db_session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to append message to session {session_id}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def get_for_session(
|
||||
@@ -4100,6 +4154,9 @@ class SessionMessageRepository:
|
||||
raise DatabaseError(
|
||||
f"Failed to get messages for session {session_id}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
@database_retry
|
||||
def count_for_session(self, session_id: str) -> int:
|
||||
@@ -4122,6 +4179,9 @@ class SessionMessageRepository:
|
||||
raise DatabaseError(
|
||||
f"Failed to count messages for session {session_id}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if self._auto_commit:
|
||||
db_session.close()
|
||||
|
||||
|
||||
# AutomationProfile Repository Errors
|
||||
|
||||
Reference in New Issue
Block a user
F1 (P1:must-fix) —
# type: ignore[assignment]violates CONTRIBUTING.md line 548 ("Never use# type: ignoreinsrc/").Fix:
svc = cast(SessionService, container.session_service())withfrom typing import castat the top.F2 (P1:must-fix) — Also, catching
AttributeErrorin this function's callers is now unnecessary sincecontainer.session_service()is a properly registered provider. The originalcontainer.db()bug is fixed by this PR. Post-fix, anyAttributeErrorhere would be a genuine programming error, not a "database unavailable" condition.