UAT: ProjectService uses legacy Project model instead of spec-aligned NamespacedProject — ProjectRepositoryProtocol is disconnected from service layer
#3900
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user