fix(project): migrate ProjectService to spec-aligned NamespacedProject model

Wire ProjectRepositoryProtocol as the primary repository dependency for
ProjectService, replacing the legacy Project model with the spec-aligned
NamespacedProject model throughout the service layer.

- ProjectService now accepts ProjectRepositoryProtocol (auto-built from
  UnitOfWork database URL when not provided via DI)
- All CRUD operations use NamespacedProject (namespaced_name as identity)
- Removed hardcoded default_model='mock-gpt' test artifact from production
- DI container wires NamespacedProjectRepository into ProjectService
- ContextService and PlanService updated with compatibility helpers
  (_resolve_legacy_project_id, _resolve_project_path, _get_exclude_patterns,
  _get_include_patterns) to bridge NamespacedProject ↔ legacy Project
  during the transitional migration period
- CLI commands updated to work with NamespacedProject attributes
- Feature test steps updated for NamespacedProject (no .path, .id, .settings)

ISSUES CLOSED: #3700
This commit is contained in:
2026-04-06 07:18:03 +00:00
committed by drew
parent a8cca4e02a
commit a161aa0118
9 changed files with 745 additions and 424 deletions
@@ -337,7 +337,7 @@ def step_plan_list_no_filters(context: Context) -> None:
""" """
import cleveragents.cli.commands.plan as _plan_mod import cleveragents.cli.commands.plan as _plan_mod
wide_runner = CliRunner(mix_stderr=False) wide_runner = CliRunner()
original_width = _plan_mod.console._width original_width = _plan_mod.console._width
_plan_mod.console._width = 200 _plan_mod.console._width = 200
try: try:
+76 -80
View File
@@ -18,9 +18,8 @@ from cleveragents.core.exceptions import FileSystemError, NotFoundError, Validat
from cleveragents.domain.models.core import ( from cleveragents.domain.models.core import (
Plan, Plan,
PlanStatus, PlanStatus,
Project,
ProjectSettings,
) )
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
@@ -189,35 +188,22 @@ def step_check_migrated_project_returned(context: Context) -> None:
@then("no duplicate project should be created") @then("no duplicate project should be created")
def step_check_no_duplicate_project(context: Context) -> None: def step_check_no_duplicate_project(context: Context) -> None:
"""Check that no duplicate project was created.""" """Check that no duplicate project was created."""
with context.unit_of_work.transaction() as ctx: # Use the project repository to verify uniqueness
projects = ctx.projects.get_by_name("legacy-project") all_projects = context.project_service.list_projects()
assert projects is not None # Should exist project_names = [p.name for p in all_projects]
# Try to get all projects and check count assert project_names.count("legacy-project") == 1
all_projects = ctx.projects.get_all()
project_names = [p.name for p in all_projects]
assert project_names.count("legacy-project") == 1
@given('I have a project already in database with name "{name}"') @given('I have a project already in database with name "{name}"')
def step_create_existing_project(context: Context, name: str) -> None: def step_create_existing_project(context: Context, name: str) -> None:
"""Create an existing project in the database.""" """Create an existing project in the database via ProjectService."""
with context.unit_of_work.transaction() as ctx: project_path = context.temp_dir / name
project = Project( project_path.mkdir(parents=True, exist_ok=True)
id=None, context.existing_project = context.project_service.initialize_project(
name=name, name=name,
path=context.temp_dir / name, path=project_path,
created_at=datetime.now(), force=False,
updated_at=datetime.now(), )
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
)
context.existing_project = ctx.projects.create(project)
@given("I have legacy JSON project data for migration with same name") @given("I have legacy JSON project data for migration with same name")
@@ -383,32 +369,27 @@ def step_create_saved_project(context: Context, name: str) -> None:
@when("I update the project's settings") @when("I update the project's settings")
def step_update_project_settings(context: Context) -> None: def step_update_project_settings(context: Context) -> None:
"""Update the project's settings.""" """Update the project's description (spec-aligned NamespacedProject has no settings)."""
# Modify the project settings # NamespacedProject uses description instead of legacy settings fields
context.saved_project.settings.auto_build = True updated = context.saved_project.model_copy(
context.saved_project.settings.auto_apply = True update={"description": "updated-description"}
context.saved_project.settings.default_model = "updated-model"
context.saved_project.updated_at = datetime.now()
# Update the project
context.updated_project = context.project_service.update_project(
context.saved_project
) )
context.updated_project = context.project_service.update_project(updated)
@then("the project should be updated successfully") @then("the project should be updated successfully")
def step_check_project_updated(context: Context) -> None: def step_check_project_updated(context: Context) -> None:
"""Check that the project was updated successfully.""" """Check that the project was updated successfully."""
assert context.updated_project is not None assert context.updated_project is not None
assert context.updated_project.settings.auto_build is True assert context.updated_project.description == "updated-description"
assert context.updated_project.settings.auto_apply is True
assert context.updated_project.settings.default_model == "updated-model"
@then("the updated project should be returned") @then("the updated project should be returned")
def step_check_updated_project_returned(context: Context) -> None: def step_check_updated_project_returned(context: Context) -> None:
"""Check that the updated project was returned.""" """Check that the updated project was returned."""
assert context.updated_project.id == context.saved_project.id assert (
context.updated_project.namespaced_name == context.saved_project.namespaced_name
)
assert context.updated_project.name == context.saved_project.name assert context.updated_project.name == context.saved_project.name
@@ -443,9 +424,10 @@ def step_create_multiple_projects_times(context: Context) -> None:
name=f"project{i}", path=project_path, force=False name=f"project{i}", path=project_path, force=False
) )
# Update created_at to simulate different creation times # Update created_at to simulate different creation times
with context.unit_of_work.transaction() as ctx: updated = project.model_copy(
project.created_at = base_time + timedelta(hours=i) update={"created_at": base_time + timedelta(hours=i)}
ctx.projects.update(project) )
context.project_service.update_project(updated)
@when("I list all projects ordered by created date") @when("I list all projects ordered by created date")
@@ -544,20 +526,22 @@ def step_create_project_with_plans_contexts(context: Context) -> None:
name="full-project", path=context.project_path, force=False name="full-project", path=context.project_path, force=False
) )
# Add some plans # Add some plans via the legacy UoW (look up the legacy project by bare name)
with context.unit_of_work.transaction() as ctx: with context.unit_of_work.transaction() as ctx:
for i in range(3): legacy_project = ctx.projects.get_by_name(context.stats_project.name)
plan = Plan( if legacy_project and legacy_project.id:
id=None, for i in range(3):
project_id=context.stats_project.id, plan = Plan(
name=f"plan{i}", id=None,
prompt=f"Test plan {i}", project_id=legacy_project.id,
status=PlanStatus.PENDING, name=f"plan{i}",
current=i == 0, prompt=f"Test plan {i}",
created_at=datetime.now(), status=PlanStatus.PENDING,
updated_at=datetime.now(), current=i == 0,
) created_at=datetime.now(),
ctx.plans.create(plan) updated_at=datetime.now(),
)
ctx.plans.create(plan)
@then("the stats should show correct counts for plans and contexts") @then("the stats should show correct counts for plans and contexts")
@@ -629,10 +613,15 @@ def step_delete_project(context: Context) -> None:
@then("the project should not exist in the database") @then("the project should not exist in the database")
def step_check_project_not_in_database(context: Context) -> None: def step_check_project_not_in_database(context: Context) -> None:
"""Check that the project was deleted from the database.""" """Check that the project was deleted from the repository."""
with context.unit_of_work.transaction() as ctx: try:
project = ctx.projects.get_by_name(context.saved_project.name) context.project_service.get_project_by_name(context.saved_project.name)
assert project is None, "Project should not exist in database" raise AssertionError("Project should not exist in repository after deletion")
except Exception as exc:
# NotFoundError or ProjectNotFoundError is expected
assert (
"not found" in str(exc).lower() or "NotFoundError" in type(exc).__name__
), f"Unexpected exception type: {type(exc)}: {exc}"
# Missing step definitions for scenario: "Create project with special characters in name" # Missing step definitions for scenario: "Create project with special characters in name"
@@ -734,21 +723,16 @@ def step_check_permission_error(context: Context) -> None:
# Missing step definitions for scenario: "Update project that does not exist" # Missing step definitions for scenario: "Update project that does not exist"
@given("I have a project object that is not in database") @given("I have a project object that is not in database")
def step_create_project_not_in_db(context: Context) -> None: def step_create_project_not_in_db(context: Context) -> None:
"""Create a project object that is not in the database.""" """Create a NamespacedProject object that is not in the database."""
context.non_existent_project = Project( from datetime import UTC, datetime
id=999999, # Non-existent ID
context.non_existent_project = NamespacedProject(
name="non-existent", name="non-existent",
path=context.temp_dir / "non-existent", namespace="local",
created_at=datetime.now(), description=None,
updated_at=datetime.now(), linked_resources=[],
current_plan_id=None, created_at=datetime.now(tz=UTC),
settings=ProjectSettings( updated_at=datetime.now(tz=UTC),
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
) )
@@ -926,7 +910,11 @@ def step_existing_project_reused(context: Context) -> None:
assert context.error is None, f"Unexpected error: {context.error}" assert context.error is None, f"Unexpected error: {context.error}"
assert context.project_result is not None, "No project was returned" assert context.project_result is not None, "No project was returned"
assert hasattr(context, "initial_project"), "Initial project missing from context" assert hasattr(context, "initial_project"), "Initial project missing from context"
assert context.project_result.id == context.initial_project.id # NamespacedProject is identified by namespaced_name, not integer id
assert (
context.project_result.namespaced_name
== context.initial_project.namespaced_name
)
@given('I set up a standalone project directory named "{name}" with a name file') @given('I set up a standalone project directory named "{name}" with a name file')
@@ -998,7 +986,8 @@ def step_assert_temporary_project(context: Context, name: str) -> None:
project = getattr(context, "current_project_result", None) project = getattr(context, "current_project_result", None)
assert project is not None, "Expected a temporary project to be returned" assert project is not None, "Expected a temporary project to be returned"
assert project.name == name assert project.name == name
assert project.id is None # NamespacedProject has no integer id; verify it is a NamespacedProject instance
assert isinstance(project, NamespacedProject)
@then("no current project should be found") @then("no current project should be found")
@@ -1026,15 +1015,22 @@ def step_assert_alias_project_created(context: Context) -> None:
project = getattr(context, "alias_project", None) project = getattr(context, "alias_project", None)
assert project is not None, "Alias project was not created" assert project is not None, "Alias project was not created"
assert project.name == context.prepared_project_name assert project.name == context.prepared_project_name
assert project.path == context.prepared_project_path # NamespacedProject has no path attribute; verify the .cleveragents dir was created
assert (context.prepared_project_path / ".cleveragents").exists()
@when("I look up the project by its saved path") @when("I look up the project by its saved path")
def step_lookup_project_by_path(context: Context) -> None: def step_lookup_project_by_path(context: Context) -> None:
"""Retrieve the project using its stored filesystem path.""" """Retrieve the project using its stored filesystem path.
Since ``NamespacedProject`` has no ``path`` attribute, we use the
``test_dir`` (the path passed to ``initialize_project``) directly.
"""
assert hasattr(context, "project"), "A project must exist before lookup" assert hasattr(context, "project"), "A project must exist before lookup"
# Use the test_dir as the project path (where .cleveragents was created)
project_path = Path(context.test_dir)
context.found_project_by_path = context.project_service.get_project_by_path( context.found_project_by_path = context.project_service.get_project_by_path(
context.project.path project_path
) )
+35 -7
View File
@@ -43,6 +43,7 @@ def step_have_project_service(context: Context) -> None:
unit_of_work = UnitOfWork(db_url) unit_of_work = UnitOfWork(db_url)
context.unit_of_work = unit_of_work
context.project_service = ProjectService(settings, unit_of_work) context.project_service = ProjectService(settings, unit_of_work)
context.project_service.search_root = Path(context.test_dir) context.project_service.search_root = Path(context.test_dir)
@@ -121,12 +122,17 @@ def step_project_name_should_be(context: Context, name: str) -> None:
@then('the project path should be "{path}"') @then('the project path should be "{path}"')
def step_project_path_should_be(context: Context, path: str) -> None: def step_project_path_should_be(context: Context, path: str) -> None:
"""Verify project path.""" """Verify project path via the .cleveragents directory on the filesystem.
``NamespacedProject`` does not carry a ``path`` attribute; instead we
verify that the ``.cleveragents`` directory was created at the expected
location, which is the observable side-effect of project initialisation.
"""
if path == "/tmp/test": if path == "/tmp/test":
path = context.test_dir path = context.test_dir
expected_path = Path(path) expected_cleveragents = Path(path) / ".cleveragents"
assert context.project.path == expected_path, ( assert expected_cleveragents.exists(), (
f"Expected path {expected_path}, got {context.project.path}" f"Expected .cleveragents directory at {expected_cleveragents}"
) )
@@ -1387,16 +1393,38 @@ def step_project_should_have_name(context: Context) -> None:
@then("the project should have a path") @then("the project should have a path")
def step_project_should_have_path(context: Context) -> None: def step_project_should_have_path(context: Context) -> None:
"""Verify project has a path.""" """Verify project has a namespaced_name (spec-aligned identity).
assert context.current_project.path is not None, "Project has no path"
``NamespacedProject`` is identified by its ``namespaced_name`` rather
than a filesystem path. We verify that the namespaced_name is set.
"""
assert context.current_project.namespaced_name is not None, (
"Project has no namespaced_name"
)
@given("I have a current project") @given("I have a current project")
def step_have_current_project(context: Context) -> None: def step_have_current_project(context: Context) -> None:
"""Ensure we have a current project.""" """Ensure we have a current project."""
if not hasattr(context, "project_service"): if not hasattr(context, "project_service"):
import uuid
settings = Settings() settings = Settings()
context.project_service = ProjectService(settings, "sqlite:///test.db") context.test_dir = tempfile.mkdtemp(prefix="test_project_")
db_file = Path(context.test_dir) / f"test_{uuid.uuid4().hex}.db"
db_url = f"sqlite:///{db_file}"
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
unit_of_work = UnitOfWork(db_url)
context.project_service = ProjectService(settings, unit_of_work)
context.project_service.search_root = Path(context.test_dir)
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
if not hasattr(context, "test_dir"): if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_project_") context.test_dir = tempfile.mkdtemp(prefix="test_project_")
+11 -6
View File
@@ -697,11 +697,22 @@ class Container(containers.DeclarativeContainer):
event_bus=event_bus, event_bus=event_bus,
) )
# Namespaced Project Repository — defined early so ProjectService can
# receive it as a constructor argument (spec-aligned NamespacedProject
# persistence, ADR-007).
namespaced_project_repo = providers.Factory(
_build_namespaced_project_repo,
database_url=database_url,
)
# Services - Factory (new instance per request with injected dependencies) # Services - Factory (new instance per request with injected dependencies)
# ProjectService receives the spec-aligned NamespacedProjectRepository so
# that all project CRUD operations use NamespacedProject (ADR-007).
project_service = providers.Factory( project_service = providers.Factory(
ProjectService, ProjectService,
settings=settings, settings=settings,
unit_of_work=unit_of_work, unit_of_work=unit_of_work,
project_repository=namespaced_project_repo,
event_bus=event_bus, event_bus=event_bus,
) )
@@ -814,12 +825,6 @@ class Container(containers.DeclarativeContainer):
database_url=database_url, database_url=database_url,
) )
# Namespaced Project Repository
namespaced_project_repo = providers.Factory(
_build_namespaced_project_repo,
database_url=database_url,
)
# Project Resource Link Repository # Project Resource Link Repository
project_resource_link_repo = providers.Factory( project_resource_link_repo = providers.Factory(
_build_project_resource_link_repo, _build_project_resource_link_repo,
@@ -21,7 +21,7 @@ import structlog
from cleveragents.config.settings import Settings from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import ConfigurationError, FileSystemError, PlanError from cleveragents.core.exceptions import ConfigurationError, FileSystemError, PlanError
from cleveragents.domain.models.core import Context, ContextType, Plan, Project from cleveragents.domain.models.core import Context, ContextType, Plan
from cleveragents.infrastructure.database.unit_of_work import ( from cleveragents.infrastructure.database.unit_of_work import (
UnitOfWork, UnitOfWork,
UnitOfWorkContext, UnitOfWorkContext,
@@ -115,8 +115,79 @@ class ContextService:
self.extra_ignore_patterns: list[str] = [] self.extra_ignore_patterns: list[str] = []
self._agentsignore_cache: dict[Path, list[str]] = {} self._agentsignore_cache: dict[Path, list[str]] = {}
# ------------------------------------------------------------------
# Compatibility helpers — bridge NamespacedProject ↔ legacy Project
# ------------------------------------------------------------------
def _resolve_legacy_project_id(self, project: Any) -> int | None:
"""Return the legacy integer project ID for *project*.
Handles both the legacy ``Project`` model (which has a numeric ``.id``)
and the spec-aligned ``NamespacedProject`` model (which uses
``namespaced_name`` as its identifier). For ``NamespacedProject``
instances the legacy record is looked up by bare name via the
``UnitOfWork``.
Returns ``None`` if no matching legacy record is found.
"""
# Legacy Project — has a numeric .id attribute
legacy_id = getattr(project, "id", None)
if legacy_id is not None:
return int(legacy_id)
# NamespacedProject — look up by bare name
bare_name = getattr(project, "name", None)
if bare_name is None:
return None
try:
with self.unit_of_work.transaction() as ctx:
legacy = ctx.projects.get_by_name(bare_name)
return legacy.id if legacy is not None else None
except Exception:
return None
def _resolve_project_path(self, project: Any) -> Path | None:
"""Return the filesystem path for *project*, or ``None`` if unavailable.
``NamespacedProject`` is path-agnostic by design; this helper returns
``None`` for such instances so callers can skip path-relative operations.
"""
return getattr(project, "path", None)
def _get_exclude_patterns(self, project: Any) -> list[str]:
"""Return the exclude/ignore glob patterns for *project*.
Handles both the legacy ``Project.settings.exclude_paths`` and the
spec-aligned ``NamespacedProject.context_config.ignore_patterns``.
"""
# NamespacedProject — use context_config
context_config = getattr(project, "context_config", None)
if context_config is not None:
return list(getattr(context_config, "ignore_patterns", []))
# Legacy Project — use settings.exclude_paths
settings = getattr(project, "settings", None)
if settings is not None:
return list(getattr(settings, "exclude_paths", []))
return []
def _get_include_patterns(self, project: Any) -> list[str]:
"""Return the include glob patterns for *project*.
Handles both the legacy ``Project.settings.include_paths`` and the
spec-aligned ``NamespacedProject.context_config.include_patterns``.
"""
# NamespacedProject — use context_config
context_config = getattr(project, "context_config", None)
if context_config is not None:
return list(getattr(context_config, "include_patterns", []))
# Legacy Project — use settings.include_paths
settings = getattr(project, "settings", None)
if settings is not None:
return list(getattr(settings, "include_paths", []))
return []
def add_to_context( def add_to_context(
self, project: Project, path: Path, recursive: bool = True self, project: Any, path: Path, recursive: bool = True
) -> tuple[list[Path], list[Path]]: ) -> tuple[list[Path], list[Path]]:
"""Add files to the current plan's context. """Add files to the current plan's context.
@@ -140,9 +211,10 @@ class ContextService:
plan_id: int | None = None plan_id: int | None = None
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
# Get current plan # Get current plan — resolve legacy project ID for NamespacedProject
_legacy_id = self._resolve_legacy_project_id(project)
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan: if not current_plan:
@@ -335,37 +407,51 @@ class ContextService:
return True return True
return rel_str.startswith(f"{cleaned}/") return rel_str.startswith(f"{cleaned}/")
def _project_path_matches(self, project: Project, path: Path, pattern: str) -> bool: def _project_path_matches(self, project: Any, path: Path, pattern: str) -> bool:
"""Match a path against a project-level include/exclude glob.""" """Match a path against a project-level include/exclude glob.
try: Handles both legacy ``Project`` (which has a ``.path`` attribute) and
rel = path.relative_to(project.path) spec-aligned ``NamespacedProject`` (which is path-agnostic). When the
except ValueError: project has no ``.path``, falls back to matching against the filename
return False only.
rel_str = rel.as_posix() """
name = path.name project_path = self._resolve_project_path(project)
return fnmatch(rel_str, pattern) or fnmatch(name, pattern) if project_path is not None:
try:
rel = path.relative_to(project_path)
rel_str = rel.as_posix()
name = path.name
return fnmatch(rel_str, pattern) or fnmatch(name, pattern)
except ValueError:
return False
# NamespacedProject — no filesystem path; match against filename only
return fnmatch(path.name, pattern)
def _should_ignore(self, project: Project, path: Path) -> bool: def _should_ignore(self, project: Any, path: Path) -> bool:
"""Check whether to ignore a path via settings and .agentsignore.""" """Check whether to ignore a path via settings and .agentsignore.
Handles both legacy ``Project.settings`` and spec-aligned
``NamespacedProject.context_config`` via the compatibility helpers.
"""
if self._matches_default_ignore(path): if self._matches_default_ignore(path):
return True return True
for pattern in project.settings.exclude_paths: for pattern in self._get_exclude_patterns(project):
if self._project_path_matches(project, path, pattern): if self._project_path_matches(project, path, pattern):
return True return True
if project.settings.include_paths and not any( include_patterns = self._get_include_patterns(project)
if include_patterns and not any(
self._project_path_matches(project, path, pattern) self._project_path_matches(project, path, pattern)
for pattern in project.settings.include_paths for pattern in include_patterns
): ):
return True return True
rules = self._collect_ignore_rules(path) rules = self._collect_ignore_rules(path)
return any(self._matches_ignore(base, pattern, path) for base, pattern in rules) return any(self._matches_ignore(base, pattern, path) for base, pattern in rules)
def remove_from_context(self, project: Project, path: Path) -> int: def remove_from_context(self, project: Any, path: Path) -> int:
"""Remove files from the context. """Remove files from the context.
Args: Args:
@@ -377,8 +463,9 @@ class ContextService:
""" """
plan_id: int | None = None plan_id: int | None = None
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
_legacy_id = self._resolve_legacy_project_id(project)
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -402,7 +489,7 @@ class ContextService:
return removed_count return removed_count
def clear_context(self, project: Project) -> int: def clear_context(self, project: Any) -> int:
"""Clear all context for the current plan. """Clear all context for the current plan.
Args: Args:
@@ -413,8 +500,9 @@ class ContextService:
""" """
plan_id: int | None = None plan_id: int | None = None
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
_legacy_id = self._resolve_legacy_project_id(project)
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -431,7 +519,7 @@ class ContextService:
return count return count
def list_context(self, project: Project) -> list[Context]: def list_context(self, project: Any) -> list[Context]:
"""List all files in the current plan's context. """List all files in the current plan's context.
Args: Args:
@@ -440,18 +528,19 @@ class ContextService:
Returns: Returns:
List of context entries List of context entries
""" """
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return [] return []
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ctx.plans.get_current_for_project(project.id) current_plan = ctx.plans.get_current_for_project(_legacy_id)
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
return [] return []
return ctx.contexts.get_for_plan(current_plan.id) return ctx.contexts.get_for_plan(current_plan.id)
def get_context_size(self, project: Project) -> int: def get_context_size(self, project: Any) -> int:
"""Get the total size of all context files. """Get the total size of all context files.
Args: Args:
@@ -463,7 +552,7 @@ class ContextService:
contexts = self.list_context(project) contexts = self.list_context(project)
return sum(c.size for c in contexts) return sum(c.size for c in contexts)
def show_context_content(self, project: Project) -> dict[str, str]: def show_context_content(self, project: Any) -> dict[str, str]:
"""Get the content of all context files. """Get the content of all context files.
Args: Args:
@@ -475,7 +564,7 @@ class ContextService:
contexts = self.list_context(project) contexts = self.list_context(project)
return {c.path: c.content or "" for c in contexts} return {c.path: c.content or "" for c in contexts}
def get_context_content(self, project: Project, path: Path) -> str | None: def get_context_content(self, project: Any, path: Path) -> str | None:
"""Get the content of a specific context file. """Get the content of a specific context file.
Args: Args:
@@ -493,7 +582,7 @@ class ContextService:
return context.content return context.content
return None return None
def list_files(self, project: Project | None = None) -> list[str]: def list_files(self, project: Any | None = None) -> list[str]:
"""List all file paths in the current plan's context. """List all file paths in the current plan's context.
This is a convenience method that returns just the file paths This is a convenience method that returns just the file paths
@@ -526,17 +615,18 @@ class ContextService:
contexts = self.list_context(project) contexts = self.list_context(project)
return [context.path for context in contexts] return [context.path for context in contexts]
def _get_current_plan(self, project: Project) -> Plan | None: def _get_current_plan(self, project: Any) -> Plan | None:
"""Fetch the current plan for metadata enrichment.""" """Fetch the current plan for metadata enrichment."""
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return None return None
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
return ctx.plans.get_current_for_project(project.id) return ctx.plans.get_current_for_project(_legacy_id)
def _build_langsmith_config( def _build_langsmith_config(
self, self,
project: Project, project: Any,
*, *,
run_name: str, run_name: str,
file_paths: list[str], file_paths: list[str],
@@ -548,8 +638,9 @@ class ContextService:
return {} return {}
plan = self._get_current_plan(project) plan = self._get_current_plan(project)
_legacy_id = self._resolve_legacy_project_id(project)
metadata = { metadata = {
"project_id": project.id, "project_id": _legacy_id,
"project_name": project.name, "project_name": project.name,
"plan_id": getattr(plan, "id", None), "plan_id": getattr(plan, "id", None),
"plan_name": getattr(plan, "name", None), "plan_name": getattr(plan, "name", None),
@@ -562,8 +653,8 @@ class ContextService:
"service:context", "service:context",
f"mode:{mode}", f"mode:{mode}",
] ]
if project.id is not None: if _legacy_id is not None:
tags.append(f"project:{project.id}") tags.append(f"project:{_legacy_id}")
return ( return (
self.settings.build_langsmith_config( self.settings.build_langsmith_config(
tags=tags, tags=tags,
@@ -575,7 +666,7 @@ class ContextService:
def _prepare_analysis_config( def _prepare_analysis_config(
self, self,
project: Project, project: Any,
*, *,
run_name: str, run_name: str,
file_paths: list[str], file_paths: list[str],
@@ -634,7 +725,7 @@ class ContextService:
def analyze_context( def analyze_context(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> ContextAnalysisState: ) -> ContextAnalysisState:
"""Analyze the current plan's context using LangGraph workflow. """Analyze the current plan's context using LangGraph workflow.
@@ -699,7 +790,7 @@ class ContextService:
async def analyze_context_async( async def analyze_context_async(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> ContextAnalysisState: ) -> ContextAnalysisState:
"""Asynchronously analyze the current plan's context. """Asynchronously analyze the current plan's context.
@@ -750,7 +841,7 @@ class ContextService:
def analyze_context_streaming( def analyze_context_streaming(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> Iterator[dict[str, Any]]: ) -> Iterator[dict[str, Any]]:
"""Stream the context analysis workflow execution. """Stream the context analysis workflow execution.
@@ -795,7 +886,7 @@ class ContextService:
async def analyze_context_streaming_async( async def analyze_context_streaming_async(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
"""Asynchronously stream the context analysis workflow execution. """Asynchronously stream the context analysis workflow execution.
@@ -840,7 +931,7 @@ class ContextService:
def get_context_summary( def get_context_summary(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> str: ) -> str:
"""Get a high-level summary of the current context. """Get a high-level summary of the current context.
@@ -860,7 +951,7 @@ class ContextService:
def get_context_dependencies( def get_context_dependencies(
self, self,
project: Project, project: Any,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
"""Get extracted dependencies for all context files. """Get extracted dependencies for all context files.
@@ -880,7 +971,7 @@ class ContextService:
def get_relevant_files( def get_relevant_files(
self, self,
project: Project, project: Any,
threshold: float = 0.5, threshold: float = 0.5,
llm: BaseLanguageModel | None = None, llm: BaseLanguageModel | None = None,
) -> list[tuple[str, float]]: ) -> list[tuple[str, float]]:
@@ -906,7 +997,7 @@ class ContextService:
def search_context( def search_context(
self, self,
project: Project, project: Any,
query: str, query: str,
*, *,
limit: int = 5, limit: int = 5,
@@ -45,7 +45,6 @@ from cleveragents.domain.models.core import (
PlanBuild, PlanBuild,
PlanResult, PlanResult,
PlanStatus, PlanStatus,
Project,
) )
from cleveragents.domain.providers.ai_provider import ( from cleveragents.domain.providers.ai_provider import (
ActorInvocationContext, ActorInvocationContext,
@@ -100,6 +99,45 @@ class PlanService:
self._llm = llm self._llm = llm
self._logger = structlog.get_logger(__name__).bind(service="plan") self._logger = structlog.get_logger(__name__).bind(service="plan")
# ------------------------------------------------------------------
# Compatibility helpers — bridge NamespacedProject ↔ legacy Project
# ------------------------------------------------------------------
def _resolve_legacy_project_id(self, project: Any) -> int | None:
"""Return the legacy integer project ID for *project*.
Handles both the legacy ``Project`` model (which has a numeric ``.id``)
and the spec-aligned ``NamespacedProject`` model (which uses
``namespaced_name`` as its identifier). For ``NamespacedProject``
instances the legacy record is looked up by bare name via the
``UnitOfWork``.
Returns ``None`` if no matching legacy record is found.
"""
# Legacy Project — has a numeric .id attribute
legacy_id = getattr(project, "id", None)
if legacy_id is not None:
return int(legacy_id)
# NamespacedProject — look up by bare name
bare_name = getattr(project, "name", None)
if bare_name is None:
return None
try:
with self.unit_of_work.transaction() as ctx:
legacy = ctx.projects.get_by_name(bare_name)
return legacy.id if legacy is not None else None
except Exception:
return None
def _resolve_project_path(self, project: Any) -> Path | None:
"""Return the filesystem path for *project*, or ``None`` if unavailable.
``NamespacedProject`` is path-agnostic by design; this helper returns
``None`` for such instances so callers can skip path-relative operations.
"""
return getattr(project, "path", None)
def _use_mock_provider(self) -> bool: def _use_mock_provider(self) -> bool:
"""Return True when the runtime is configured to force the mock provider.""" """Return True when the runtime is configured to force the mock provider."""
@@ -510,7 +548,7 @@ class PlanService:
def _build_langsmith_config( def _build_langsmith_config(
self, self,
project: Project, project: Any,
plan: Plan | None, plan: Plan | None,
*, *,
run_name: str, run_name: str,
@@ -522,8 +560,9 @@ class PlanService:
if not getattr(self.settings, "is_langsmith_enabled", False): if not getattr(self.settings, "is_langsmith_enabled", False):
return {} return {}
_legacy_id = self._resolve_legacy_project_id(project)
base_metadata: dict[str, Any] = { base_metadata: dict[str, Any] = {
"project_id": project.id, "project_id": _legacy_id,
"project_name": project.name, "project_name": project.name,
} }
if plan and plan.id: if plan and plan.id:
@@ -539,8 +578,8 @@ class PlanService:
global_tags = list(self.settings.langsmith_tags) global_tags = list(self.settings.langsmith_tags)
if global_tags: if global_tags:
base_tags.extend(global_tags) base_tags.extend(global_tags)
if project.id is not None: if _legacy_id is not None:
base_tags.append(f"project:{project.id}") base_tags.append(f"project:{_legacy_id}")
if plan and plan.id is not None: if plan and plan.id is not None:
base_tags.append(f"plan:{plan.id}") base_tags.append(f"plan:{plan.id}")
if tags: if tags:
@@ -557,7 +596,7 @@ class PlanService:
def _prepare_langsmith_config( def _prepare_langsmith_config(
self, self,
project: Project, project: Any,
plan: Plan | None, plan: Plan | None,
*, *,
run_name: str, run_name: str,
@@ -578,9 +617,7 @@ class PlanService:
config["configurable"]["thread_id"] = f"{thread_prefix}-{uuid.uuid4()}" config["configurable"]["thread_id"] = f"{thread_prefix}-{uuid.uuid4()}"
return config return config
def create_plan( def create_plan(self, project: Any, prompt: str, name: str | None = None) -> Plan:
self, project: Project, prompt: str, name: str | None = None
) -> Plan:
"""Create a new plan with instructions for AI. """Create a new plan with instructions for AI.
Args: Args:
@@ -599,8 +636,9 @@ class PlanService:
words = prompt.split()[:3] if prompt else ["new", "plan"] words = prompt.split()[:3] if prompt else ["new", "plan"]
name = "_".join(words).lower() name = "_".join(words).lower()
# Ensure project has a valid ID # Ensure project has a valid legacy ID
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
raise ValidationError( raise ValidationError(
message=( message=(
"Cannot create plan: this directory is not linked to a saved " "Cannot create plan: this directory is not linked to a saved "
@@ -623,7 +661,7 @@ class PlanService:
plan = Plan( plan = Plan(
id=None, id=None,
project_id=project.id, project_id=_legacy_id,
name=name, name=name,
prompt=prompt, prompt=prompt,
status=PlanStatus.PENDING, status=PlanStatus.PENDING,
@@ -647,13 +685,13 @@ class PlanService:
created_plan = ctx.plans.create(plan) created_plan = ctx.plans.create(plan)
# Set as current plan for the project # Set as current plan for the project
if project.id and created_plan.id: if _legacy_id and created_plan.id:
ctx.plans.set_current(project.id, created_plan.id) ctx.plans.set_current(_legacy_id, created_plan.id)
created_plan.current = True created_plan.current = True
return created_plan return created_plan
def new_plan(self, project: Project, name: str | None = None) -> Plan: def new_plan(self, project: Any, name: str | None = None) -> Plan:
"""Create a new empty plan. """Create a new empty plan.
Args: Args:
@@ -668,7 +706,7 @@ class PlanService:
def build_plan( def build_plan(
self, self,
project: Project, project: Any,
progress_callback: Callable[[int], None] | None = None, progress_callback: Callable[[int], None] | None = None,
actor: str | None = None, actor: str | None = None,
) -> list[Change]: ) -> list[Change]:
@@ -685,9 +723,10 @@ class PlanService:
Raises: Raises:
PlanError: If no current plan or build fails PlanError: If no current plan or build fails
""" """
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -784,7 +823,7 @@ class PlanService:
def auto_debug_build( def auto_debug_build(
self, self,
project: Project, project: Any,
max_attempts: int = 3, max_attempts: int = 3,
progress_callback: Callable[[int], None] | None = None, progress_callback: Callable[[int], None] | None = None,
) -> tuple[bool, list[Change], str | None]: ) -> tuple[bool, list[Change], str | None]:
@@ -807,9 +846,10 @@ class PlanService:
from cleveragents.agents import AutoDebugAgent, AutoDebugState from cleveragents.agents import AutoDebugAgent, AutoDebugState
from cleveragents.domain.models.core import DebugAttempt from cleveragents.domain.models.core import DebugAttempt
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -942,7 +982,7 @@ class PlanService:
# If we exhausted all attempts, return failure # If we exhausted all attempts, return failure
return (False, changes, last_error) return (False, changes, last_error)
def get_pending_changes(self, project: Project) -> list[Change]: def get_pending_changes(self, project: Any) -> list[Change]:
"""Get pending (not applied) changes for the current plan. """Get pending (not applied) changes for the current plan.
Args: Args:
@@ -951,9 +991,10 @@ class PlanService:
Returns: Returns:
List of pending changes List of pending changes
""" """
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -962,7 +1003,7 @@ class PlanService:
all_changes = ctx.changes.get_for_plan(current_plan.id) all_changes = ctx.changes.get_for_plan(current_plan.id)
return [c for c in all_changes if not c.applied] return [c for c in all_changes if not c.applied]
def apply_changes(self, project: Project) -> int: def apply_changes(self, project: Any) -> int:
"""Apply pending changes to the filesystem. """Apply pending changes to the filesystem.
Args: Args:
@@ -974,9 +1015,11 @@ class PlanService:
Raises: Raises:
PlanError: If apply fails PlanError: If apply fails
""" """
_legacy_id = self._resolve_legacy_project_id(project)
_project_path = self._resolve_project_path(project)
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -988,14 +1031,14 @@ class PlanService:
changes = ctx.changes.get_for_plan(current_plan.id) changes = ctx.changes.get_for_plan(current_plan.id)
pending_changes = [c for c in changes if not c.applied] pending_changes = [c for c in changes if not c.applied]
project_root = project.path.resolve() project_root = _project_path.resolve() if _project_path else Path.cwd()
def _safe_resolve(raw_path: str) -> Path: def _safe_resolve(raw_path: str) -> Path:
"""Resolve *raw_path* and reject it if it escapes *project_root*.""" """Resolve *raw_path* and reject it if it escapes *project_root*."""
if Path(raw_path).is_absolute(): if Path(raw_path).is_absolute():
resolved = Path(raw_path).resolve() resolved = Path(raw_path).resolve()
else: else:
resolved = (project.path / raw_path).resolve() resolved = ((_project_path or Path.cwd()) / raw_path).resolve()
if not resolved.is_relative_to(project_root): if not resolved.is_relative_to(project_root):
raise PlanError( raise PlanError(
message=( message=(
@@ -1068,7 +1111,7 @@ class PlanService:
return applied_count return applied_count
def get_current_plan(self, project: Project) -> Plan | None: def get_current_plan(self, project: Any) -> Plan | None:
"""Get the current active plan for a project. """Get the current active plan for a project.
Args: Args:
@@ -1077,13 +1120,14 @@ class PlanService:
Returns: Returns:
Current plan or None Current plan or None
""" """
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return None return None
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
return ctx.plans.get_current_for_project(project.id) return ctx.plans.get_current_for_project(_legacy_id)
def list_plans(self, project: Project) -> list[Plan]: def list_plans(self, project: Any) -> list[Plan]:
"""List all plans in the project. """List all plans in the project.
Args: Args:
@@ -1092,13 +1136,14 @@ class PlanService:
Returns: Returns:
List of plans List of plans
""" """
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return [] return []
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
return ctx.plans.get_all_for_project(project.id) return ctx.plans.get_all_for_project(_legacy_id)
def switch_to_plan(self, project: Project, name: str) -> Plan: def switch_to_plan(self, project: Any, name: str) -> Plan:
"""Switch to a different plan. """Switch to a different plan.
Args: Args:
@@ -1111,19 +1156,20 @@ class PlanService:
Raises: Raises:
ValidationError: If plan not found ValidationError: If plan not found
""" """
if not project.id: _legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
raise ValidationError( raise ValidationError(
message="Project not initialized", message="Project not initialized",
details={"hint": "Initialize project first with 'agents init'"}, details={"hint": "Initialize project first with 'agents init'"},
) )
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
plans = ctx.plans.get_all_for_project(project.id) plans = ctx.plans.get_all_for_project(_legacy_id)
for plan in plans: for plan in plans:
if plan.name == name: if plan.name == name:
if plan.id: if plan.id:
ctx.plans.set_current(project.id, plan.id) ctx.plans.set_current(_legacy_id, plan.id)
return plan return plan
raise ValidationError( raise ValidationError(
@@ -1131,7 +1177,7 @@ class PlanService:
details={"available_plans": [p.name for p in plans]}, details={"available_plans": [p.name for p in plans]},
) )
def add_to_plan(self, project: Project, prompt: str) -> None: def add_to_plan(self, project: Any, prompt: str) -> None:
"""Add additional instructions to the current plan. """Add additional instructions to the current plan.
Args: Args:
@@ -1141,9 +1187,10 @@ class PlanService:
Raises: Raises:
PlanError: If no current plan PlanError: If no current plan
""" """
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(_legacy_id) if _legacy_id else None
) )
if not current_plan or not current_plan.id: if not current_plan or not current_plan.id:
@@ -1161,7 +1208,7 @@ class PlanService:
current_plan.status = PlanStatus.PENDING current_plan.status = PlanStatus.PENDING
ctx.plans.update(current_plan) ctx.plans.update(current_plan)
def continue_plan(self, project: Project, prompt: str) -> None: def continue_plan(self, project: Any, prompt: str) -> None:
"""Alias for add_to_plan for compatibility. """Alias for add_to_plan for compatibility.
Args: Args:
@@ -1172,7 +1219,7 @@ class PlanService:
async def generate_plan_streaming( async def generate_plan_streaming(
self, self,
project: Project, project: Any,
description: str, description: str,
name: str | None = None, name: str | None = None,
actor: str | None = None, actor: str | None = None,
@@ -1,13 +1,20 @@
"""Project service for managing CleverAgents projects. """Project service for managing CleverAgents projects.
This service handles project initialization, configuration, and management. This service handles project initialization, configuration, and management.
Uses repository pattern and Unit of Work for persistence (ADR-007). Uses the spec-aligned ``NamespacedProject`` model and ``ProjectRepositoryProtocol``
for all persistence operations (ADR-007).
The service accepts a ``ProjectRepositoryProtocol`` implementation (e.g.
``NamespacedProjectRepository``) as its primary repository dependency, wired
via the DI container. A ``UnitOfWork`` is retained for legacy migration
support and plan/context statistics queries that still rely on the legacy
schema.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -20,7 +27,14 @@ from cleveragents.core.exceptions import (
NotFoundError, NotFoundError,
ValidationError, ValidationError,
) )
from cleveragents.domain.models.core import Plan, PlanStatus, Project, ProjectSettings from cleveragents.domain.models.core.project import (
ContextConfig,
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.domain.repositories.project_repository import (
ProjectRepositoryProtocol,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.infrastructure.events.models import DomainEvent from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType from cleveragents.infrastructure.events.types import EventType
@@ -35,20 +49,28 @@ class ProjectService:
"""Service for managing CleverAgents projects. """Service for managing CleverAgents projects.
This service provides methods for initializing, configuring, and This service provides methods for initializing, configuring, and
managing projects throughout their lifecycle. managing projects throughout their lifecycle. All persistence
operations use the spec-aligned ``NamespacedProject`` model via
``ProjectRepositoryProtocol``.
""" """
def __init__( def __init__(
self, self,
settings: Settings, settings: Settings,
unit_of_work: UnitOfWork, unit_of_work: UnitOfWork,
project_repository: ProjectRepositoryProtocol | None = None,
event_bus: EventBus | None = None, event_bus: EventBus | None = None,
): ):
"""Initialize the project service. """Initialize the project service.
Args: Args:
settings: Application settings settings: Application settings.
unit_of_work: Unit of Work for database transactions unit_of_work: Unit of Work for legacy migration and plan/context
statistics queries.
project_repository: Spec-aligned repository for ``NamespacedProject``
persistence. When ``None`` a ``NamespacedProjectRepository``
backed by the same database URL as *unit_of_work* is built
automatically.
event_bus: Optional EventBus for domain event emission. event_bus: Optional EventBus for domain event emission.
""" """
self.settings = settings self.settings = settings
@@ -57,6 +79,52 @@ class ProjectService:
# Optional search root to limit filesystem discovery (used in tests) # Optional search root to limit filesystem discovery (used in tests)
self.search_root: Path | None = None self.search_root: Path | None = None
if project_repository is not None:
self._project_repo: ProjectRepositoryProtocol = project_repository
else:
# Build a NamespacedProjectRepository from the UnitOfWork database URL
self._project_repo = self._build_default_repo(unit_of_work)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _build_default_repo(unit_of_work: UnitOfWork) -> ProjectRepositoryProtocol:
"""Build a ``NamespacedProjectRepository`` from the UoW database URL."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
engine = create_engine(unit_of_work.database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return NamespacedProjectRepository(session_factory=factory)
@staticmethod
def _parse_name_to_parts(name: str) -> tuple[str, str]:
"""Parse a project name into (namespace, bare_name).
Accepts bare names (defaulting to ``local/``) or fully qualified
``[[server:]namespace/]name`` strings.
Returns:
Tuple of (namespace, bare_name).
"""
parsed = parse_namespaced_name(name)
return parsed.namespace, parsed.name
def _namespaced_name_for(self, name: str) -> str:
"""Return the ``namespace/name`` string for a given project name."""
namespace, bare = self._parse_name_to_parts(name)
return f"{namespace}/{bare}"
# ------------------------------------------------------------------
# Core CRUD — spec-aligned NamespacedProject
# ------------------------------------------------------------------
def initialize_project( def initialize_project(
self, self,
name: str, name: str,
@@ -64,39 +132,44 @@ class ProjectService:
force: bool = False, force: bool = False,
create_ignore_file: bool = False, create_ignore_file: bool = False,
apply_default_filters: bool = False, apply_default_filters: bool = False,
) -> Project: ) -> NamespacedProject:
"""Initialize a new CleverAgents project. """Initialize a new CleverAgents project.
Creates the ``.cleveragents`` directory structure on the filesystem
and persists a ``NamespacedProject`` record via the repository.
Args: Args:
name: Project name name: Project name (bare or ``[[server:]namespace/]name``).
path: Project path path: Project filesystem path.
force: Force reinitialization if project exists force: Force reinitialization if project exists.
create_ignore_file: Whether to write a default .agentsignore create_ignore_file: Whether to write a default ``.agentsignore``
in the project root in the project root.
apply_default_filters: Whether to populate ``context_config``
with the default ignore patterns.
Returns: Returns:
Project: The initialized project The initialized ``NamespacedProject``.
Raises: Raises:
ValidationError: If project already exists and force is False ValidationError: If project already exists and *force* is False.
FileSystemError: If unable to create project directories FileSystemError: If unable to create project directories.
""" """
project_dir = path / ".cleveragents" project_dir = path / ".cleveragents"
# Check if already initialized # Check if already initialized on the filesystem
if project_dir.exists() and not force: if project_dir.exists() and not force:
raise ValidationError( raise ValidationError(
message=f"Project already initialized at {path}", message=f"Project already initialized at {path}",
details={"path": str(path), "use_force": "Add --force to reinitialize"}, details={"path": str(path), "use_force": "Add --force to reinitialize"},
) )
# Create project structure # Create project structure on filesystem
try: try:
project_dir.mkdir(parents=True, exist_ok=True) project_dir.mkdir(parents=True, exist_ok=True)
(project_dir / "db.sqlite").touch() (project_dir / "db.sqlite").touch()
(project_dir / "config.yaml").touch() (project_dir / "config.yaml").touch()
(project_dir / "current").write_text("main") (project_dir / "current").write_text("main")
# Store the project name # Store the project name for later discovery
(project_dir / "project.name").write_text(name) (project_dir / "project.name").write_text(name)
if create_ignore_file: if create_ignore_file:
@@ -110,28 +183,7 @@ class ProjectService:
path=path, path=path,
) from e ) from e
# Create project in database # Ensure the database schema exists
project = Project(
id=None,
name=name,
path=path,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024, # 50MB
default_model="mock-gpt",
include_paths=[],
exclude_paths=list(DEFAULT_IGNORE_PATTERNS)
if apply_default_filters
else [],
),
)
# Initialize database and save project
self.unit_of_work.init_database() self.unit_of_work.init_database()
# Check for and migrate legacy JSON data if it exists # Check for and migrate legacy JSON data if it exists
@@ -141,66 +193,134 @@ class ProjectService:
migrated = check_and_migrate_legacy_data(path, self.unit_of_work) migrated = check_and_migrate_legacy_data(path, self.unit_of_work)
with self.unit_of_work.transaction() as ctx: # Parse the namespaced name
# Check if project with this name already exists FIRST namespace, bare_name = self._parse_name_to_parts(name)
existing = ctx.projects.get_by_name(name) namespaced_name = f"{namespace}/{bare_name}"
if existing and not force:
# If we just migrated, return the existing project
if migrated:
return existing
raise ValidationError(
message=f"Project with name '{name}' already exists",
details={"name": name},
)
# If project was created during migration, just return it # Check if project already exists in the repository
if migrated and existing: try:
existing = self._project_repo.get(namespaced_name)
except Exception:
existing = None
if existing is not None and not force:
if migrated:
return existing return existing
raise ValidationError(
message=f"Project with name '{name}' already exists",
details={"name": name},
)
# If force=True and project exists, delete it first if migrated and existing is not None:
if existing and force and existing.id: return existing
ctx.projects.delete(existing.id)
# Flush to ensure delete is committed before creating new project
ctx.flush()
# Now create the project # If force=True and project exists, delete it first
created_project = ctx.projects.create(project) if existing is not None and force:
self._project_repo.delete(namespaced_name)
# Create default "main" plan for the project # Build context_config
if created_project.id: ignore_patterns = list(DEFAULT_IGNORE_PATTERNS) if apply_default_filters else []
main_plan = Plan( context_config = ContextConfig(
id=None, ignore_patterns=ignore_patterns,
project_id=created_project.id, include_patterns=[],
name="main", )
prompt="Main development plan",
status=PlanStatus.PENDING, # Create the NamespacedProject
current=True, now = datetime.now(tz=UTC)
created_at=datetime.now(), project = NamespacedProject(
updated_at=datetime.now(), name=bare_name,
build=None, namespace=namespace,
build_started_at=None, description=None,
build_completed_at=None, linked_resources=[],
model_used=None, context_config=context_config,
token_count=None, created_at=now,
result=None, updated_at=now,
applied_at=None, )
files_created=None,
files_modified=None, created_project = self._project_repo.create(project)
files_deleted=None,
) # Create a default "main" plan in the legacy schema so that
created_plan = ctx.plans.create(main_plan) # plan-based operations continue to work during the transition.
if created_plan.id: self._create_default_plan_for_project(bare_name)
ctx.plans.set_current(created_project.id, created_plan.id)
created_project.current_plan_id = created_plan.id
ctx.projects.update(created_project)
return created_project return created_project
def get_current_project(self) -> Project | None: def _create_default_plan_for_project(self, bare_name: str) -> None:
"""Create a default 'main' plan for a newly created project.
This uses the legacy UnitOfWork/Plan schema so that plan-based
operations continue to work during the transition period.
"""
from cleveragents.domain.models.core import (
Plan,
PlanStatus,
Project,
ProjectSettings,
)
# Look up the legacy project record (created by migration or existing)
with self.unit_of_work.transaction() as ctx:
legacy_project = ctx.projects.get_by_name(bare_name)
if legacy_project is None:
# Create a minimal legacy record so plans can be attached
legacy_project = Project(
id=None,
name=bare_name,
path=Path("."),
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
),
)
legacy_project = ctx.projects.create(legacy_project)
if legacy_project.id is None:
return
# Check if a "main" plan already exists
existing_plans = ctx.plans.get_all_for_project(legacy_project.id)
if existing_plans:
return
main_plan = Plan(
id=None,
project_id=legacy_project.id,
name="main",
prompt="Main development plan",
status=PlanStatus.PENDING,
current=True,
created_at=datetime.now(),
updated_at=datetime.now(),
build=None,
build_started_at=None,
build_completed_at=None,
model_used=None,
token_count=None,
result=None,
applied_at=None,
files_created=None,
files_modified=None,
files_deleted=None,
)
created_plan = ctx.plans.create(main_plan)
if created_plan.id:
ctx.plans.set_current(legacy_project.id, created_plan.id)
def get_current_project(self) -> NamespacedProject | None:
"""Get the current project from the working directory. """Get the current project from the working directory.
Walks up the directory tree looking for a ``.cleveragents`` directory.
If found, looks up the project in the repository by its namespaced name.
Falls back to a synthesized ``NamespacedProject`` if the record is not
in the repository (e.g. for projects not yet migrated).
Returns: Returns:
Project or None if no project found ``NamespacedProject`` or ``None`` if no project found.
""" """
path = Path.cwd().resolve() path = Path.cwd().resolve()
env_root = os.getenv("CLEVERAGENTS_PROJECT_SEARCH_ROOT") env_root = os.getenv("CLEVERAGENTS_PROJECT_SEARCH_ROOT")
@@ -209,117 +329,94 @@ class ProjectService:
) )
limit = search_root.resolve() if isinstance(search_root, Path) else None limit = search_root.resolve() if isinstance(search_root, Path) else None
# Look for .cleveragents directory
while path != path.parent: while path != path.parent:
if limit and not path.is_relative_to(limit): if limit and not path.is_relative_to(limit):
break break
if (path / ".cleveragents").exists(): if (path / ".cleveragents").exists():
# Try to read the project name from file
name_file = path / ".cleveragents" / "project.name" name_file = path / ".cleveragents" / "project.name"
if name_file.exists(): if name_file.exists():
project_name = name_file.read_text().strip() raw_name = name_file.read_text().strip()
# Look up in database
with self.unit_of_work.transaction() as ctx:
project = ctx.projects.get_by_name(project_name)
if project:
return project
# If not in database, return a temporary project object
# This handles projects that exist but haven't been
# migrated to DB yet
try:
return Project(
id=None,
name=project_name,
path=path,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
)
except Exception:
return None
else: else:
# Legacy project without name file # Legacy project without name file — use directory name
project_name = path.name raw_name = path.name
# Try to look up in the repository
try:
namespace, bare_name = self._parse_name_to_parts(raw_name)
namespaced_name = f"{namespace}/{bare_name}"
return self._project_repo.get(namespaced_name)
except Exception:
pass
# Fall back to a synthesized NamespacedProject
try:
namespace, bare_name = self._parse_name_to_parts(raw_name)
return NamespacedProject(
name=bare_name,
namespace=namespace,
description=None,
linked_resources=[],
context_config=ContextConfig(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
except Exception:
return None
try:
return Project(
id=None,
name=project_name,
path=path,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
)
except Exception:
return None
path = path.parent path = path.parent
return None return None
def get_project_by_name(self, name: str) -> Project: def get_project_by_name(self, name: str) -> NamespacedProject:
"""Get a project by name. """Get a project by name.
Args: Args:
name: Project name name: Project name (bare or ``[[server:]namespace/]name``).
Returns: Returns:
Project ``NamespacedProject``.
Raises: Raises:
NotFoundError: If project not found NotFoundError: If project not found.
""" """
with self.unit_of_work.transaction() as ctx: namespaced_name = self._namespaced_name_for(name)
project = ctx.projects.get_by_name(name) try:
if not project: return self._project_repo.get(namespaced_name)
raise NotFoundError( except Exception as exc:
resource_type="project", raise NotFoundError(
resource_id=name, resource_type="project",
) resource_id=name,
return project ) from exc
def update_project(self, project: Project) -> Project: def update_project(self, project: NamespacedProject) -> NamespacedProject:
"""Update a project. """Update a project.
Args: Args:
project: Project to update project: ``NamespacedProject`` to update.
Returns: Returns:
Updated project Updated ``NamespacedProject``.
""" """
with self.unit_of_work.transaction() as ctx: return self._project_repo.update(project)
return ctx.projects.update(project)
def get_project_stats(self, project: Project) -> dict[str, Any]: def get_project_stats(self, project: NamespacedProject) -> dict[str, Any]:
"""Get statistics for a project. """Get statistics for a project.
Args: Args:
project: The project to get stats for project: The ``NamespacedProject`` to get stats for.
Returns: Returns:
Dict with project statistics Dict with project statistics.
""" """
with self.unit_of_work.transaction() as ctx: with self.unit_of_work.transaction() as ctx:
# Get actual stats from database # Look up the legacy project record by bare name
plans = ctx.plans.get_all_for_project(project.id) if project.id else [] legacy_project = ctx.projects.get_by_name(project.name)
legacy_id = legacy_project.id if legacy_project else None
plans = ctx.plans.get_all_for_project(legacy_id) if legacy_id else []
current_plan = ( current_plan = (
ctx.plans.get_current_for_project(project.id) if project.id else None ctx.plans.get_current_for_project(legacy_id) if legacy_id else None
) )
context_count = 0 context_count = 0
@@ -346,22 +443,18 @@ class ProjectService:
force: bool = False, force: bool = False,
create_ignore_file: bool = False, create_ignore_file: bool = False,
apply_default_filters: bool = False, apply_default_filters: bool = False,
) -> Project: ) -> NamespacedProject:
"""Create a new CleverAgents project (alias for initialize_project). """Create a new CleverAgents project (alias for initialize_project).
This method is an alias for initialize_project to maintain API compatibility.
Args: Args:
name: Project name name: Project name.
path: Project path path: Project path.
force: Force reinitialization if project exists force: Force reinitialization if project exists.
create_ignore_file: Whether to write a default ``.agentsignore``.
apply_default_filters: Whether to populate default ignore patterns.
Returns: Returns:
Project: The created project The created ``NamespacedProject``.
Raises:
ValidationError: If project already exists and force is False
FileSystemError: If unable to create project directories
""" """
return self.initialize_project( return self.initialize_project(
name, name,
@@ -371,46 +464,53 @@ class ProjectService:
apply_default_filters=apply_default_filters, apply_default_filters=apply_default_filters,
) )
def get_project_by_path(self, path: Path) -> Project | None: def get_project_by_path(self, path: Path) -> NamespacedProject | None:
"""Get a project by its filesystem path. """Get a project by its filesystem path.
Walks the ``.cleveragents/project.name`` file at the given path and
looks up the project in the repository.
Args: Args:
path: Project path path: Project filesystem path.
Returns: Returns:
Project or None if not found ``NamespacedProject`` or ``None`` if not found.
""" """
with self.unit_of_work.transaction() as ctx: name_file = path / ".cleveragents" / "project.name"
all_projects = ctx.projects.get_all() if not name_file.exists():
for project in all_projects: return None
if project.path == path: raw_name = name_file.read_text().strip()
return project try:
namespaced_name = self._namespaced_name_for(raw_name)
return self._project_repo.get(namespaced_name)
except Exception:
return None return None
def list_projects(self, order_by: str = "created_at") -> list[Project]: def list_projects(
self,
order_by: str = "created_at",
namespace: str | None = None,
) -> list[NamespacedProject]:
"""List all projects with optional ordering. """List all projects with optional ordering.
Args: Args:
order_by: Field to order by (e.g., "created_at", "name") order_by: Field to order by (``"created_at"`` or ``"name"``).
namespace: Optional namespace filter.
Returns: Returns:
List of projects List of ``NamespacedProject`` instances.
""" """
with self.unit_of_work.transaction() as ctx: projects = self._project_repo.list_projects(namespace=namespace)
projects = ctx.projects.get_all()
# Sort projects based on order_by parameter if order_by == "name":
if order_by == "name": return sorted(projects, key=lambda p: p.name)
return sorted(projects, key=lambda p: p.name) else:
elif order_by == "created_at": # Default to created_at ordering
return sorted(projects, key=lambda p: p.created_at) return sorted(projects, key=lambda p: p.created_at)
else:
# Default to created_at ordering
return sorted(projects, key=lambda p: p.created_at)
def update_file_filters( def update_file_filters(
self, self,
project: Project, project: NamespacedProject,
*, *,
include_add: list[str] | None = None, include_add: list[str] | None = None,
exclude_add: list[str] | None = None, exclude_add: list[str] | None = None,
@@ -418,8 +518,24 @@ class ProjectService:
exclude_remove: list[str] | None = None, exclude_remove: list[str] | None = None,
clear_include: bool = False, clear_include: bool = False,
clear_exclude: bool = False, clear_exclude: bool = False,
) -> Project: ) -> NamespacedProject:
"""Update project include/exclude globs and persist.""" """Update project include/exclude globs and persist.
Maps to ``NamespacedProject.context_config.include_patterns`` and
``context_config.ignore_patterns`` respectively.
Args:
project: The project to update.
include_add: Include patterns to add.
exclude_add: Exclude/ignore patterns to add.
include_remove: Include patterns to remove.
exclude_remove: Exclude/ignore patterns to remove.
clear_include: Clear all include patterns.
clear_exclude: Clear all exclude/ignore patterns.
Returns:
Updated ``NamespacedProject``.
"""
def _dedup(seq: list[str]) -> list[str]: def _dedup(seq: list[str]) -> list[str]:
seen: set[str] = set() seen: set[str] = set()
@@ -430,63 +546,93 @@ class ProjectService:
ordered.append(item) ordered.append(item)
return ordered return ordered
with self.unit_of_work.transaction() as ctx: namespaced_name = project.namespaced_name
refreshed = ctx.projects.get_by_name(project.name) try:
if not refreshed: refreshed = self._project_repo.get(namespaced_name)
raise NotFoundError( except Exception as exc:
message="Project not found", details={"name": project.name} raise NotFoundError(
) message="Project not found", details={"name": namespaced_name}
) from exc
settings = refreshed.settings config = refreshed.context_config
includes = [] if clear_include else list(settings.include_paths)
excludes = [] if clear_exclude else list(settings.exclude_paths)
if include_add: includes = [] if clear_include else list(config.include_patterns)
includes.extend(include_add) excludes = [] if clear_exclude else list(config.ignore_patterns)
if exclude_add:
excludes.extend(exclude_add)
if include_remove:
includes = [p for p in includes if p not in include_remove]
if exclude_remove:
excludes = [p for p in excludes if p not in exclude_remove]
settings.include_paths = _dedup(includes) if include_add:
settings.exclude_paths = _dedup(excludes) includes.extend(include_add)
refreshed.settings = settings if exclude_add:
ctx.projects.update(refreshed) excludes.extend(exclude_add)
return refreshed if include_remove:
includes = [p for p in includes if p not in include_remove]
if exclude_remove:
excludes = [p for p in excludes if p not in exclude_remove]
def get_project_filters(self, project: Project) -> tuple[list[str], list[str]]: new_config = ContextConfig(
"""Return include and exclude globs for a project.""" ignore_patterns=_dedup(excludes),
include_patterns=_dedup(includes),
max_file_size=config.max_file_size,
max_total_size=config.max_total_size,
indexing_strategy=config.indexing_strategy,
chunking_policy=config.chunking_policy,
chunk_size=config.chunk_size,
hot_max_tokens=config.hot_max_tokens,
warm_max_decisions=config.warm_max_decisions,
cold_max_decisions=config.cold_max_decisions,
summarize=config.summarize,
summary_max_tokens=config.summary_max_tokens,
temporal_scope=config.temporal_scope,
auto_refresh=config.auto_refresh,
retention_policy=config.retention_policy,
execution_environment=config.execution_environment,
execution_env_priority=config.execution_env_priority,
)
with self.unit_of_work.transaction() as ctx: updated = refreshed.model_copy(
refreshed = ctx.projects.get_by_name(project.name) update={
if not refreshed: "context_config": new_config,
raise NotFoundError( "updated_at": datetime.now(tz=UTC),
message="Project not found", details={"name": project.name} }
) )
return ( return self._project_repo.update(updated)
list(refreshed.settings.include_paths),
list(refreshed.settings.exclude_paths),
)
def delete_project(self, project: Project) -> None: def get_project_filters(
"""Delete a project from the database. self, project: NamespacedProject
) -> tuple[list[str], list[str]]:
"""Return include and exclude globs for a project.
Returns:
Tuple of (include_patterns, ignore_patterns).
"""
namespaced_name = project.namespaced_name
try:
refreshed = self._project_repo.get(namespaced_name)
except Exception as exc:
raise NotFoundError(
message="Project not found", details={"name": namespaced_name}
) from exc
return (
list(refreshed.context_config.include_patterns),
list(refreshed.context_config.ignore_patterns),
)
def delete_project(self, project: NamespacedProject) -> None:
"""Delete a project from the repository.
Args: Args:
project: The project to delete project: The ``NamespacedProject`` to delete.
""" """
with self.unit_of_work.transaction() as ctx: namespaced_name = project.namespaced_name
if project.id: self._project_repo.delete(namespaced_name)
ctx.projects.delete(project.id)
if project.id and self._event_bus is not None: if self._event_bus is not None:
try: try:
self._event_bus.emit( self._event_bus.emit(
DomainEvent( DomainEvent(
event_type=EventType.ENTITY_DELETED, event_type=EventType.ENTITY_DELETED,
details={ details={
"entity_type": "project", "entity_type": "project",
"entity_name": project.name, "entity_name": namespaced_name,
}, },
) )
) )
+3 -4
View File
@@ -5,7 +5,7 @@ automatic debugging of build failures.
""" """
from contextlib import suppress from contextlib import suppress
from typing import Annotated from typing import Annotated, Any
import typer import typer
from rich.live import Live from rich.live import Live
@@ -14,7 +14,6 @@ from rich.text import Text
from cleveragents.cli.renderers import _get_console from cleveragents.cli.renderers import _get_console
from cleveragents.core.exceptions import CleverAgentsError, PlanError from cleveragents.core.exceptions import CleverAgentsError, PlanError
from cleveragents.domain.models.core import Project
# Create sub-app for auto-debug commands # Create sub-app for auto-debug commands
app = typer.Typer(help="Auto-debug commands") app = typer.Typer(help="Auto-debug commands")
@@ -53,11 +52,11 @@ def auto_debug_command(max_attempts: int = 3) -> tuple[bool, int]:
return (success, attempts_made) return (success, attempts_made)
def _get_current_project() -> Project: def _get_current_project() -> Any:
"""Get the current project or exit with error. """Get the current project or exit with error.
Returns: Returns:
Project: The current project The current project (``NamespacedProject`` or legacy ``Project``).
Raises: Raises:
typer.Abort: If no project found typer.Abort: If no project found
+11 -2
View File
@@ -260,11 +260,18 @@ def init_command(
) )
console.print("[green]✓ OK[/green] Initialized (non-interactive)") console.print("[green]✓ OK[/green] Initialized (non-interactive)")
else: else:
# NamespacedProject has no path attribute; display namespaced_name instead
project_location = getattr(project, "path", None)
location_str = (
str(project_location / ".cleveragents")
if project_location is not None
else project.namespaced_name
)
console.print( console.print(
Panel( Panel(
f"[green]✓[/green] Project '{project.name}' " f"[green]✓[/green] Project '{project.name}' "
f"initialized successfully!\n\n" f"initialized successfully!\n\n"
f"Location: {project.path / '.cleveragents'}\n" f"Location: {location_str}\n"
f"Database: SQLite\n" f"Database: SQLite\n"
f"Status: Ready", f"Status: Ready",
title="Project Initialized", title="Project Initialized",
@@ -513,9 +520,11 @@ def status() -> None:
stats = project_service.get_project_stats(project) stats = project_service.get_project_stats(project)
# Display project information # Display project information
# NamespacedProject has no path attribute; display namespaced_name instead
project_path_str = getattr(project, "path", project.namespaced_name)
info_text = f""" info_text = f"""
[bold]Project:[/bold] {project.name} [bold]Project:[/bold] {project.name}
[bold]Path:[/bold] {project.path} [bold]Path:[/bold] {project_path_str}
[bold]Created:[/bold] {project.created_at} [bold]Created:[/bold] {project.created_at}
[bold]Statistics:[/bold] [bold]Statistics:[/bold]