UAT: ProjectService uses legacy Project model instead of spec-aligned NamespacedProjectProjectRepositoryProtocol is disconnected from service layer #3900

Closed
freemo wants to merge 3 commits from fix/project-service-namespaced-project into master
10 changed files with 745 additions and 429 deletions
@@ -337,7 +337,7 @@ def step_plan_list_no_filters(context: Context) -> None:
"""
import cleveragents.cli.commands.plan as _plan_mod
wide_runner = CliRunner(mix_stderr=False)
wide_runner = CliRunner()
original_width = _plan_mod.console._width
_plan_mod.console._width = 200
try:
+76 -80
View File
@@ -18,9 +18,8 @@ from cleveragents.core.exceptions import FileSystemError, NotFoundError, Validat
from cleveragents.domain.models.core import (
Plan,
PlanStatus,
Project,
ProjectSettings,
)
from cleveragents.domain.models.core.project import NamespacedProject
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")
def step_check_no_duplicate_project(context: Context) -> None:
"""Check that no duplicate project was created."""
with context.unit_of_work.transaction() as ctx:
projects = ctx.projects.get_by_name("legacy-project")
assert projects is not None # Should exist
# Try to get all projects and check count
all_projects = ctx.projects.get_all()
project_names = [p.name for p in all_projects]
assert project_names.count("legacy-project") == 1
# Use the project repository to verify uniqueness
all_projects = context.project_service.list_projects()
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}"')
def step_create_existing_project(context: Context, name: str) -> None:
"""Create an existing project in the database."""
with context.unit_of_work.transaction() as ctx:
project = Project(
id=None,
name=name,
path=context.temp_dir / name,
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",
),
)
context.existing_project = ctx.projects.create(project)
"""Create an existing project in the database via ProjectService."""
project_path = context.temp_dir / name
project_path.mkdir(parents=True, exist_ok=True)
context.existing_project = context.project_service.initialize_project(
name=name,
path=project_path,
force=False,
)
@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")
def step_update_project_settings(context: Context) -> None:
"""Update the project's settings."""
# Modify the project settings
context.saved_project.settings.auto_build = True
context.saved_project.settings.auto_apply = True
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
"""Update the project's description (spec-aligned NamespacedProject has no settings)."""
# NamespacedProject uses description instead of legacy settings fields
updated = context.saved_project.model_copy(
update={"description": "updated-description"}
)
context.updated_project = context.project_service.update_project(updated)
@then("the project should be updated successfully")
def step_check_project_updated(context: Context) -> None:
"""Check that the project was updated successfully."""
assert context.updated_project is not None
assert context.updated_project.settings.auto_build is True
assert context.updated_project.settings.auto_apply is True
assert context.updated_project.settings.default_model == "updated-model"
assert context.updated_project.description == "updated-description"
@then("the updated project should be returned")
def step_check_updated_project_returned(context: Context) -> None:
"""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
@@ -443,9 +424,10 @@ def step_create_multiple_projects_times(context: Context) -> None:
name=f"project{i}", path=project_path, force=False
)
# Update created_at to simulate different creation times
with context.unit_of_work.transaction() as ctx:
project.created_at = base_time + timedelta(hours=i)
ctx.projects.update(project)
updated = project.model_copy(
update={"created_at": base_time + timedelta(hours=i)}
)
context.project_service.update_project(updated)
@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
)
# 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:
for i in range(3):
plan = Plan(
id=None,
project_id=context.stats_project.id,
name=f"plan{i}",
prompt=f"Test plan {i}",
status=PlanStatus.PENDING,
current=i == 0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
ctx.plans.create(plan)
legacy_project = ctx.projects.get_by_name(context.stats_project.name)
if legacy_project and legacy_project.id:
for i in range(3):
plan = Plan(
id=None,
project_id=legacy_project.id,
name=f"plan{i}",
prompt=f"Test plan {i}",
status=PlanStatus.PENDING,
current=i == 0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
ctx.plans.create(plan)
@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")
def step_check_project_not_in_database(context: Context) -> None:
"""Check that the project was deleted from the database."""
with context.unit_of_work.transaction() as ctx:
project = ctx.projects.get_by_name(context.saved_project.name)
assert project is None, "Project should not exist in database"
"""Check that the project was deleted from the repository."""
try:
context.project_service.get_project_by_name(context.saved_project.name)
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"
@@ -734,21 +723,16 @@ def step_check_permission_error(context: Context) -> None:
# Missing step definitions for scenario: "Update project that does not exist"
@given("I have a project object that is not in database")
def step_create_project_not_in_db(context: Context) -> None:
"""Create a project object that is not in the database."""
context.non_existent_project = Project(
id=999999, # Non-existent ID
"""Create a NamespacedProject object that is not in the database."""
from datetime import UTC, datetime
context.non_existent_project = NamespacedProject(
name="non-existent",
path=context.temp_dir / "non-existent",
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",
),
namespace="local",
description=None,
linked_resources=[],
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
@@ -926,7 +910,11 @@ def step_existing_project_reused(context: Context) -> None:
assert context.error is None, f"Unexpected error: {context.error}"
assert context.project_result is not None, "No project was returned"
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')
@@ -998,7 +986,8 @@ def step_assert_temporary_project(context: Context, name: str) -> None:
project = getattr(context, "current_project_result", None)
assert project is not None, "Expected a temporary project to be returned"
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")
@@ -1026,15 +1015,22 @@ def step_assert_alias_project_created(context: Context) -> None:
project = getattr(context, "alias_project", None)
assert project is not None, "Alias project was not created"
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")
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"
# 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.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)
context.unit_of_work = unit_of_work
context.project_service = ProjectService(settings, unit_of_work)
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}"')
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":
path = context.test_dir
expected_path = Path(path)
assert context.project.path == expected_path, (
f"Expected path {expected_path}, got {context.project.path}"
expected_cleveragents = Path(path) / ".cleveragents"
assert expected_cleveragents.exists(), (
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")
def step_project_should_have_path(context: Context) -> None:
"""Verify project has a path."""
assert context.current_project.path is not None, "Project has no path"
"""Verify project has a namespaced_name (spec-aligned identity).
``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")
def step_have_current_project(context: Context) -> None:
"""Ensure we have a current project."""
if not hasattr(context, "project_service"):
import uuid
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"):
context.test_dir = tempfile.mkdtemp(prefix="test_project_")
+11 -6
View File
@@ -697,11 +697,22 @@ class Container(containers.DeclarativeContainer):
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)
# ProjectService receives the spec-aligned NamespacedProjectRepository so
# that all project CRUD operations use NamespacedProject (ADR-007).
project_service = providers.Factory(
ProjectService,
settings=settings,
unit_of_work=unit_of_work,
project_repository=namespaced_project_repo,
event_bus=event_bus,
)
@@ -814,12 +825,6 @@ class Container(containers.DeclarativeContainer):
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_repo = providers.Factory(
_build_project_resource_link_repo,
@@ -21,7 +21,7 @@ import structlog
from cleveragents.config.settings import Settings
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 (
UnitOfWork,
UnitOfWorkContext,
@@ -115,8 +115,79 @@ class ContextService:
self.extra_ignore_patterns: 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(
self, project: Project, path: Path, recursive: bool = True
self, project: Any, path: Path, recursive: bool = True
) -> tuple[list[Path], list[Path]]:
"""Add files to the current plan's context.
@@ -140,9 +211,10 @@ class ContextService:
plan_id: int | None = None
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 = (
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:
@@ -335,37 +407,51 @@ class ContextService:
return True
return rel_str.startswith(f"{cleaned}/")
def _project_path_matches(self, project: Project, path: Path, pattern: str) -> bool:
"""Match a path against a project-level include/exclude glob."""
def _project_path_matches(self, project: Any, path: Path, pattern: str) -> bool:
"""Match a path against a project-level include/exclude glob.
try:
rel = path.relative_to(project.path)
except ValueError:
return False
rel_str = rel.as_posix()
name = path.name
return fnmatch(rel_str, pattern) or fnmatch(name, pattern)
Handles both legacy ``Project`` (which has a ``.path`` attribute) and
spec-aligned ``NamespacedProject`` (which is path-agnostic). When the
project has no ``.path``, falls back to matching against the filename
only.
"""
project_path = self._resolve_project_path(project)
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:
"""Check whether to ignore a path via settings and .agentsignore."""
def _should_ignore(self, project: Any, path: Path) -> bool:
"""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):
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):
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)
for pattern in project.settings.include_paths
for pattern in include_patterns
):
return True
rules = self._collect_ignore_rules(path)
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.
Args:
@@ -377,8 +463,9 @@ class ContextService:
"""
plan_id: int | None = None
with self.unit_of_work.transaction() as ctx:
_legacy_id = self._resolve_legacy_project_id(project)
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:
@@ -402,7 +489,7 @@ class ContextService:
return removed_count
def clear_context(self, project: Project) -> int:
def clear_context(self, project: Any) -> int:
"""Clear all context for the current plan.
Args:
@@ -413,8 +500,9 @@ class ContextService:
"""
plan_id: int | None = None
with self.unit_of_work.transaction() as ctx:
_legacy_id = self._resolve_legacy_project_id(project)
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:
@@ -431,7 +519,7 @@ class ContextService:
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.
Args:
@@ -440,18 +528,19 @@ class ContextService:
Returns:
List of context entries
"""
if not project.id:
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return []
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:
return []
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.
Args:
@@ -463,7 +552,7 @@ class ContextService:
contexts = self.list_context(project)
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.
Args:
@@ -475,7 +564,7 @@ class ContextService:
contexts = self.list_context(project)
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.
Args:
@@ -493,7 +582,7 @@ class ContextService:
return context.content
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.
This is a convenience method that returns just the file paths
@@ -526,17 +615,18 @@ class ContextService:
contexts = self.list_context(project)
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."""
if not project.id:
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return None
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(
self,
project: Project,
project: Any,
*,
run_name: str,
file_paths: list[str],
@@ -548,8 +638,9 @@ class ContextService:
return {}
plan = self._get_current_plan(project)
_legacy_id = self._resolve_legacy_project_id(project)
metadata = {
"project_id": project.id,
"project_id": _legacy_id,
"project_name": project.name,
"plan_id": getattr(plan, "id", None),
"plan_name": getattr(plan, "name", None),
@@ -562,8 +653,8 @@ class ContextService:
"service:context",
f"mode:{mode}",
]
if project.id is not None:
tags.append(f"project:{project.id}")
if _legacy_id is not None:
tags.append(f"project:{_legacy_id}")
return (
self.settings.build_langsmith_config(
tags=tags,
@@ -575,7 +666,7 @@ class ContextService:
def _prepare_analysis_config(
self,
project: Project,
project: Any,
*,
run_name: str,
file_paths: list[str],
@@ -634,7 +725,7 @@ class ContextService:
def analyze_context(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> ContextAnalysisState:
"""Analyze the current plan's context using LangGraph workflow.
@@ -699,7 +790,7 @@ class ContextService:
async def analyze_context_async(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> ContextAnalysisState:
"""Asynchronously analyze the current plan's context.
@@ -750,7 +841,7 @@ class ContextService:
def analyze_context_streaming(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> Iterator[dict[str, Any]]:
"""Stream the context analysis workflow execution.
@@ -795,7 +886,7 @@ class ContextService:
async def analyze_context_streaming_async(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Asynchronously stream the context analysis workflow execution.
@@ -840,7 +931,7 @@ class ContextService:
def get_context_summary(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> str:
"""Get a high-level summary of the current context.
@@ -860,7 +951,7 @@ class ContextService:
def get_context_dependencies(
self,
project: Project,
project: Any,
llm: BaseLanguageModel | None = None,
) -> dict[str, list[str]]:
"""Get extracted dependencies for all context files.
@@ -880,7 +971,7 @@ class ContextService:
def get_relevant_files(
self,
project: Project,
project: Any,
threshold: float = 0.5,
llm: BaseLanguageModel | None = None,
) -> list[tuple[str, float]]:
@@ -906,7 +997,7 @@ class ContextService:
def search_context(
self,
project: Project,
project: Any,
query: str,
*,
limit: int = 5,
@@ -45,7 +45,6 @@ from cleveragents.domain.models.core import (
PlanBuild,
PlanResult,
PlanStatus,
Project,
)
from cleveragents.domain.providers.ai_provider import (
ActorInvocationContext,
@@ -100,6 +99,45 @@ class PlanService:
self._llm = llm
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:
"""Return True when the runtime is configured to force the mock provider."""
@@ -510,7 +548,7 @@ class PlanService:
def _build_langsmith_config(
self,
project: Project,
project: Any,
plan: Plan | None,
*,
run_name: str,
@@ -522,8 +560,9 @@ class PlanService:
if not getattr(self.settings, "is_langsmith_enabled", False):
return {}
_legacy_id = self._resolve_legacy_project_id(project)
base_metadata: dict[str, Any] = {
"project_id": project.id,
"project_id": _legacy_id,
"project_name": project.name,
}
if plan and plan.id:
@@ -539,8 +578,8 @@ class PlanService:
global_tags = list(self.settings.langsmith_tags)
if global_tags:
base_tags.extend(global_tags)
if project.id is not None:
base_tags.append(f"project:{project.id}")
if _legacy_id is not None:
base_tags.append(f"project:{_legacy_id}")
if plan and plan.id is not None:
base_tags.append(f"plan:{plan.id}")
if tags:
@@ -557,7 +596,7 @@ class PlanService:
def _prepare_langsmith_config(
self,
project: Project,
project: Any,
plan: Plan | None,
*,
run_name: str,
@@ -578,9 +617,7 @@ class PlanService:
config["configurable"]["thread_id"] = f"{thread_prefix}-{uuid.uuid4()}"
return config
def create_plan(
self, project: Project, prompt: str, name: str | None = None
) -> Plan:
def create_plan(self, project: Any, prompt: str, name: str | None = None) -> Plan:
"""Create a new plan with instructions for AI.
Args:
@@ -599,8 +636,9 @@ class PlanService:
words = prompt.split()[:3] if prompt else ["new", "plan"]
name = "_".join(words).lower()
# Ensure project has a valid ID
if not project.id:
# Ensure project has a valid legacy ID
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
raise ValidationError(
message=(
"Cannot create plan: this directory is not linked to a saved "
@@ -623,7 +661,7 @@ class PlanService:
plan = Plan(
id=None,
project_id=project.id,
project_id=_legacy_id,
name=name,
prompt=prompt,
status=PlanStatus.PENDING,
@@ -647,13 +685,13 @@ class PlanService:
created_plan = ctx.plans.create(plan)
# Set as current plan for the project
if project.id and created_plan.id:
ctx.plans.set_current(project.id, created_plan.id)
if _legacy_id and created_plan.id:
ctx.plans.set_current(_legacy_id, created_plan.id)
created_plan.current = True
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.
Args:
@@ -668,7 +706,7 @@ class PlanService:
def build_plan(
self,
project: Project,
project: Any,
progress_callback: Callable[[int], None] | None = None,
actor: str | None = None,
) -> list[Change]:
@@ -685,9 +723,10 @@ class PlanService:
Raises:
PlanError: If no current plan or build fails
"""
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx:
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:
@@ -784,7 +823,7 @@ class PlanService:
def auto_debug_build(
self,
project: Project,
project: Any,
max_attempts: int = 3,
progress_callback: Callable[[int], None] | None = None,
) -> tuple[bool, list[Change], str | None]:
@@ -807,9 +846,10 @@ class PlanService:
from cleveragents.agents import AutoDebugAgent, AutoDebugState
from cleveragents.domain.models.core import DebugAttempt
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx:
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:
@@ -942,7 +982,7 @@ class PlanService:
# If we exhausted all attempts, return failure
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.
Args:
@@ -951,9 +991,10 @@ class PlanService:
Returns:
List of pending changes
"""
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx:
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:
@@ -962,7 +1003,7 @@ class PlanService:
all_changes = ctx.changes.get_for_plan(current_plan.id)
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.
Args:
@@ -974,9 +1015,11 @@ class PlanService:
Raises:
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:
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:
@@ -988,14 +1031,14 @@ class PlanService:
changes = ctx.changes.get_for_plan(current_plan.id)
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:
"""Resolve *raw_path* and reject it if it escapes *project_root*."""
if Path(raw_path).is_absolute():
resolved = Path(raw_path).resolve()
else:
resolved = (project.path / raw_path).resolve()
resolved = ((_project_path or Path.cwd()) / raw_path).resolve()
if not resolved.is_relative_to(project_root):
raise PlanError(
message=(
@@ -1068,7 +1111,7 @@ class PlanService:
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.
Args:
@@ -1077,13 +1120,14 @@ class PlanService:
Returns:
Current plan or None
"""
if not project.id:
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return None
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.
Args:
@@ -1092,13 +1136,14 @@ class PlanService:
Returns:
List of plans
"""
if not project.id:
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
return []
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.
Args:
@@ -1111,19 +1156,20 @@ class PlanService:
Raises:
ValidationError: If plan not found
"""
if not project.id:
_legacy_id = self._resolve_legacy_project_id(project)
if not _legacy_id:
raise ValidationError(
message="Project not initialized",
details={"hint": "Initialize project first with 'agents init'"},
)
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:
if plan.name == name:
if plan.id:
ctx.plans.set_current(project.id, plan.id)
ctx.plans.set_current(_legacy_id, plan.id)
return plan
raise ValidationError(
@@ -1131,7 +1177,7 @@ class PlanService:
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.
Args:
@@ -1141,9 +1187,10 @@ class PlanService:
Raises:
PlanError: If no current plan
"""
_legacy_id = self._resolve_legacy_project_id(project)
with self.unit_of_work.transaction() as ctx:
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:
@@ -1161,7 +1208,7 @@ class PlanService:
current_plan.status = PlanStatus.PENDING
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.
Args:
@@ -1172,7 +1219,7 @@ class PlanService:
async def generate_plan_streaming(
self,
project: Project,
project: Any,
description: str,
name: str | None = None,
actor: str | None = None,
@@ -1,17 +1,26 @@
"""Project service for managing CleverAgents projects.
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
import os
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import structlog
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.context_service import DEFAULT_IGNORE_PATTERNS
from cleveragents.config.settings import Settings
@@ -21,6 +30,20 @@ from cleveragents.core.exceptions import (
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.legacy_migrator import (
check_and_migrate_legacy_data,
)
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
@@ -35,20 +58,28 @@ class ProjectService:
"""Service for managing CleverAgents projects.
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__(
self,
settings: Settings,
unit_of_work: UnitOfWork,
project_repository: ProjectRepositoryProtocol | None = None,
event_bus: EventBus | None = None,
):
"""Initialize the project service.
Args:
settings: Application settings
unit_of_work: Unit of Work for database transactions
settings: Application settings.
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.
"""
self.settings = settings
@@ -57,6 +88,45 @@ class ProjectService:
# Optional search root to limit filesystem discovery (used in tests)
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."""
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(
self,
name: str,
@@ -64,39 +134,44 @@ class ProjectService:
force: bool = False,
create_ignore_file: bool = False,
apply_default_filters: bool = False,
) -> Project:
) -> NamespacedProject:
"""Initialize a new CleverAgents project.
Creates the ``.cleveragents`` directory structure on the filesystem
and persists a ``NamespacedProject`` record via the repository.
Args:
name: Project name
path: Project path
force: Force reinitialization if project exists
create_ignore_file: Whether to write a default .agentsignore
in the project root
name: Project name (bare or ``[[server:]namespace/]name``).
path: Project filesystem path.
force: Force reinitialization if project exists.
create_ignore_file: Whether to write a default ``.agentsignore``
in the project root.
apply_default_filters: Whether to populate ``context_config``
with the default ignore patterns.
Returns:
Project: The initialized project
The initialized ``NamespacedProject``.
Raises:
ValidationError: If project already exists and force is False
FileSystemError: If unable to create project directories
ValidationError: If project already exists and *force* is False.
FileSystemError: If unable to create project directories.
"""
project_dir = path / ".cleveragents"
# Check if already initialized
# Check if already initialized on the filesystem
if project_dir.exists() and not force:
raise ValidationError(
message=f"Project already initialized at {path}",
details={"path": str(path), "use_force": "Add --force to reinitialize"},
)
# Create project structure
# Create project structure on filesystem
try:
project_dir.mkdir(parents=True, exist_ok=True)
(project_dir / "db.sqlite").touch()
(project_dir / "config.yaml").touch()
(project_dir / "current").write_text("main")
# Store the project name
# Store the project name for later discovery
(project_dir / "project.name").write_text(name)
if create_ignore_file:
@@ -110,97 +185,133 @@ class ProjectService:
path=path,
) from e
# Create project in database
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
# Ensure the database schema exists
self.unit_of_work.init_database()
# Check for and migrate legacy JSON data if it exists
from cleveragents.infrastructure.database.legacy_migrator import (
check_and_migrate_legacy_data,
)
migrated = check_and_migrate_legacy_data(path, self.unit_of_work)
with self.unit_of_work.transaction() as ctx:
# Check if project with this name already exists FIRST
existing = ctx.projects.get_by_name(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},
)
# Parse the namespaced name
namespace, bare_name = self._parse_name_to_parts(name)
namespaced_name = f"{namespace}/{bare_name}"
# If project was created during migration, just return it
if migrated and existing:
# Check if project already exists in the repository
try:
existing = self._project_repo.get(namespaced_name)
except NotFoundError:
existing = None
if existing is not None and not force:
if migrated:
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 existing and force and existing.id:
ctx.projects.delete(existing.id)
# Flush to ensure delete is committed before creating new project
ctx.flush()
if migrated and existing is not None:
return existing
# Now create the project
created_project = ctx.projects.create(project)
# If force=True and project exists, delete it first
if existing is not None and force:
self._project_repo.delete(namespaced_name)
# Create default "main" plan for the project
if created_project.id:
main_plan = Plan(
id=None,
project_id=created_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(created_project.id, created_plan.id)
created_project.current_plan_id = created_plan.id
ctx.projects.update(created_project)
# Build context_config
ignore_patterns = list(DEFAULT_IGNORE_PATTERNS) if apply_default_filters else []
context_config = ContextConfig(
ignore_patterns=ignore_patterns,
include_patterns=[],
)
# Create the NamespacedProject
now = datetime.now(tz=UTC)
project = NamespacedProject(
name=bare_name,
namespace=namespace,
description=None,
linked_resources=[],
context_config=context_config,
created_at=now,
updated_at=now,
)
created_project = self._project_repo.create(project)
# Create a default "main" plan in the legacy schema so that
# plan-based operations continue to work during the transition.
self._create_default_plan_for_project(bare_name)
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.
"""
# 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.
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:
Project or None if no project found
``NamespacedProject`` or ``None`` if no project found.
"""
path = Path.cwd().resolve()
env_root = os.getenv("CLEVERAGENTS_PROJECT_SEARCH_ROOT")
@@ -209,117 +320,98 @@ class ProjectService:
)
limit = search_root.resolve() if isinstance(search_root, Path) else None
# Look for .cleveragents directory
while path != path.parent:
if limit and not path.is_relative_to(limit):
break
if (path / ".cleveragents").exists():
# Try to read the project name from file
name_file = path / ".cleveragents" / "project.name"
if name_file.exists():
project_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
raw_name = name_file.read_text().strip()
else:
# Legacy project without name file
project_name = path.name
# Legacy project without name file — use directory 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 NotFoundError:
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 (ValueError, ValidationError):
_logger.warning(
"Could not synthesize project from name file",
raw_name=raw_name,
)
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
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.
Args:
name: Project name
name: Project name (bare or ``[[server:]namespace/]name``).
Returns:
Project
``NamespacedProject``.
Raises:
NotFoundError: If project not found
NotFoundError: If project not found.
"""
with self.unit_of_work.transaction() as ctx:
project = ctx.projects.get_by_name(name)
if not project:
raise NotFoundError(
resource_type="project",
resource_id=name,
)
return project
namespaced_name = self._namespaced_name_for(name)
try:
return self._project_repo.get(namespaced_name)
except Exception as exc:
raise NotFoundError(
resource_type="project",
resource_id=name,
) from exc
def update_project(self, project: Project) -> Project:
def update_project(self, project: NamespacedProject) -> NamespacedProject:
"""Update a project.
Args:
project: Project to update
project: ``NamespacedProject`` to update.
Returns:
Updated project
Updated ``NamespacedProject``.
"""
with self.unit_of_work.transaction() as ctx:
return ctx.projects.update(project)
return self._project_repo.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.
Args:
project: The project to get stats for
project: The ``NamespacedProject`` to get stats for.
Returns:
Dict with project statistics
Dict with project statistics.
"""
with self.unit_of_work.transaction() as ctx:
# Get actual stats from database
plans = ctx.plans.get_all_for_project(project.id) if project.id else []
# Look up the legacy project record by bare name
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 = (
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
@@ -346,22 +438,18 @@ class ProjectService:
force: bool = False,
create_ignore_file: bool = False,
apply_default_filters: bool = False,
) -> Project:
) -> NamespacedProject:
"""Create a new CleverAgents project (alias for initialize_project).
This method is an alias for initialize_project to maintain API compatibility.
Args:
name: Project name
path: Project path
force: Force reinitialization if project exists
name: Project name.
path: Project path.
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:
Project: The created project
Raises:
ValidationError: If project already exists and force is False
FileSystemError: If unable to create project directories
The created ``NamespacedProject``.
"""
return self.initialize_project(
name,
@@ -371,46 +459,53 @@ class ProjectService:
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.
Walks the ``.cleveragents/project.name`` file at the given path and
looks up the project in the repository.
Args:
path: Project path
path: Project filesystem path.
Returns:
Project or None if not found
``NamespacedProject`` or ``None`` if not found.
"""
with self.unit_of_work.transaction() as ctx:
all_projects = ctx.projects.get_all()
for project in all_projects:
if project.path == path:
return project
name_file = path / ".cleveragents" / "project.name"
if not name_file.exists():
return None
raw_name = name_file.read_text().strip()
try:
namespaced_name = self._namespaced_name_for(raw_name)
return self._project_repo.get(namespaced_name)
except (NotFoundError, ValueError):
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.
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:
List of projects
List of ``NamespacedProject`` instances.
"""
with self.unit_of_work.transaction() as ctx:
projects = ctx.projects.get_all()
projects = self._project_repo.list_projects(namespace=namespace)
# Sort projects based on order_by parameter
if order_by == "name":
return sorted(projects, key=lambda p: p.name)
elif order_by == "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)
if order_by == "name":
return sorted(projects, key=lambda p: p.name)
else:
# Default to created_at ordering
return sorted(projects, key=lambda p: p.created_at)
def update_file_filters(
self,
project: Project,
project: NamespacedProject,
*,
include_add: list[str] | None = None,
exclude_add: list[str] | None = None,
@@ -418,8 +513,24 @@ class ProjectService:
exclude_remove: list[str] | None = None,
clear_include: bool = False,
clear_exclude: bool = False,
) -> Project:
"""Update project include/exclude globs and persist."""
) -> NamespacedProject:
"""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]:
seen: set[str] = set()
@@ -430,63 +541,93 @@ class ProjectService:
ordered.append(item)
return ordered
with self.unit_of_work.transaction() as ctx:
refreshed = ctx.projects.get_by_name(project.name)
if not refreshed:
raise NotFoundError(
message="Project not found", details={"name": project.name}
)
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
settings = refreshed.settings
includes = [] if clear_include else list(settings.include_paths)
excludes = [] if clear_exclude else list(settings.exclude_paths)
config = refreshed.context_config
if include_add:
includes.extend(include_add)
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]
includes = [] if clear_include else list(config.include_patterns)
excludes = [] if clear_exclude else list(config.ignore_patterns)
settings.include_paths = _dedup(includes)
settings.exclude_paths = _dedup(excludes)
refreshed.settings = settings
ctx.projects.update(refreshed)
return refreshed
if include_add:
includes.extend(include_add)
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]
def get_project_filters(self, project: Project) -> tuple[list[str], list[str]]:
"""Return include and exclude globs for a project."""
new_config = ContextConfig(
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:
refreshed = ctx.projects.get_by_name(project.name)
if not refreshed:
raise NotFoundError(
message="Project not found", details={"name": project.name}
)
return (
list(refreshed.settings.include_paths),
list(refreshed.settings.exclude_paths),
)
updated = refreshed.model_copy(
update={
"context_config": new_config,
"updated_at": datetime.now(tz=UTC),
}
)
return self._project_repo.update(updated)
def delete_project(self, project: Project) -> None:
"""Delete a project from the database.
def get_project_filters(
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:
project: The project to delete
project: The ``NamespacedProject`` to delete.
"""
with self.unit_of_work.transaction() as ctx:
if project.id:
ctx.projects.delete(project.id)
if project.id and self._event_bus is not None:
namespaced_name = project.namespaced_name
self._project_repo.delete(namespaced_name)
if self._event_bus is not None:
try:
self._event_bus.emit(
DomainEvent(
event_type=EventType.ENTITY_DELETED,
details={
"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 typing import Annotated
from typing import Annotated, Any
import typer
from rich.live import Live
@@ -14,7 +14,6 @@ from rich.text import Text
from cleveragents.cli.renderers import _get_console
from cleveragents.core.exceptions import CleverAgentsError, PlanError
from cleveragents.domain.models.core import Project
# Create sub-app for 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)
def _get_current_project() -> Project:
def _get_current_project() -> Any:
"""Get the current project or exit with error.
Returns:
Project: The current project
The current project (``NamespacedProject`` or legacy ``Project``).
Raises:
typer.Abort: If no project found
+2 -2
View File
@@ -209,8 +209,8 @@ if TYPE_CHECKING:
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core import Project
from cleveragents.domain.models.core.decision import Decision
from cleveragents.domain.models.core.project import NamespacedProject
# Create sub-app for plan commands
app = typer.Typer(
@@ -477,7 +477,7 @@ def _execute_output_dict(
}
def _get_current_project() -> Project:
def _get_current_project() -> NamespacedProject:
"""Get the current project or exit with error.
Returns:
+11 -2
View File
@@ -260,11 +260,18 @@ def init_command(
)
console.print("[green]✓ OK[/green] Initialized (non-interactive)")
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(
Panel(
f"[green]✓[/green] Project '{project.name}' "
f"initialized successfully!\n\n"
f"Location: {project.path / '.cleveragents'}\n"
f"Location: {location_str}\n"
f"Database: SQLite\n"
f"Status: Ready",
title="Project Initialized",
@@ -513,9 +520,11 @@ def status() -> None:
stats = project_service.get_project_stats(project)
# Display project information
# NamespacedProject has no path attribute; display namespaced_name instead
project_path_str = getattr(project, "path", project.namespaced_name)
info_text = f"""
[bold]Project:[/bold] {project.name}
[bold]Path:[/bold] {project.path}
[bold]Path:[/bold] {project_path_str}
[bold]Created:[/bold] {project.created_at}
[bold]Statistics:[/bold]