diff --git a/features/steps/plan_cli_spec_alignment_steps.py b/features/steps/plan_cli_spec_alignment_steps.py index 694caa8f9..0c495b911 100644 --- a/features/steps/plan_cli_spec_alignment_steps.py +++ b/features/steps/plan_cli_spec_alignment_steps.py @@ -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: diff --git a/features/steps/project_service_steps.py b/features/steps/project_service_steps.py index 3ff2260e0..ec7ec7925 100644 --- a/features/steps/project_service_steps.py +++ b/features/steps/project_service_steps.py @@ -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 ) diff --git a/features/steps/service_steps.py b/features/steps/service_steps.py index 3d17feaeb..47c4af80f 100644 --- a/features/steps/service_steps.py +++ b/features/steps/service_steps.py @@ -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_") diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index 03a79d5f4..65b6eff61 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -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, diff --git a/src/cleveragents/application/services/context_service.py b/src/cleveragents/application/services/context_service.py index f8d95086b..5f8127927 100644 --- a/src/cleveragents/application/services/context_service.py +++ b/src/cleveragents/application/services/context_service.py @@ -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, diff --git a/src/cleveragents/application/services/plan_service.py b/src/cleveragents/application/services/plan_service.py index d4dde2bf4..322ba4f76 100644 --- a/src/cleveragents/application/services/plan_service.py +++ b/src/cleveragents/application/services/plan_service.py @@ -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, diff --git a/src/cleveragents/application/services/project_service.py b/src/cleveragents/application/services/project_service.py index 3480d69fe..64b349b0c 100644 --- a/src/cleveragents/application/services/project_service.py +++ b/src/cleveragents/application/services/project_service.py @@ -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, }, ) ) diff --git a/src/cleveragents/cli/commands/auto_debug.py b/src/cleveragents/cli/commands/auto_debug.py index 376b1b711..c3bb6e1d3 100644 --- a/src/cleveragents/cli/commands/auto_debug.py +++ b/src/cleveragents/cli/commands/auto_debug.py @@ -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 diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 31528c61a..7c844c552 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -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: diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index 9fb55de18..788821829 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -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]