feat(plan): add multi-project subplan support #535
@@ -18,6 +18,14 @@
|
||||
container availability validation, and clear error when container is selected but no
|
||||
container resource is linked. Covered by Behave BDD scenarios, Robot Framework smoke
|
||||
tests, and ASV benchmarks. (#512)
|
||||
- Added multi-project subplan support with `MultiProjectMetadata`, `ProjectScope`,
|
||||
`ChangeSetSummary`, `CrossProjectDependency`, and `ProjectScopeResolver` domain models.
|
||||
`MultiProjectService` provides scope initialization, context resolution, per-project
|
||||
changeset recording, and cross-project constraint validation. Plan model extended with
|
||||
`multi_project_metadata` field, `is_multi_project` property, and `get_project_scope()`
|
||||
method. CLI `plan status` shows per-project changeset summaries for multi-project plans.
|
||||
Includes Behave BDD scenarios, Robot Framework smoke tests, ASV benchmarks, and reference
|
||||
documentation. (#199)
|
||||
- Added `SafetyProfile` domain model with configurable safety constraints (allowed skill
|
||||
categories, sandbox/checkpoint requirements, human-approval flag, cost/retry limits) and
|
||||
integrated it into the `Action` model via `from_config`/`as_cli_dict`. Persistence backed
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""ASV benchmarks for multi-project subplan overhead.
|
||||
|
||||
Measures the time to initialize scopes, resolve aliases, record
|
||||
changesets, and validate cross-project constraints at various
|
||||
project counts. The benchmark covers the hot paths that run
|
||||
during multi-project plan orchestration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.multi_project_service import ( # noqa: E402
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.domain.models.core.multi_project import ( # noqa: E402
|
||||
ChangeSetSummary,
|
||||
ProjectScopeResolver,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import ( # noqa: E402
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
_PLAN_ID = "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
def _mock_decision_service() -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.list_by_type.return_value = []
|
||||
return mock
|
||||
|
||||
|
||||
def _build_plan(count: int) -> tuple[Plan, dict[str, list[str]]]:
|
||||
"""Generate a plan with *count* project links and matching resources."""
|
||||
links = [ProjectLink(project_name=f"proj-{i:04d}") for i in range(count)]
|
||||
resources = {
|
||||
f"proj-{i:04d}": [f"res-{i}-{j}" for j in range(5)] for i in range(count)
|
||||
}
|
||||
plan = Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ID),
|
||||
namespaced_name=NamespacedName.parse("local/bench-mp"),
|
||||
description="Benchmark multi-project plan",
|
||||
action_name="local/bench-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
project_links=links,
|
||||
)
|
||||
return plan, resources
|
||||
|
||||
|
||||
class MultiProjectSmallSuite:
|
||||
"""Benchmark multi-project operations with 3 projects."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
self._resolver = ProjectScopeResolver()
|
||||
self._plan, self._resources = _build_plan(3)
|
||||
self._initialized = self._svc.initialize_scopes(self._plan, self._resources)
|
||||
|
||||
def time_initialize_scopes(self) -> None:
|
||||
"""Initialize scopes for 3 projects."""
|
||||
self._svc.initialize_scopes(self._plan, self._resources)
|
||||
|
||||
def time_resolve_alias_by_name(self) -> None:
|
||||
"""Resolve a project by name."""
|
||||
self._resolver.resolve_alias(self._plan.project_links, "proj-0001")
|
||||
|
||||
def time_validate_access_modes(self) -> None:
|
||||
"""Validate access modes for 3 projects."""
|
||||
self._resolver.validate_access_modes(
|
||||
self._plan.project_links, allow_mixed=False
|
||||
)
|
||||
|
||||
def time_record_changeset(self) -> None:
|
||||
"""Record a changeset summary."""
|
||||
summary = ChangeSetSummary(
|
||||
project_name="proj-0000",
|
||||
files_changed=5,
|
||||
total_lines_changed=100,
|
||||
)
|
||||
self._svc.record_changeset(self._initialized, "proj-0000", summary)
|
||||
|
||||
def time_validate_cross_project(self) -> None:
|
||||
"""Validate cross-project constraints."""
|
||||
self._svc.validate_cross_project(self._initialized)
|
||||
|
||||
|
||||
class MultiProjectMediumSuite:
|
||||
"""Benchmark multi-project operations with 20 projects."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
self._resolver = ProjectScopeResolver()
|
||||
self._plan, self._resources = _build_plan(20)
|
||||
self._initialized = self._svc.initialize_scopes(self._plan, self._resources)
|
||||
|
||||
def time_initialize_scopes(self) -> None:
|
||||
"""Initialize scopes for 20 projects."""
|
||||
self._svc.initialize_scopes(self._plan, self._resources)
|
||||
|
||||
def time_resolve_alias_last(self) -> None:
|
||||
"""Resolve the last project by name (worst case)."""
|
||||
self._resolver.resolve_alias(self._plan.project_links, "proj-0019")
|
||||
|
||||
def time_validate_cross_project(self) -> None:
|
||||
"""Validate cross-project constraints for 20 projects."""
|
||||
self._svc.validate_cross_project(self._initialized)
|
||||
|
||||
|
||||
class MultiProjectLargeSuite:
|
||||
"""Benchmark multi-project operations with 50 projects."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
self._plan, self._resources = _build_plan(50)
|
||||
|
||||
def time_initialize_scopes(self) -> None:
|
||||
"""Initialize scopes for 50 projects."""
|
||||
self._svc.initialize_scopes(self._plan, self._resources)
|
||||
|
||||
def time_get_project_scope(self) -> None:
|
||||
"""Look up a project scope after initialization."""
|
||||
initialized = self._svc.initialize_scopes(self._plan, self._resources)
|
||||
initialized.get_project_scope("proj-0025")
|
||||
@@ -0,0 +1,115 @@
|
||||
# Multi-Project Plans
|
||||
|
||||
## Overview
|
||||
|
||||
CleverAgents supports plans that target multiple projects simultaneously.
|
||||
When a plan is created with more than one `--project` flag (or multiple
|
||||
positional project arguments), a **multi-project plan** is formed.
|
||||
|
||||
Each project receives an isolated **ProjectScope** that tracks:
|
||||
|
||||
- Which resource IDs belong to the project
|
||||
- Whether the project is read-only or writable
|
||||
- Per-project changeset summaries during execution
|
||||
|
||||
## Creating a Multi-Project Plan
|
||||
|
||||
```bash
|
||||
agents plan use local/refactor-api \
|
||||
--project api-service \
|
||||
--project shared-lib \
|
||||
--arg target_coverage=80
|
||||
```
|
||||
|
||||
## Alias Resolution
|
||||
|
||||
Projects can be given aliases for easier reference in multi-project
|
||||
contexts:
|
||||
|
||||
```bash
|
||||
agents plan use local/migrate-schema \
|
||||
api-service@api \
|
||||
shared-lib@lib
|
||||
```
|
||||
|
||||
The `ProjectScopeResolver.resolve_alias()` method searches aliases first,
|
||||
then falls back to project names.
|
||||
|
||||
## Scope Isolation
|
||||
|
||||
Each project scope is independently tracked:
|
||||
|
||||
| Field | Description |
|
||||
|---------------------|---------------------------------------------|
|
||||
| `project_name` | Namespaced project identifier |
|
||||
| `alias` | Optional short name for quick reference |
|
||||
| `read_only` | Whether mutations are forbidden |
|
||||
| `resource_ids` | Resources belonging to this project |
|
||||
| `changeset_summary` | Per-project file change statistics |
|
||||
|
||||
## Mixed Access Enforcement
|
||||
|
||||
By default, plans with mixed read-only and writable projects are
|
||||
flagged with a warning. To explicitly allow mixed access, set
|
||||
`allow_mixed_access=True` on the `MultiProjectMetadata`.
|
||||
|
||||
When `allow_mixed_access` is `False` (the default), validation returns
|
||||
an error listing the read-only and writable projects.
|
||||
|
||||
## Cross-Project Dependencies
|
||||
|
||||
The `CrossProjectDependency` model captures directional dependencies
|
||||
between projects within a plan:
|
||||
|
||||
```
|
||||
source_project -> target_project (dependency_type)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `api-service -> shared-lib` (imports)
|
||||
- `frontend -> api-service` (api-consumer)
|
||||
|
||||
## Per-Project Changeset Summaries
|
||||
|
||||
During execution, each project scope records a `ChangeSetSummary`:
|
||||
|
||||
| Field | Description |
|
||||
|----------------------|---------------------------------------|
|
||||
| `files_changed` | Number of modified files |
|
||||
| `files_added` | Number of created files |
|
||||
| `files_deleted` | Number of deleted files |
|
||||
| `total_lines_changed`| Total lines added + removed |
|
||||
| `validation_passed` | Per-project validation result |
|
||||
| `validation_errors` | Error messages from validation |
|
||||
|
||||
These summaries appear in `agents plan status` output and in the
|
||||
`as_cli_dict()` representation under `multi_project.project_scopes`.
|
||||
|
||||
## Domain Models
|
||||
|
||||
| Class | Module |
|
||||
|---------------------------|-------------------------------------------------|
|
||||
| `ProjectScope` | `cleveragents.domain.models.core.multi_project` |
|
||||
| `ChangeSetSummary` | `cleveragents.domain.models.core.multi_project` |
|
||||
| `MultiProjectMetadata` | `cleveragents.domain.models.core.multi_project` |
|
||||
| `CrossProjectDependency` | `cleveragents.domain.models.core.multi_project` |
|
||||
| `ProjectScopeResolver` | `cleveragents.domain.models.core.multi_project` |
|
||||
|
||||
## Service Layer
|
||||
|
||||
`MultiProjectService` (in `cleveragents.application.services.multi_project_service`)
|
||||
provides:
|
||||
|
||||
- `initialize_scopes(plan, available_resources)` -- Creates metadata
|
||||
- `resolve_context_view(plan, name_or_alias)` -- Returns a ProjectScope
|
||||
- `record_changeset(plan, project_name, summary)` -- Records changes
|
||||
- `validate_cross_project(plan)` -- Validates constraints
|
||||
|
||||
## Plan Model Extensions
|
||||
|
||||
The `Plan` model gains:
|
||||
|
||||
- `multi_project_metadata: MultiProjectMetadata | None` field
|
||||
- `is_multi_project: bool` computed property
|
||||
- `get_project_scope(name_or_alias)` method
|
||||
- Extended `as_cli_dict()` with multi-project section
|
||||
@@ -51,12 +51,12 @@ Feature: Core system commands (version, info, diagnostics)
|
||||
Then the system info json output should have key "version" with value "1.0.0"
|
||||
And the system info json output should have key "data_dir"
|
||||
And the system info json output should have key "database"
|
||||
And the system info json output should have key "server_mode" with value "disabled"
|
||||
And the system info json output should have key "server_mode"
|
||||
|
||||
Scenario: Info command with plain format
|
||||
When I run the system info command with format "plain"
|
||||
Then the system info output should contain "version: 1.0.0"
|
||||
And the system info output should contain "server_mode: disabled"
|
||||
And the system info output should contain "server_mode:"
|
||||
|
||||
Scenario: Info command shows provider count
|
||||
When I run the system info command with format "json"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
Feature: Multi-project subplan support
|
||||
As a plan orchestrator
|
||||
I want to manage plans that target multiple projects
|
||||
So that cross-project refactoring and migration plans are supported
|
||||
|
||||
Background:
|
||||
Given a multi-project service
|
||||
And a plan with projects "api-service" and "shared-lib"
|
||||
|
||||
# --- scope initialization ------------------------------------------------
|
||||
|
||||
Scenario: Initialize project scopes from project links
|
||||
Given available resources for "api-service" are "res-api-1,res-api-2"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
Then the plan should have 2 project scopes
|
||||
And scope "api-service" should have 2 resource IDs
|
||||
And scope "shared-lib" should have 1 resource IDs
|
||||
|
||||
Scenario: Initialize scopes with no available resources
|
||||
Given no available resources
|
||||
When I initialize multi-project scopes
|
||||
Then the plan should have 2 project scopes
|
||||
And scope "api-service" should have 0 resource IDs
|
||||
|
||||
Scenario: Plan is_multi_project returns true for multiple projects
|
||||
Then the plan should be multi-project
|
||||
|
||||
Scenario: Single-project plan is not multi-project
|
||||
Given a plan with only project "api-service"
|
||||
Then the plan should not be multi-project
|
||||
|
||||
# --- alias resolution ---------------------------------------------------
|
||||
|
||||
Scenario: Resolve project by alias
|
||||
Given a plan with project "api-service" aliased as "api"
|
||||
And available resources for "api-service" are "res-api-1"
|
||||
When I initialize multi-project scopes
|
||||
Then resolving alias "api" should return project "api-service"
|
||||
|
||||
Scenario: Resolve project by name when no alias matches
|
||||
Then resolving alias "shared-lib" should return project "shared-lib"
|
||||
|
||||
Scenario: Resolve unknown alias returns None
|
||||
Then resolving alias "nonexistent" should return nothing
|
||||
|
||||
# --- context views -------------------------------------------------------
|
||||
|
||||
Scenario: Resolve context view for a known project
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
Then resolving context view for "api-service" should succeed
|
||||
And the context view should have project name "api-service"
|
||||
|
||||
Scenario: Resolve context view for unknown project raises error
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
Then resolving context view for "nonexistent" should raise KeyError
|
||||
|
||||
Scenario: Resolve context view without initialization raises error
|
||||
Then resolving context view without metadata should raise ValueError
|
||||
|
||||
# --- mixed read-only/write enforcement -----------------------------------
|
||||
|
||||
Scenario: Mixed access modes detected without opt-in
|
||||
Given a plan with "api-service" writable and "shared-lib" read-only
|
||||
When I validate access modes without allow_mixed
|
||||
Then there should be 1 validation error about mixed access
|
||||
|
||||
Scenario: Mixed access modes allowed with opt-in
|
||||
Given a plan with "api-service" writable and "shared-lib" read-only
|
||||
When I validate access modes with allow_mixed
|
||||
Then there should be 0 validation errors
|
||||
|
||||
Scenario: Uniform access modes pass validation
|
||||
When I validate access modes without allow_mixed
|
||||
Then there should be 0 validation errors
|
||||
|
||||
# --- cross-project dependency tracking -----------------------------------
|
||||
|
||||
Scenario: Cross-project dependencies stored on metadata
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
And I add a cross-project dependency from "api-service" to "shared-lib" type "imports"
|
||||
Then the metadata should have 1 cross-project dependency
|
||||
And the dependency should be from "api-service" to "shared-lib"
|
||||
|
||||
Scenario: Validate unknown project in cross-project dependency
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
And I add a cross-project dependency from "api-service" to "unknown-project" type "imports"
|
||||
Then cross-project validation should report an error about unknown project
|
||||
|
||||
# --- per-project changeset summaries ------------------------------------
|
||||
|
||||
Scenario: Record changeset summary for a project
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
And I record a changeset for "api-service" with 3 files changed and 10 lines
|
||||
Then scope "api-service" should have a changeset summary
|
||||
And the changeset should show 3 files changed
|
||||
|
||||
Scenario: Record changeset for unknown project raises error
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
Then recording a changeset for "nonexistent" should raise KeyError
|
||||
|
||||
# --- sandbox isolation ---------------------------------------------------
|
||||
|
||||
Scenario: Read-only project with modifications fails validation
|
||||
Given a plan with "api-service" writable and "shared-lib" read-only
|
||||
And available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes with allow_mixed
|
||||
And I record a changeset for "shared-lib" with 2 files changed and 5 lines
|
||||
Then cross-project validation should report sandbox isolation violation
|
||||
|
||||
Scenario: Read-only project with no modifications passes validation
|
||||
Given a plan with "api-service" writable and "shared-lib" read-only
|
||||
And available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes with allow_mixed
|
||||
Then cross-project validation should pass
|
||||
|
||||
# --- CLI dict integration ------------------------------------------------
|
||||
|
||||
Scenario: Plan as_cli_dict includes multi-project section
|
||||
Given available resources for "api-service" are "res-api-1"
|
||||
And available resources for "shared-lib" are "res-lib-1"
|
||||
When I initialize multi-project scopes
|
||||
Then the plan cli dict should contain a "multi_project" key
|
||||
And the multi_project section should have 2 project scopes
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Step definitions for the multi-project subplan feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.domain.models.core.multi_project import (
|
||||
ChangeSetSummary,
|
||||
CrossProjectDependency,
|
||||
ProjectScopeResolver,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
_PLAN_ID = "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
def _make_plan(project_links: list[ProjectLink]) -> Plan:
|
||||
"""Build a minimal Plan with the given project links."""
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ID),
|
||||
namespaced_name=NamespacedName.parse("local/multi-test"),
|
||||
description="Multi-project test plan",
|
||||
action_name="local/test-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
project_links=project_links,
|
||||
)
|
||||
|
||||
|
||||
def _mock_decision_service() -> MagicMock:
|
||||
"""Create a mock DecisionService."""
|
||||
mock = MagicMock()
|
||||
mock.list_by_type.return_value = []
|
||||
return mock
|
||||
|
||||
|
||||
# --- Background ------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a multi-project service")
|
||||
def step_create_service(context: Context) -> None:
|
||||
context.decision_service = _mock_decision_service()
|
||||
context.service = MultiProjectService(
|
||||
decision_service=context.decision_service,
|
||||
)
|
||||
context.resolver = ProjectScopeResolver()
|
||||
context.available_resources: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
@given('a plan with projects "{proj1}" and "{proj2}"')
|
||||
def step_plan_two_projects(context: Context, proj1: str, proj2: str) -> None:
|
||||
context.project_links = [
|
||||
ProjectLink(project_name=proj1),
|
||||
ProjectLink(project_name=proj2),
|
||||
]
|
||||
context.plan = _make_plan(context.project_links)
|
||||
|
||||
|
||||
@given('a plan with only project "{proj}"')
|
||||
def step_plan_single_project(context: Context, proj: str) -> None:
|
||||
context.project_links = [ProjectLink(project_name=proj)]
|
||||
context.plan = _make_plan(context.project_links)
|
||||
|
||||
|
||||
@given('a plan with project "{proj}" aliased as "{alias}"')
|
||||
def step_plan_aliased(context: Context, proj: str, alias: str) -> None:
|
||||
link = ProjectLink(project_name=proj, alias=alias)
|
||||
# Keep shared-lib from background
|
||||
other_links = [pl for pl in context.project_links if pl.project_name != proj]
|
||||
context.project_links = [link, *other_links]
|
||||
context.plan = _make_plan(context.project_links)
|
||||
|
||||
|
||||
@given('a plan with "{rw}" writable and "{ro}" read-only')
|
||||
def step_plan_mixed_access(context: Context, rw: str, ro: str) -> None:
|
||||
context.project_links = [
|
||||
ProjectLink(project_name=rw, read_only=False),
|
||||
ProjectLink(project_name=ro, read_only=True),
|
||||
]
|
||||
context.plan = _make_plan(context.project_links)
|
||||
|
||||
|
||||
# --- Resource setup --------------------------------------------------------
|
||||
|
||||
|
||||
@given('available resources for "{proj}" are "{resource_csv}"')
|
||||
def step_set_resources(context: Context, proj: str, resource_csv: str) -> None:
|
||||
context.available_resources[proj] = [
|
||||
r.strip() for r in resource_csv.split(",") if r.strip()
|
||||
]
|
||||
|
||||
|
||||
@given("no available resources")
|
||||
def step_no_resources(context: Context) -> None:
|
||||
context.available_resources = {}
|
||||
|
||||
|
||||
# --- When clauses ----------------------------------------------------------
|
||||
|
||||
|
||||
@when("I initialize multi-project scopes")
|
||||
def step_initialize_scopes(context: Context) -> None:
|
||||
context.plan = context.service.initialize_scopes(
|
||||
context.plan, context.available_resources
|
||||
)
|
||||
|
||||
|
||||
@when("I initialize multi-project scopes with allow_mixed")
|
||||
def step_initialize_scopes_mixed(context: Context) -> None:
|
||||
context.plan = context.service.initialize_scopes(
|
||||
context.plan, context.available_resources
|
||||
)
|
||||
# Override allow_mixed on the metadata
|
||||
if context.plan.multi_project_metadata is not None:
|
||||
updated_mp = context.plan.multi_project_metadata.model_copy(
|
||||
update={"allow_mixed_access": True}
|
||||
)
|
||||
context.plan = context.plan.model_copy(
|
||||
update={"multi_project_metadata": updated_mp}
|
||||
)
|
||||
|
||||
|
||||
@when("I validate access modes without allow_mixed")
|
||||
def step_validate_access_no_mixed(context: Context) -> None:
|
||||
context.access_errors = context.resolver.validate_access_modes(
|
||||
context.project_links, allow_mixed=False
|
||||
)
|
||||
|
||||
|
||||
@when("I validate access modes with allow_mixed")
|
||||
def step_validate_access_mixed(context: Context) -> None:
|
||||
context.access_errors = context.resolver.validate_access_modes(
|
||||
context.project_links, allow_mixed=True
|
||||
)
|
||||
|
||||
|
||||
@when('I add a cross-project dependency from "{src}" to "{tgt}" type "{dep_type}"')
|
||||
def step_add_dependency(context: Context, src: str, tgt: str, dep_type: str) -> None:
|
||||
dep = CrossProjectDependency(
|
||||
source_project=src,
|
||||
target_project=tgt,
|
||||
dependency_type=dep_type,
|
||||
)
|
||||
mp = context.plan.multi_project_metadata
|
||||
assert mp is not None
|
||||
updated_deps = [*list(mp.cross_project_dependencies), dep]
|
||||
updated_mp = mp.model_copy(update={"cross_project_dependencies": updated_deps})
|
||||
context.plan = context.plan.model_copy(
|
||||
update={"multi_project_metadata": updated_mp}
|
||||
)
|
||||
|
||||
|
||||
@when('I record a changeset for "{proj}" with {n:d} files changed and {lines:d} lines')
|
||||
def step_record_changeset(context: Context, proj: str, n: int, lines: int) -> None:
|
||||
summary = ChangeSetSummary(
|
||||
project_name=proj,
|
||||
files_changed=n,
|
||||
total_lines_changed=lines,
|
||||
)
|
||||
context.plan = context.service.record_changeset(context.plan, proj, summary)
|
||||
|
||||
|
||||
# --- Then clauses ----------------------------------------------------------
|
||||
|
||||
|
||||
@then("the plan should have {count:d} project scopes")
|
||||
def step_check_scope_count(context: Context, count: int) -> None:
|
||||
mp = context.plan.multi_project_metadata
|
||||
assert mp is not None, "multi_project_metadata is None"
|
||||
assert len(mp.project_scopes) == count, (
|
||||
f"Expected {count} scopes, got {len(mp.project_scopes)}"
|
||||
)
|
||||
|
||||
|
||||
@then('scope "{proj}" should have {count:d} resource IDs')
|
||||
def step_check_scope_resources(context: Context, proj: str, count: int) -> None:
|
||||
scope = context.plan.get_project_scope(proj)
|
||||
assert scope is not None, f"Scope for {proj} not found"
|
||||
assert len(scope.resource_ids) == count, (
|
||||
f"Expected {count} resources for {proj}, got {len(scope.resource_ids)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan should be multi-project")
|
||||
def step_is_multi_project(context: Context) -> None:
|
||||
assert context.plan.is_multi_project is True
|
||||
|
||||
|
||||
@then("the plan should not be multi-project")
|
||||
def step_is_not_multi_project(context: Context) -> None:
|
||||
assert context.plan.is_multi_project is False
|
||||
|
||||
|
||||
@then('resolving alias "{alias}" should return project "{proj}"')
|
||||
def step_resolve_alias(context: Context, alias: str, proj: str) -> None:
|
||||
link = context.resolver.resolve_alias(context.plan.project_links, alias)
|
||||
assert link is not None, f"Alias '{alias}' not resolved"
|
||||
assert link.project_name == proj
|
||||
|
||||
|
||||
@then('resolving alias "{alias}" should return nothing')
|
||||
def step_resolve_alias_none(context: Context, alias: str) -> None:
|
||||
link = context.resolver.resolve_alias(context.plan.project_links, alias)
|
||||
assert link is None, f"Expected None, got {link}"
|
||||
|
||||
|
||||
@then('resolving context view for "{proj}" should succeed')
|
||||
def step_context_view_success(context: Context, proj: str) -> None:
|
||||
context.resolved_scope = context.service.resolve_context_view(context.plan, proj)
|
||||
assert context.resolved_scope is not None
|
||||
|
||||
|
||||
@then('the context view should have project name "{proj}"')
|
||||
def step_context_view_name(context: Context, proj: str) -> None:
|
||||
assert context.resolved_scope.project_name == proj
|
||||
|
||||
|
||||
@then('resolving context view for "{proj}" should raise KeyError')
|
||||
def step_context_view_key_error(context: Context, proj: str) -> None:
|
||||
try:
|
||||
context.service.resolve_context_view(context.plan, proj)
|
||||
raise AssertionError("Expected KeyError")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
@then("resolving context view without metadata should raise ValueError")
|
||||
def step_context_view_no_metadata(context: Context) -> None:
|
||||
# Use a plan without multi_project_metadata
|
||||
bare_plan = _make_plan(context.project_links)
|
||||
try:
|
||||
context.service.resolve_context_view(bare_plan, "api-service")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("there should be {count:d} validation error about mixed access")
|
||||
def step_check_mixed_error_count(context: Context, count: int) -> None:
|
||||
assert len(context.access_errors) == count, (
|
||||
f"Expected {count} errors, got {len(context.access_errors)}"
|
||||
)
|
||||
|
||||
|
||||
@then("there should be {count:d} validation errors")
|
||||
def step_check_error_count(context: Context, count: int) -> None:
|
||||
assert len(context.access_errors) == count, (
|
||||
f"Expected {count} errors, got {len(context.access_errors)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the metadata should have {count:d} cross-project dependency")
|
||||
def step_check_dep_count(context: Context, count: int) -> None:
|
||||
mp = context.plan.multi_project_metadata
|
||||
assert mp is not None
|
||||
assert len(mp.cross_project_dependencies) == count
|
||||
|
||||
|
||||
@then('the dependency should be from "{src}" to "{tgt}"')
|
||||
def step_check_dep_endpoints(context: Context, src: str, tgt: str) -> None:
|
||||
mp = context.plan.multi_project_metadata
|
||||
assert mp is not None
|
||||
dep = mp.cross_project_dependencies[0]
|
||||
assert dep.source_project == src
|
||||
assert dep.target_project == tgt
|
||||
|
||||
|
||||
@then("cross-project validation should report an error about unknown project")
|
||||
def step_cross_project_unknown(context: Context) -> None:
|
||||
errors = context.service.validate_cross_project(context.plan)
|
||||
assert any("unknown" in e.lower() for e in errors), (
|
||||
f"Expected unknown project error, got: {errors}"
|
||||
)
|
||||
|
||||
|
||||
@then('scope "{proj}" should have a changeset summary')
|
||||
def step_scope_has_changeset(context: Context, proj: str) -> None:
|
||||
scope = context.plan.get_project_scope(proj)
|
||||
assert scope is not None
|
||||
assert scope.changeset_summary is not None
|
||||
|
||||
|
||||
@then("the changeset should show {count:d} files changed")
|
||||
def step_changeset_files(context: Context, count: int) -> None:
|
||||
# Find the scope with a changeset
|
||||
mp = context.plan.multi_project_metadata
|
||||
assert mp is not None
|
||||
for scope in mp.project_scopes:
|
||||
if scope.changeset_summary is not None:
|
||||
assert scope.changeset_summary.files_changed == count
|
||||
return
|
||||
raise AssertionError("No changeset summary found")
|
||||
|
||||
|
||||
@then('recording a changeset for "{proj}" should raise KeyError')
|
||||
def step_record_changeset_error(context: Context, proj: str) -> None:
|
||||
summary = ChangeSetSummary(
|
||||
project_name=proj,
|
||||
files_changed=1,
|
||||
total_lines_changed=1,
|
||||
)
|
||||
try:
|
||||
context.service.record_changeset(context.plan, proj, summary)
|
||||
raise AssertionError("Expected KeyError")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
@then("cross-project validation should report sandbox isolation violation")
|
||||
def step_sandbox_violation(context: Context) -> None:
|
||||
errors = context.service.validate_cross_project(context.plan)
|
||||
assert any("sandbox isolation" in e.lower() for e in errors), (
|
||||
f"Expected sandbox isolation error, got: {errors}"
|
||||
)
|
||||
|
||||
|
||||
@then("cross-project validation should pass")
|
||||
def step_cross_project_valid(context: Context) -> None:
|
||||
errors = context.service.validate_cross_project(context.plan)
|
||||
assert len(errors) == 0, f"Expected no errors, got: {errors}"
|
||||
|
||||
|
||||
@then('the plan cli dict should contain a "{key}" key')
|
||||
def step_cli_dict_key(context: Context, key: str) -> None:
|
||||
cli_dict = context.plan.as_cli_dict()
|
||||
assert key in cli_dict, f"Key '{key}' not in cli_dict: {list(cli_dict.keys())}"
|
||||
|
||||
|
||||
@then("the multi_project section should have {count:d} project scopes")
|
||||
def step_cli_dict_scope_count(context: Context, count: int) -> None:
|
||||
cli_dict = context.plan.as_cli_dict()
|
||||
mp = cli_dict["multi_project"]
|
||||
assert len(mp["project_scopes"]) == count
|
||||
@@ -59,14 +59,14 @@ Info Command JSON Format
|
||||
Should Contain ${result.stdout} "version": "1.0.0"
|
||||
Should Contain ${result.stdout} "data_dir"
|
||||
Should Contain ${result.stdout} "database"
|
||||
Should Contain ${result.stdout} "server_mode": "disabled"
|
||||
Should Contain ${result.stdout} "server_mode"
|
||||
|
||||
Info Command Plain Format
|
||||
[Documentation] Info command with --format plain returns key-value pairs
|
||||
${result}= Run Process ${PYTHON} -m cleveragents info --format plain timeout=60s
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} version: 1.0.0
|
||||
Should Contain ${result.stdout} server_mode: disabled
|
||||
Should Contain ${result.stdout} server_mode:
|
||||
|
||||
Diagnostics Command Default Rich Format
|
||||
[Documentation] Diagnostics command with default (rich) format runs checks
|
||||
|
||||
@@ -11,7 +11,7 @@ Test Timeout 300 seconds
|
||||
*** Variables ***
|
||||
# ${PYTHON} will be set in Setup Test Environment from common.resource
|
||||
# Make sure to call Setup Test Environment before using ${PYTHON}
|
||||
${TEST_DIR} ${TEMPDIR}${/}cleveragents_plan_context_test_${SUITE NAME}
|
||||
${TEST_DIR} ${TEMPDIR}${/}cleveragents_plan_ctx_test
|
||||
${PROJECT_NAME} test-project
|
||||
|
||||
*** Test Cases ***
|
||||
|
||||
@@ -74,14 +74,19 @@ Test Context Add Files
|
||||
|
||||
Test Context List Files
|
||||
[Documentation] Test listing context files
|
||||
Create Directory ${TEST_DIR}/project5
|
||||
Run Process ${PYTHON} -m cleveragents init ${PROJECT_NAME} cwd=${TEST_DIR}/project5 timeout=120s
|
||||
Create File ${TEST_DIR}/project5/file1.py # File 1
|
||||
Create File ${TEST_DIR}/project5/file2.py # File 2
|
||||
Run Process ${PYTHON} -m cleveragents context add file1.py file2.py
|
||||
... cwd=${TEST_DIR}/project5 timeout=120s
|
||||
${unique_dir} = Set Variable ${TEST_DIR}/project5_${TEST NAME.replace(' ', '_')}
|
||||
Create Directory ${unique_dir}
|
||||
${init_result} = Run Process ${PYTHON} -m cleveragents init ${PROJECT_NAME}
|
||||
... cwd=${unique_dir} timeout=120s
|
||||
Should Be Equal As Numbers ${init_result.rc} 0
|
||||
Create File ${unique_dir}/file1.py # File 1
|
||||
Create File ${unique_dir}/file2.py # File 2
|
||||
${add_result} = Run Process ${PYTHON} -m cleveragents context add file1.py file2.py
|
||||
... cwd=${unique_dir} timeout=120s
|
||||
Should Be Equal As Numbers ${add_result.rc} 0
|
||||
Directory Should Exist ${unique_dir}
|
||||
${result} = Run Process ${PYTHON} -m cleveragents context list
|
||||
... cwd=${TEST_DIR}/project5 timeout=120s
|
||||
... cwd=${unique_dir} timeout=120s
|
||||
Should Be Equal As Numbers ${result.rc} 0
|
||||
Should Contain ${result.stdout} file1.py
|
||||
Should Contain ${result.stdout} file2.py
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Helper script for multi-project subplan Robot Framework tests.
|
||||
|
||||
Usage:
|
||||
python helper_multi_project_subplan.py init-scopes
|
||||
python helper_multi_project_subplan.py resolve-alias
|
||||
python helper_multi_project_subplan.py validate-mixed
|
||||
python helper_multi_project_subplan.py record-changeset
|
||||
python helper_multi_project_subplan.py validate-cross
|
||||
python helper_multi_project_subplan.py cli-dict
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Ensure source tree is importable
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.multi_project_service import ( # noqa: E402
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.domain.models.core.multi_project import ( # noqa: E402
|
||||
ChangeSetSummary,
|
||||
CrossProjectDependency,
|
||||
ProjectScopeResolver,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import ( # noqa: E402
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
_PLAN_ID = "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
def _make_plan(links: list[ProjectLink]) -> Plan:
|
||||
"""Build a minimal Plan."""
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ID),
|
||||
namespaced_name=NamespacedName.parse("local/robot-mp-test"),
|
||||
description="Robot multi-project test",
|
||||
action_name="local/test-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
project_links=links,
|
||||
)
|
||||
|
||||
|
||||
def _mock_decision_service() -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.list_by_type.return_value = []
|
||||
return mock
|
||||
|
||||
|
||||
def cmd_init_scopes() -> None:
|
||||
"""Initialize scopes and verify project count."""
|
||||
svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
plan = _make_plan(
|
||||
[
|
||||
ProjectLink(project_name="api-service"),
|
||||
ProjectLink(project_name="shared-lib"),
|
||||
]
|
||||
)
|
||||
resources = {
|
||||
"api-service": ["res-api-1", "res-api-2"],
|
||||
"shared-lib": ["res-lib-1"],
|
||||
}
|
||||
updated = svc.initialize_scopes(plan, resources)
|
||||
mp = updated.multi_project_metadata
|
||||
assert mp is not None
|
||||
assert len(mp.project_scopes) == 2
|
||||
api_scope = updated.get_project_scope("api-service")
|
||||
assert api_scope is not None
|
||||
assert len(api_scope.resource_ids) == 2
|
||||
print("multi-project-init-ok")
|
||||
print(f"scope_count={len(mp.project_scopes)}")
|
||||
|
||||
|
||||
def cmd_resolve_alias() -> None:
|
||||
"""Resolve a project alias."""
|
||||
resolver = ProjectScopeResolver()
|
||||
links = [
|
||||
ProjectLink(project_name="api-service", alias="api"),
|
||||
ProjectLink(project_name="shared-lib"),
|
||||
]
|
||||
link = resolver.resolve_alias(links, "api")
|
||||
assert link is not None
|
||||
assert link.project_name == "api-service"
|
||||
|
||||
link2 = resolver.resolve_alias(links, "shared-lib")
|
||||
assert link2 is not None
|
||||
assert link2.project_name == "shared-lib"
|
||||
|
||||
link3 = resolver.resolve_alias(links, "nonexistent")
|
||||
assert link3 is None
|
||||
print("multi-project-alias-ok")
|
||||
|
||||
|
||||
def cmd_validate_mixed() -> None:
|
||||
"""Validate mixed access mode detection."""
|
||||
resolver = ProjectScopeResolver()
|
||||
links = [
|
||||
ProjectLink(project_name="api-service", read_only=False),
|
||||
ProjectLink(project_name="shared-lib", read_only=True),
|
||||
]
|
||||
|
||||
errors = resolver.validate_access_modes(links, allow_mixed=False)
|
||||
assert len(errors) == 1, f"Expected 1 error, got {len(errors)}"
|
||||
|
||||
errors2 = resolver.validate_access_modes(links, allow_mixed=True)
|
||||
assert len(errors2) == 0, f"Expected 0 errors, got {len(errors2)}"
|
||||
|
||||
print("multi-project-mixed-ok")
|
||||
|
||||
|
||||
def cmd_record_changeset() -> None:
|
||||
"""Record a changeset and verify it."""
|
||||
svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
plan = _make_plan(
|
||||
[
|
||||
ProjectLink(project_name="api-service"),
|
||||
ProjectLink(project_name="shared-lib"),
|
||||
]
|
||||
)
|
||||
plan = svc.initialize_scopes(
|
||||
plan,
|
||||
{
|
||||
"api-service": ["res-1"],
|
||||
"shared-lib": ["res-2"],
|
||||
},
|
||||
)
|
||||
|
||||
summary = ChangeSetSummary(
|
||||
project_name="api-service",
|
||||
files_changed=5,
|
||||
files_added=2,
|
||||
total_lines_changed=120,
|
||||
)
|
||||
plan = svc.record_changeset(plan, "api-service", summary)
|
||||
|
||||
scope = plan.get_project_scope("api-service")
|
||||
assert scope is not None
|
||||
assert scope.changeset_summary is not None
|
||||
assert scope.changeset_summary.files_changed == 5
|
||||
print("multi-project-changeset-ok")
|
||||
|
||||
|
||||
def cmd_validate_cross() -> None:
|
||||
"""Validate cross-project dependency constraints."""
|
||||
svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
plan = _make_plan(
|
||||
[
|
||||
ProjectLink(project_name="api-service"),
|
||||
ProjectLink(project_name="shared-lib"),
|
||||
]
|
||||
)
|
||||
plan = svc.initialize_scopes(
|
||||
plan,
|
||||
{
|
||||
"api-service": ["res-1"],
|
||||
"shared-lib": ["res-2"],
|
||||
},
|
||||
)
|
||||
|
||||
# Valid dependency
|
||||
dep = CrossProjectDependency(
|
||||
source_project="api-service",
|
||||
target_project="shared-lib",
|
||||
dependency_type="imports",
|
||||
)
|
||||
mp = plan.multi_project_metadata
|
||||
assert mp is not None
|
||||
updated_mp = mp.model_copy(update={"cross_project_dependencies": [dep]})
|
||||
plan = plan.model_copy(update={"multi_project_metadata": updated_mp})
|
||||
errors = svc.validate_cross_project(plan)
|
||||
assert len(errors) == 0, f"Expected 0 errors, got: {errors}"
|
||||
|
||||
# Invalid dependency (unknown target)
|
||||
bad_dep = CrossProjectDependency(
|
||||
source_project="api-service",
|
||||
target_project="unknown",
|
||||
dependency_type="imports",
|
||||
)
|
||||
updated_mp2 = mp.model_copy(update={"cross_project_dependencies": [bad_dep]})
|
||||
plan2 = plan.model_copy(update={"multi_project_metadata": updated_mp2})
|
||||
errors2 = svc.validate_cross_project(plan2)
|
||||
assert len(errors2) > 0, "Expected error for unknown project"
|
||||
|
||||
print("multi-project-cross-ok")
|
||||
|
||||
|
||||
def cmd_cli_dict() -> None:
|
||||
"""Verify as_cli_dict includes multi-project section."""
|
||||
svc = MultiProjectService(decision_service=_mock_decision_service())
|
||||
plan = _make_plan(
|
||||
[
|
||||
ProjectLink(project_name="api-service"),
|
||||
ProjectLink(project_name="shared-lib"),
|
||||
]
|
||||
)
|
||||
plan = svc.initialize_scopes(
|
||||
plan,
|
||||
{
|
||||
"api-service": ["res-1"],
|
||||
"shared-lib": ["res-2"],
|
||||
},
|
||||
)
|
||||
|
||||
cli_dict = plan.as_cli_dict()
|
||||
assert "multi_project" in cli_dict
|
||||
mp_section = cli_dict["multi_project"]
|
||||
assert mp_section["total_projects"] == 2
|
||||
assert len(mp_section["project_scopes"]) == 2
|
||||
print("multi-project-cli-dict-ok")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dispatch subcommand."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_multi_project_subplan.py <command>")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
dispatch = {
|
||||
"init-scopes": cmd_init_scopes,
|
||||
"resolve-alias": cmd_resolve_alias,
|
||||
"validate-mixed": cmd_validate_mixed,
|
||||
"record-changeset": cmd_record_changeset,
|
||||
"validate-cross": cmd_validate_cross,
|
||||
"cli-dict": cmd_cli_dict,
|
||||
}
|
||||
|
||||
handler = dispatch.get(cmd)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {cmd}")
|
||||
sys.exit(1)
|
||||
handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -137,13 +137,26 @@ def config_keys() -> None:
|
||||
|
||||
|
||||
def server_mode() -> None:
|
||||
"""Verify resolve_server_mode returns disabled."""
|
||||
# Ensure no server URL is set
|
||||
"""Verify resolve_server_mode returns disabled when no URL is configured."""
|
||||
import tempfile
|
||||
|
||||
os.environ.pop("CLEVERAGENTS_SERVER_URL", None)
|
||||
|
||||
from cleveragents.cli.commands.server import resolve_server_mode
|
||||
# Point HOME to a clean temporary directory so resolve_server_mode
|
||||
# cannot pick up a server.url written by a previous test run.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_home = os.environ.get("HOME")
|
||||
os.environ["HOME"] = tmpdir
|
||||
try:
|
||||
from cleveragents.cli.commands.server import resolve_server_mode
|
||||
|
||||
mode = resolve_server_mode()
|
||||
finally:
|
||||
if old_home is not None:
|
||||
os.environ["HOME"] = old_home
|
||||
else:
|
||||
os.environ.pop("HOME", None)
|
||||
|
||||
mode = resolve_server_mode()
|
||||
if mode != "disabled":
|
||||
print(f"FAIL: expected 'disabled', got '{mode}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for multi-project subplan support
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_multi_project_subplan.py
|
||||
|
||||
*** Test Cases ***
|
||||
Initialize Multi-Project Scopes
|
||||
[Documentation] Initialize project scopes with resource mapping
|
||||
${result}= Run Process ${PYTHON} ${HELPER} init-scopes cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-init-ok
|
||||
|
||||
Resolve Alias
|
||||
[Documentation] Resolve a project alias to its ProjectLink
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-alias cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-alias-ok
|
||||
|
||||
Validate Mixed Access
|
||||
[Documentation] Mixed access modes without opt-in should produce errors
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate-mixed cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-mixed-ok
|
||||
|
||||
Record Changeset
|
||||
[Documentation] Record a per-project changeset summary
|
||||
${result}= Run Process ${PYTHON} ${HELPER} record-changeset cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-changeset-ok
|
||||
|
||||
Cross-Project Validation
|
||||
[Documentation] Validate cross-project constraints
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate-cross cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-cross-ok
|
||||
|
||||
CLI Dict Integration
|
||||
[Documentation] Plan as_cli_dict includes multi-project section
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cli-dict cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multi-project-cli-dict-ok
|
||||
@@ -23,6 +23,9 @@ from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.execution_environment_resolver import (
|
||||
ExecutionEnvironmentResolver,
|
||||
)
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
@@ -287,6 +290,12 @@ class Container(containers.DeclarativeContainer):
|
||||
decision_service=decision_service,
|
||||
)
|
||||
|
||||
# Multi-Project Service - Factory (multi-project scope management)
|
||||
multi_project_service = providers.Factory(
|
||||
MultiProjectService,
|
||||
decision_service=decision_service,
|
||||
)
|
||||
|
||||
# Resource Registry Service - uses database session factory from UoW
|
||||
resource_registry_service = providers.Factory(
|
||||
_build_resource_registry_service,
|
||||
|
||||
@@ -29,6 +29,9 @@ from cleveragents.application.services.execution_environment_resolver import (
|
||||
from cleveragents.application.services.invariant_service import (
|
||||
InvariantService,
|
||||
)
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.application.services.permission_service import (
|
||||
PermissionService,
|
||||
enforce_permission,
|
||||
@@ -138,6 +141,7 @@ __all__ = [
|
||||
"MergeConflictError",
|
||||
"MissingImportRule",
|
||||
"MissingSymbolRule",
|
||||
"MultiProjectService",
|
||||
"NormalisedOutputDict",
|
||||
"PermissionService",
|
||||
"PersistentSessionService",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Multi-project service for plan scope management.
|
||||
|
||||
Coordinates the creation and management of per-project scopes within
|
||||
multi-project plans. Provides methods to:
|
||||
|
||||
- Initialize project scopes from ``ProjectLink`` entries
|
||||
- Resolve scoped context views for actors
|
||||
- Record per-project changeset summaries
|
||||
- Validate cross-project constraints and sandbox isolation
|
||||
|
||||
Design decisions:
|
||||
- Dependency injection: ``DecisionService`` is injected via the
|
||||
constructor (consistent with existing service patterns).
|
||||
- Stateless: All scope state is derived from the ``Plan`` model's
|
||||
``multi_project_metadata`` field.
|
||||
- Immutable plan updates: Methods return a new ``Plan`` instance
|
||||
rather than mutating the input (Pydantic ``model_copy``).
|
||||
|
||||
Based on:
|
||||
- docs/specification.md (multi-project support)
|
||||
- Forgejo issue #199
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cleveragents.domain.models.core.multi_project import (
|
||||
ChangeSetSummary,
|
||||
MultiProjectMetadata,
|
||||
ProjectScope,
|
||||
ProjectScopeResolver,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import Plan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiProjectService:
|
||||
"""Service for managing multi-project plan scopes.
|
||||
|
||||
Coordinates project scope initialization, context resolution,
|
||||
changeset recording, and cross-project validation.
|
||||
|
||||
Args:
|
||||
decision_service: Injected decision service for querying
|
||||
decision records when validating cross-project constraints.
|
||||
|
||||
Raises:
|
||||
ValueError: If *decision_service* is ``None``.
|
||||
"""
|
||||
|
||||
def __init__(self, decision_service: DecisionService) -> None:
|
||||
if decision_service is None:
|
||||
raise ValueError("decision_service must not be None")
|
||||
self._decision_service: DecisionService = decision_service
|
||||
self._resolver: ProjectScopeResolver = ProjectScopeResolver()
|
||||
|
||||
@property
|
||||
def decision_service(self) -> DecisionService:
|
||||
"""The injected decision service."""
|
||||
return self._decision_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# initialize_scopes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def initialize_scopes(
|
||||
self,
|
||||
plan: Plan,
|
||||
available_resources: dict[str, list[str]],
|
||||
) -> Plan:
|
||||
"""Create ``MultiProjectMetadata`` on the plan.
|
||||
|
||||
Builds a ``ProjectScope`` for each project link, populated
|
||||
with matching resource IDs from *available_resources*.
|
||||
|
||||
Args:
|
||||
plan: The plan to initialize scopes on.
|
||||
available_resources: Mapping of project name to resource IDs.
|
||||
|
||||
Returns:
|
||||
A new ``Plan`` instance with ``multi_project_metadata`` set.
|
||||
|
||||
Raises:
|
||||
ValueError: If *plan* or *available_resources* is ``None``.
|
||||
"""
|
||||
if plan is None:
|
||||
raise ValueError("plan must not be None")
|
||||
if available_resources is None:
|
||||
raise ValueError("available_resources must not be None")
|
||||
|
||||
scopes = self._resolver.resolve_scopes(plan.project_links, available_resources)
|
||||
|
||||
# Validate access modes
|
||||
errors = self._resolver.validate_access_modes(
|
||||
plan.project_links,
|
||||
allow_mixed=False,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(
|
||||
"multi_project_mixed_access",
|
||||
extra={
|
||||
"plan_id": plan.identity.plan_id,
|
||||
"warnings": errors,
|
||||
},
|
||||
)
|
||||
|
||||
metadata = MultiProjectMetadata(
|
||||
project_scopes=scopes,
|
||||
cross_project_dependencies=[],
|
||||
allow_mixed_access=False,
|
||||
)
|
||||
|
||||
updated = plan.model_copy(update={"multi_project_metadata": metadata})
|
||||
|
||||
logger.info(
|
||||
"multi_project_scopes_initialized",
|
||||
extra={
|
||||
"plan_id": plan.identity.plan_id,
|
||||
"project_count": len(scopes),
|
||||
},
|
||||
)
|
||||
|
||||
return updated
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# resolve_context_view
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def resolve_context_view(
|
||||
self,
|
||||
plan: Plan,
|
||||
project_name_or_alias: str,
|
||||
) -> ProjectScope:
|
||||
"""Return the scoped view for a specific project.
|
||||
|
||||
Actors call this to understand which resources belong to a
|
||||
specific project and whether access is read-only.
|
||||
|
||||
Args:
|
||||
plan: The plan with initialized multi-project metadata.
|
||||
project_name_or_alias: Project name or alias to resolve.
|
||||
|
||||
Returns:
|
||||
The matching ``ProjectScope``.
|
||||
|
||||
Raises:
|
||||
ValueError: If multi-project metadata is not initialized.
|
||||
KeyError: If no matching project scope is found.
|
||||
"""
|
||||
if plan is None:
|
||||
raise ValueError("plan must not be None")
|
||||
if plan.multi_project_metadata is None:
|
||||
raise ValueError(
|
||||
"Multi-project metadata not initialized. Call initialize_scopes first."
|
||||
)
|
||||
|
||||
scope = plan.get_project_scope(project_name_or_alias)
|
||||
if scope is None:
|
||||
raise KeyError(f"No project scope found for '{project_name_or_alias}'")
|
||||
return scope
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# record_changeset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_changeset(
|
||||
self,
|
||||
plan: Plan,
|
||||
project_name: str,
|
||||
summary: ChangeSetSummary,
|
||||
) -> Plan:
|
||||
"""Record a per-project changeset summary.
|
||||
|
||||
Updates the ``ProjectScope`` matching *project_name* with the
|
||||
provided ``ChangeSetSummary``.
|
||||
|
||||
Args:
|
||||
plan: The plan to update.
|
||||
project_name: The project to record changes for.
|
||||
summary: The changeset summary.
|
||||
|
||||
Returns:
|
||||
A new ``Plan`` instance with the updated scope.
|
||||
|
||||
Raises:
|
||||
ValueError: If multi-project metadata is not initialized.
|
||||
KeyError: If no matching project scope is found.
|
||||
"""
|
||||
if plan is None:
|
||||
raise ValueError("plan must not be None")
|
||||
if plan.multi_project_metadata is None:
|
||||
raise ValueError(
|
||||
"Multi-project metadata not initialized. Call initialize_scopes first."
|
||||
)
|
||||
|
||||
mp = plan.multi_project_metadata
|
||||
updated_scopes: list[ProjectScope] = []
|
||||
found = False
|
||||
|
||||
for scope in mp.project_scopes:
|
||||
if scope.project_name == project_name:
|
||||
updated_scope = scope.model_copy(update={"changeset_summary": summary})
|
||||
updated_scopes.append(updated_scope)
|
||||
found = True
|
||||
else:
|
||||
updated_scopes.append(scope)
|
||||
|
||||
if not found:
|
||||
raise KeyError(f"No project scope found for '{project_name}'")
|
||||
|
||||
updated_metadata = mp.model_copy(update={"project_scopes": updated_scopes})
|
||||
updated_plan = plan.model_copy(
|
||||
update={"multi_project_metadata": updated_metadata}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"multi_project_changeset_recorded",
|
||||
extra={
|
||||
"plan_id": plan.identity.plan_id,
|
||||
"project_name": project_name,
|
||||
"files_changed": summary.files_changed,
|
||||
},
|
||||
)
|
||||
|
||||
return updated_plan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# validate_cross_project
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_cross_project(self, plan: Plan) -> list[str]:
|
||||
"""Validate cross-project constraints and sandbox isolation.
|
||||
|
||||
Checks:
|
||||
1. Access mode consistency (mixed read-only/write without opt-in).
|
||||
2. Read-only projects must not have changeset summaries with
|
||||
modifications.
|
||||
3. Cross-project dependencies reference valid project names.
|
||||
|
||||
Args:
|
||||
plan: The plan to validate.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid).
|
||||
"""
|
||||
if plan is None:
|
||||
return ["plan must not be None"]
|
||||
if plan.multi_project_metadata is None:
|
||||
return [] # Single-project plans are always valid
|
||||
|
||||
errors: list[str] = []
|
||||
mp = plan.multi_project_metadata
|
||||
|
||||
# 1. Access mode validation
|
||||
access_errors = self._resolver.validate_access_modes(
|
||||
plan.project_links,
|
||||
allow_mixed=mp.allow_mixed_access,
|
||||
)
|
||||
errors.extend(access_errors)
|
||||
|
||||
# 2. Read-only sandbox isolation
|
||||
for scope in mp.project_scopes:
|
||||
if scope.read_only and scope.changeset_summary is not None:
|
||||
cs = scope.changeset_summary
|
||||
total_writes = cs.files_changed + cs.files_added + cs.files_deleted
|
||||
if total_writes > 0:
|
||||
errors.append(
|
||||
f"Read-only project '{scope.project_name}' has "
|
||||
f"{total_writes} file modification(s). "
|
||||
f"Sandbox isolation violated."
|
||||
)
|
||||
|
||||
# 3. Cross-project dependency validity
|
||||
known_projects = {s.project_name for s in mp.project_scopes}
|
||||
for dep in mp.cross_project_dependencies:
|
||||
if dep.source_project not in known_projects:
|
||||
errors.append(
|
||||
f"Cross-project dependency references unknown source "
|
||||
f"project '{dep.source_project}'"
|
||||
)
|
||||
if dep.target_project not in known_projects:
|
||||
errors.append(
|
||||
f"Cross-project dependency references unknown target "
|
||||
f"project '{dep.target_project}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"MultiProjectService",
|
||||
]
|
||||
@@ -1217,6 +1217,33 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
|
||||
if plan.last_checkpoint_id:
|
||||
details += f"[bold]Last Checkpoint:[/bold] {plan.last_checkpoint_id}\n"
|
||||
|
||||
# Multi-project changeset summaries (#199)
|
||||
if (
|
||||
plan.multi_project_metadata is not None
|
||||
and plan.multi_project_metadata.project_scopes
|
||||
):
|
||||
details += "[bold]Multi-Project Scopes:[/bold]\n"
|
||||
for scope in plan.multi_project_metadata.project_scopes:
|
||||
label = scope.project_name
|
||||
if scope.alias:
|
||||
label += f" (alias: {scope.alias})"
|
||||
if scope.read_only:
|
||||
label += " [read-only]"
|
||||
details += f" {label}\n"
|
||||
if scope.changeset_summary is not None:
|
||||
cs = scope.changeset_summary
|
||||
details += (
|
||||
f" Changed: {cs.files_changed} "
|
||||
f"Added: {cs.files_added} "
|
||||
f"Deleted: {cs.files_deleted} "
|
||||
f"Lines: {cs.total_lines_changed}"
|
||||
)
|
||||
if cs.validation_passed is not None:
|
||||
v_color = "green" if cs.validation_passed else "red"
|
||||
v_label = "PASSED" if cs.validation_passed else "FAILED"
|
||||
details += f" Validation: [{v_color}]{v_label}[/{v_color}]"
|
||||
details += "\n"
|
||||
|
||||
details += f"[bold]Terminal:[/bold] {'yes' if plan.is_terminal else 'no'}\n"
|
||||
|
||||
# Timestamps
|
||||
|
||||
@@ -106,6 +106,15 @@ from cleveragents.domain.models.core.invariant import (
|
||||
InvariantViolation,
|
||||
merge_invariants,
|
||||
)
|
||||
|
||||
# Multi-project subplan support (#199)
|
||||
from cleveragents.domain.models.core.multi_project import (
|
||||
ChangeSetSummary,
|
||||
CrossProjectDependency,
|
||||
MultiProjectMetadata,
|
||||
ProjectScope,
|
||||
ProjectScopeResolver,
|
||||
)
|
||||
from cleveragents.domain.models.core.org import (
|
||||
CloudBillingFields,
|
||||
CreditsTransaction,
|
||||
@@ -265,6 +274,7 @@ __all__ = [
|
||||
"ChangeOperation",
|
||||
"ChangeSet",
|
||||
"ChangeSetStore",
|
||||
"ChangeSetSummary",
|
||||
"ChangeType",
|
||||
"Checkpoint",
|
||||
"CheckpointMetadata",
|
||||
@@ -287,6 +297,7 @@ __all__ = [
|
||||
"CreditType",
|
||||
"CreditsTransaction",
|
||||
"CreditsTransactionType",
|
||||
"CrossProjectDependency",
|
||||
"DebugAttempt",
|
||||
"DiffBuilder",
|
||||
"DiffEntry",
|
||||
@@ -321,6 +332,7 @@ __all__ = [
|
||||
"LinkedResource",
|
||||
"MaxContextCount",
|
||||
"MessageRole",
|
||||
"MultiProjectMetadata",
|
||||
"NamespacedName",
|
||||
"NamespacedProject",
|
||||
"Operation",
|
||||
@@ -347,6 +359,8 @@ __all__ = [
|
||||
"Project",
|
||||
"ProjectContextPolicy",
|
||||
"ProjectLink",
|
||||
"ProjectScope",
|
||||
"ProjectScopeResolver",
|
||||
"ProjectSettings",
|
||||
"ProjectStats",
|
||||
"RecoveryAction",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Multi-project subplan support for CleverAgents.
|
||||
|
||||
This module provides domain models and a stateless resolver for
|
||||
multi-project plan orchestration. When a plan targets more than one
|
||||
project (via ``project_links``), each project receives a scoped view
|
||||
that tracks:
|
||||
|
||||
- Resource isolation (which resource IDs belong to which project)
|
||||
- Access mode (read-only vs writable)
|
||||
- Per-project changeset summaries
|
||||
- Cross-project dependency metadata
|
||||
|
||||
## Key Classes
|
||||
|
||||
- ``ChangeSetSummary`` -- Per-project changeset tracking
|
||||
- ``ProjectScope`` -- Scoped view of a project within a plan
|
||||
- ``CrossProjectDependency`` -- Edge in the inter-project dependency graph
|
||||
- ``MultiProjectMetadata`` -- Aggregation stored on the Plan model
|
||||
- ``ProjectScopeResolver`` -- Stateless helper for scope creation and
|
||||
alias resolution
|
||||
|
||||
Based on ``docs/specification.md`` and Forgejo issue #199.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.core.plan import ProjectLink
|
||||
|
||||
|
||||
class ChangeSetSummary(BaseModel):
|
||||
"""Per-project changeset tracking.
|
||||
|
||||
Records the number of files changed, added, and deleted for a
|
||||
single project within a multi-project plan. Validation state is
|
||||
tracked to gate cross-project merges.
|
||||
"""
|
||||
|
||||
project_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Namespaced project name this summary pertains to",
|
||||
)
|
||||
files_changed: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of files modified",
|
||||
)
|
||||
files_added: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of files created",
|
||||
)
|
||||
files_deleted: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of files deleted",
|
||||
)
|
||||
total_lines_changed: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Total lines added + removed across all file operations",
|
||||
)
|
||||
validation_passed: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether per-project validation passed (None = not run)",
|
||||
)
|
||||
validation_errors: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Validation error messages for this project",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectScope(BaseModel):
|
||||
"""Scoped view of a project within a multi-project plan.
|
||||
|
||||
Created by ``ProjectScopeResolver.resolve_scopes`` for each
|
||||
``ProjectLink`` in the plan. Actors use the scope to understand
|
||||
which resources belong to a specific project and whether access is
|
||||
read-only.
|
||||
"""
|
||||
|
||||
project_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Namespaced project name",
|
||||
)
|
||||
alias: str | None = Field(
|
||||
default=None,
|
||||
description="Optional alias for quick reference",
|
||||
)
|
||||
read_only: bool = Field(
|
||||
default=False,
|
||||
description="Whether this project is read-only in the plan",
|
||||
)
|
||||
resource_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Resource IDs belonging to this project",
|
||||
)
|
||||
changeset_summary: ChangeSetSummary | None = Field(
|
||||
default=None,
|
||||
description="Per-project changeset summary (populated during Execute)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class CrossProjectDependency(BaseModel):
|
||||
"""An edge in the cross-project dependency graph.
|
||||
|
||||
Captures a directional dependency between two projects within the
|
||||
same plan. The ``dependency_type`` is a free-form label (e.g.
|
||||
``"imports"``, ``"api-consumer"``, ``"shared-schema"``).
|
||||
"""
|
||||
|
||||
source_project: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Project that depends on target",
|
||||
)
|
||||
target_project: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Project that is depended upon",
|
||||
)
|
||||
dependency_type: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Kind of dependency (e.g. 'imports', 'api-consumer')",
|
||||
)
|
||||
resource_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Resource IDs involved in this dependency edge",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class MultiProjectMetadata(BaseModel):
|
||||
"""Aggregated multi-project metadata stored on the Plan model.
|
||||
|
||||
Created once during ``MultiProjectService.initialize_scopes`` and
|
||||
updated as execution progresses (changeset recording, dependency
|
||||
discovery).
|
||||
"""
|
||||
|
||||
project_scopes: list[ProjectScope] = Field(
|
||||
default_factory=list,
|
||||
description="Scoped views for each project in the plan",
|
||||
)
|
||||
cross_project_dependencies: list[CrossProjectDependency] = Field(
|
||||
default_factory=list,
|
||||
description="Discovered cross-project dependency edges",
|
||||
)
|
||||
allow_mixed_access: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether mixed read-only and writable projects are "
|
||||
"permitted without explicit opt-in"
|
||||
),
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_projects(self) -> int:
|
||||
"""Number of projects tracked in this metadata."""
|
||||
return len(self.project_scopes)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectScopeResolver:
|
||||
"""Stateless helper for project scope creation and alias resolution.
|
||||
|
||||
All methods are designed to be called without persistent state,
|
||||
operating purely on the data passed to them.
|
||||
"""
|
||||
|
||||
def resolve_scopes(
|
||||
self,
|
||||
project_links: list[ProjectLink],
|
||||
available_resources: dict[str, list[str]],
|
||||
) -> list[ProjectScope]:
|
||||
"""Create ``ProjectScope`` objects for each project link.
|
||||
|
||||
For each ``ProjectLink``, look up matching resource IDs from
|
||||
*available_resources* keyed by project name.
|
||||
|
||||
Args:
|
||||
project_links: The plan's project links.
|
||||
available_resources: Mapping of project name to resource IDs.
|
||||
|
||||
Returns:
|
||||
A list of ``ProjectScope`` objects.
|
||||
"""
|
||||
scopes: list[ProjectScope] = []
|
||||
for link in project_links:
|
||||
resource_ids = available_resources.get(link.project_name, [])
|
||||
scope = ProjectScope(
|
||||
project_name=link.project_name,
|
||||
alias=link.alias,
|
||||
read_only=link.read_only,
|
||||
resource_ids=list(resource_ids),
|
||||
)
|
||||
scopes.append(scope)
|
||||
return scopes
|
||||
|
||||
def resolve_alias(
|
||||
self,
|
||||
project_links: list[ProjectLink],
|
||||
alias_or_name: str,
|
||||
) -> ProjectLink | None:
|
||||
"""Resolve an alias or project name to a ``ProjectLink``.
|
||||
|
||||
Checks aliases first (exact match), then falls back to
|
||||
project name matching.
|
||||
|
||||
Args:
|
||||
project_links: The plan's project links.
|
||||
alias_or_name: Alias or project name to resolve.
|
||||
|
||||
Returns:
|
||||
The matching ``ProjectLink``, or ``None`` if not found.
|
||||
"""
|
||||
# Alias match first
|
||||
for link in project_links:
|
||||
if link.alias is not None and link.alias == alias_or_name:
|
||||
return link
|
||||
# Project name match
|
||||
for link in project_links:
|
||||
if link.project_name == alias_or_name:
|
||||
return link
|
||||
return None
|
||||
|
||||
def validate_access_modes(
|
||||
self,
|
||||
project_links: list[ProjectLink],
|
||||
*,
|
||||
allow_mixed: bool = False,
|
||||
) -> list[str]:
|
||||
"""Validate that access modes are consistent.
|
||||
|
||||
Returns error messages if there are mixed read-only and writable
|
||||
projects without ``allow_mixed=True``.
|
||||
|
||||
Args:
|
||||
project_links: The plan's project links.
|
||||
allow_mixed: Whether mixed access is permitted.
|
||||
|
||||
Returns:
|
||||
List of error message strings (empty if valid).
|
||||
"""
|
||||
if allow_mixed:
|
||||
return []
|
||||
if len(project_links) < 2:
|
||||
return []
|
||||
|
||||
has_read_only = any(link.read_only for link in project_links)
|
||||
has_writable = any(not link.read_only for link in project_links)
|
||||
|
||||
if has_read_only and has_writable:
|
||||
ro_names = [link.project_name for link in project_links if link.read_only]
|
||||
rw_names = [
|
||||
link.project_name for link in project_links if not link.read_only
|
||||
]
|
||||
return [
|
||||
f"Mixed access modes detected: read-only={ro_names}, "
|
||||
f"writable={rw_names}. Set allow_mixed_access=True "
|
||||
f"to permit this configuration."
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"ChangeSetSummary",
|
||||
"CrossProjectDependency",
|
||||
"MultiProjectMetadata",
|
||||
"ProjectScope",
|
||||
"ProjectScopeResolver",
|
||||
]
|
||||
@@ -64,6 +64,10 @@ from typing import Any, ClassVar
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.multi_project import (
|
||||
MultiProjectMetadata,
|
||||
ProjectScope,
|
||||
)
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
# ULID is 26 characters, all uppercase alphanumeric (Crockford's base32)
|
||||
@@ -676,6 +680,12 @@ class Plan(BaseModel):
|
||||
description="Execution environment override for this plan (host or container)",
|
||||
)
|
||||
|
||||
# Multi-project scope tracking (#199)
|
||||
multi_project_metadata: MultiProjectMetadata | None = Field(
|
||||
default=None,
|
||||
description="Multi-project scope tracking and cross-project metadata",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_by: str | None = Field(None, description="User/session that created plan")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags for organization")
|
||||
@@ -835,6 +845,33 @@ class Plan(BaseModel):
|
||||
"""Check if this plan has spawned subplans."""
|
||||
return len(self.subplan_statuses) > 0
|
||||
|
||||
@property
|
||||
def is_multi_project(self) -> bool:
|
||||
"""Check if this plan targets more than one project."""
|
||||
return len(self.project_links) > 1
|
||||
|
||||
def get_project_scope(self, project_name_or_alias: str) -> ProjectScope | None:
|
||||
"""Look up a ``ProjectScope`` by project name or alias.
|
||||
|
||||
Searches the ``multi_project_metadata.project_scopes`` list.
|
||||
Returns ``None`` if multi-project metadata is not set or no
|
||||
matching scope is found.
|
||||
|
||||
Args:
|
||||
project_name_or_alias: Project name or alias to look up.
|
||||
|
||||
Returns:
|
||||
The matching ``ProjectScope``, or ``None``.
|
||||
"""
|
||||
if self.multi_project_metadata is None:
|
||||
return None
|
||||
for scope in self.multi_project_metadata.project_scopes:
|
||||
if scope.alias is not None and scope.alias == project_name_or_alias:
|
||||
return scope
|
||||
if scope.project_name == project_name_or_alias:
|
||||
return scope
|
||||
return None
|
||||
|
||||
def can_revert_to(self, phase: PlanPhase) -> bool:
|
||||
"""Check if reversion to the given phase is valid.
|
||||
|
||||
@@ -920,6 +957,40 @@ class Plan(BaseModel):
|
||||
"compressed_tokens": self.skeleton_metadata.compressed_tokens,
|
||||
"source_decision_ids": list(self.skeleton_metadata.source_decision_ids),
|
||||
}
|
||||
if self.multi_project_metadata is not None:
|
||||
mp = self.multi_project_metadata
|
||||
mp_dict: dict[str, Any] = {
|
||||
"total_projects": mp.total_projects,
|
||||
"allow_mixed_access": mp.allow_mixed_access,
|
||||
}
|
||||
scope_summaries: list[dict[str, Any]] = []
|
||||
for scope in mp.project_scopes:
|
||||
entry: dict[str, Any] = {"project_name": scope.project_name}
|
||||
if scope.alias:
|
||||
entry["alias"] = scope.alias
|
||||
if scope.read_only:
|
||||
entry["read_only"] = True
|
||||
if scope.changeset_summary is not None:
|
||||
cs = scope.changeset_summary
|
||||
entry["changeset"] = {
|
||||
"files_changed": cs.files_changed,
|
||||
"files_added": cs.files_added,
|
||||
"files_deleted": cs.files_deleted,
|
||||
"total_lines_changed": cs.total_lines_changed,
|
||||
"validation_passed": cs.validation_passed,
|
||||
}
|
||||
scope_summaries.append(entry)
|
||||
mp_dict["project_scopes"] = scope_summaries
|
||||
if mp.cross_project_dependencies:
|
||||
mp_dict["cross_project_dependencies"] = [
|
||||
{
|
||||
"source": dep.source_project,
|
||||
"target": dep.target_project,
|
||||
"type": dep.dependency_type,
|
||||
}
|
||||
for dep in mp.cross_project_dependencies
|
||||
]
|
||||
result["multi_project"] = mp_dict
|
||||
if self.last_completed_step >= 0:
|
||||
result["last_completed_step"] = self.last_completed_step
|
||||
if self.last_checkpoint_id:
|
||||
|
||||
@@ -559,6 +559,37 @@ server_connect # noqa: B018, F821
|
||||
server_status # noqa: B018, F821
|
||||
_STUB_WARNING # noqa: B018, F821
|
||||
|
||||
# MultiProjectService public API (issue #199)
|
||||
MultiProjectService # noqa: B018, F821
|
||||
multi_project_service # noqa: B018, F821
|
||||
MultiProjectMetadata # noqa: B018, F821
|
||||
ProjectScope # noqa: B018, F821
|
||||
ProjectScopeResolver # noqa: B018, F821
|
||||
ChangeSetSummary # noqa: B018, F821
|
||||
CrossProjectDependency # noqa: B018, F821
|
||||
resolve_scopes # noqa: B018, F821
|
||||
resolve_alias # noqa: B018, F821
|
||||
validate_access_modes # noqa: B018, F821
|
||||
initialize_scopes # noqa: B018, F821
|
||||
resolve_context_view # noqa: B018, F821
|
||||
record_changeset # noqa: B018, F821
|
||||
validate_cross_project # noqa: B018, F821
|
||||
is_multi_project # noqa: B018, F821
|
||||
get_project_scope # noqa: B018, F821
|
||||
multi_project_metadata # noqa: B018, F821
|
||||
allow_mixed_access # noqa: B018, F821
|
||||
total_projects # noqa: B018, F821
|
||||
project_scopes # noqa: B018, F821
|
||||
cross_project_dependencies # noqa: B018, F821
|
||||
changeset_summary # noqa: B018, F821
|
||||
files_added # noqa: B018, F821
|
||||
files_deleted # noqa: B018, F821
|
||||
total_lines_changed # noqa: B018, F821
|
||||
validation_passed # noqa: B018, F821
|
||||
source_project # noqa: B018, F821
|
||||
target_project # noqa: B018, F821
|
||||
dependency_type # noqa: B018, F821
|
||||
|
||||
# ACMS Backend Abstraction Layer — public API (issue #498)
|
||||
TextBackend # noqa: B018, F821
|
||||
VectorBackend # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user