feat(plan): add multi-project subplan support #535

Merged
freemo merged 1 commits from feature/m5-multi-project into master 2026-03-03 20:10:04 +00:00
20 changed files with 1832 additions and 16 deletions
+8
View File
@@ -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
+142
View File
@@ -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")
+115
View File
@@ -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
+2 -2
View File
@@ -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"
+138
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 ***
+12 -7
View File
@@ -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
+248
View File
@@ -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()
+17 -4
View File
@@ -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)
+47
View File
@@ -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",
]
+27
View File
@@ -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:
+31
View File
@@ -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