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