feat(server): team collaboration features

Implemented multi-user connection handling with user identity tracking
(TeamMember with owner/admin/member/viewer roles), role-based access
control (TeamPermission with read/write/admin/manage_members), concurrent
session support (SessionRegistry with thread-safe locking and per-user/
per-project queries), and optimistic-locking conflict resolution
(VersionStamp with last-writer-wins, reject, and merge strategies).

Added TeamCollaborationService as the central orchestrator for all
collaboration operations: team membership management, permission
enforcement, session lifecycle, and version-stamp conflict detection/
resolution. The service cleans up user sessions when members are removed.

Domain models follow existing patterns: Pydantic BaseModel with
ConfigDict, StrEnum enums, ULID identifiers, and an error hierarchy
rooted in TeamCollaborationError.

Includes 48 Behave BDD scenarios (178 steps) covering all models,
service operations, and edge cases, plus 15 Robot Framework integration
tests for end-to-end workflow validation.

ISSUES CLOSED: #863
This commit is contained in:
2026-03-23 06:04:38 +00:00
committed by Forgejo
parent c6ccb85bb8
commit b5f56a6fb8
8 changed files with 2224 additions and 0 deletions
+8
View File
@@ -6,6 +6,14 @@ Changed `wf10_batch.robot` to be less likely to create files, and
`plan_generation_graph.robot` to give more test answers.
## [Unreleased]
- Added team collaboration features: multi-user connection handling with
user identity tracking (TeamMember with owner/admin/member/viewer roles),
role-based access control (TeamPermission with read/write/admin/manage_members),
concurrent session support (SessionRegistry with thread-safe locking),
and optimistic-locking conflict resolution (VersionStamp with last-writer-wins,
reject, and merge strategies). Includes TeamCollaborationService orchestrating
all collaboration operations. Behave BDD tests and Robot Framework integration
tests included. (#863)
- Added FastAPI-based ASGI server endpoint served by uvicorn for the
CleverAgents server mode. Includes health check endpoint (`/health`),
A2A Agent Card discovery (`/.well-known/agent.json`), A2A JSON-RPC 2.0
+675
View File
@@ -0,0 +1,675 @@
"""Step definitions for team collaboration feature tests.
Covers team roles, permissions, user sessions, session registry,
version stamps, conflict resolution, and the TeamCollaborationService.
Based on issue #863 — feat(server): team collaboration features.
"""
from __future__ import annotations
import re
from time import sleep
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.application.services.team_collab_service import (
TeamCollaborationService,
)
from cleveragents.domain.models.core.team_collab import (
TEAM_ROLE_PERMISSIONS,
ConflictDetectionResult,
ConflictResolutionStrategy,
SessionRegistry,
TeamCollaborationError,
TeamMember,
TeamMemberExistsError,
TeamMemberNotFoundError,
TeamPermission,
TeamRole,
UserSession,
VersionConflictError,
VersionStamp,
)
ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
# ── TeamRole enum ──────────────────────────────────────────────────
@given("the TeamRole enum")
def step_given_team_role_enum(context: Context) -> None:
context.enum_cls = TeamRole
@then('the TeamRole enum should contain "{value}"')
def step_then_team_role_contains(context: Context, value: str) -> None:
members = [m.value for m in TeamRole]
assert value in members, f"{value} not in TeamRole: {members}"
# ── TeamPermission enum ────────────────────────────────────────────
@given("the TeamPermission enum")
def step_given_team_permission_enum(context: Context) -> None:
context.enum_cls = TeamPermission
@then('the TeamPermission enum should contain "{value}"')
def step_then_team_permission_contains(context: Context, value: str) -> None:
members = [m.value for m in TeamPermission]
assert value in members, f"{value} not in TeamPermission: {members}"
# ── ConflictResolutionStrategy enum ────────────────────────────────
@given("the ConflictResolutionStrategy enum")
def step_given_conflict_strategy_enum(context: Context) -> None:
context.enum_cls = ConflictResolutionStrategy
@then('the ConflictResolutionStrategy enum should contain "{value}"')
def step_then_conflict_strategy_contains(context: Context, value: str) -> None:
members = [m.value for m in ConflictResolutionStrategy]
assert value in members, f"{value} not in ConflictResolutionStrategy: {members}"
# ── TEAM_ROLE_PERMISSIONS constant ─────────────────────────────────
@given("the TEAM_ROLE_PERMISSIONS constant")
def step_given_team_role_permissions(context: Context) -> None:
context.role_permissions = TEAM_ROLE_PERMISSIONS
@then('the "{role}" team role should have {count:d} allowed permissions')
def step_then_team_role_has_n_permissions(
context: Context, role: str, count: int
) -> None:
team_role = TeamRole(role)
perms = TEAM_ROLE_PERMISSIONS[team_role]
assert len(perms) == count, f"{role} has {len(perms)} permissions, expected {count}"
# ── TeamMember model ───────────────────────────────────────────────
@given('a team member "{user_id}" with role "{role}" and email "{email}"')
def step_given_team_member(
context: Context, user_id: str, role: str, email: str
) -> None:
context.team_member = TeamMember(
user_id=user_id,
display_name=user_id.title(),
email=email,
role=TeamRole(role),
)
@then('the team member user_id should be "{expected}"')
def step_then_member_user_id(context: Context, expected: str) -> None:
assert context.team_member.user_id == expected
@then('the team member role should be "{expected}"')
def step_then_member_role(context: Context, expected: str) -> None:
assert context.team_member.role == TeamRole(expected)
@then('the team member email should be "{expected}"')
def step_then_member_email(context: Context, expected: str) -> None:
assert context.team_member.email == expected
@when('I try to create a team member with email "{email}"')
def step_when_create_member_bad_email(context: Context, email: str) -> None:
context.validation_error = None
try:
TeamMember(
user_id="test",
display_name="Test",
email=email,
role=TeamRole.MEMBER,
)
except ValidationError as exc:
context.validation_error = exc
@then("a team member validation error should be raised")
def step_then_team_member_validation_error_raised(context: Context) -> None:
assert context.validation_error is not None, "Expected ValidationError"
@then('the team member should have "{permission}" permission')
def step_then_member_has_permission(context: Context, permission: str) -> None:
perm = TeamPermission(permission)
assert context.team_member.has_permission(perm), (
f"Expected member to have {permission}"
)
@then('the team member should not have "{permission}" permission')
def step_then_member_no_permission(context: Context, permission: str) -> None:
perm = TeamPermission(permission)
assert not context.team_member.has_permission(perm), (
f"Expected member NOT to have {permission}"
)
# ── UserSession model ──────────────────────────────────────────────
@given('a user session for user "{user_id}" on project "{project_id}"')
def step_given_user_session(context: Context, user_id: str, project_id: str) -> None:
context.user_session = UserSession(
session_id=str(SessionRegistry().generate_session_id()),
user_id=user_id,
display_name=user_id.title(),
project_id=project_id,
)
@then('the user session user_id should be "{expected}"')
def step_then_session_user_id(context: Context, expected: str) -> None:
assert context.user_session.user_id == expected
@then('the user session project_id should be "{expected}"')
def step_then_session_project_id(context: Context, expected: str) -> None:
assert context.user_session.project_id == expected
@then("the user session should be active")
def step_then_session_active(context: Context) -> None:
assert context.user_session.is_active is True
@when("the session is deactivated")
def step_when_session_deactivated(context: Context) -> None:
context.user_session.deactivate()
@then("the user session should not be active")
def step_then_session_not_active(context: Context) -> None:
assert context.user_session.is_active is False
@when("the session is touched")
def step_when_session_touched(context: Context) -> None:
context.old_last_active = context.user_session.last_active
sleep(0.01)
context.user_session.touch()
@then("the last_active timestamp should be updated")
def step_then_last_active_updated(context: Context) -> None:
assert context.user_session.last_active >= context.old_last_active
# ── VersionStamp model ─────────────────────────────────────────────
@given('a version stamp for resource "{resource_id}" by user "{user_id}"')
def step_given_version_stamp(context: Context, resource_id: str, user_id: str) -> None:
context.version_stamp = VersionStamp(
resource_id=resource_id,
updated_by=user_id,
)
@then("the version stamp version should be {expected:d}")
def step_then_stamp_version(context: Context, expected: int) -> None:
assert context.version_stamp.version == expected
@then('the version stamp updated_by should be "{expected}"')
def step_then_stamp_updated_by(context: Context, expected: str) -> None:
assert context.version_stamp.updated_by == expected
@when('the version stamp is incremented by "{user_id}"')
def step_when_stamp_incremented(context: Context, user_id: str) -> None:
context.new_version_stamp = context.version_stamp.increment(user_id)
@then("the new version stamp version should be {expected:d}")
def step_then_new_stamp_version(context: Context, expected: int) -> None:
assert context.new_version_stamp.version == expected
@then('the new version stamp updated_by should be "{expected}"')
def step_then_new_stamp_updated_by(context: Context, expected: str) -> None:
assert context.new_version_stamp.updated_by == expected
@then("incrementing with empty user should raise ValueError")
def step_then_increment_empty_raises(context: Context) -> None:
raised = False
try:
context.version_stamp.increment("")
except ValueError:
raised = True
assert raised, "Expected ValueError"
# ── ConflictDetectionResult ────────────────────────────────────────
@given('a conflict detection result with no conflict for "{resource_id}"')
def step_given_no_conflict_result(context: Context, resource_id: str) -> None:
context.conflict_result = ConflictDetectionResult(
detected=False,
resource_id=resource_id,
resolved=True,
details="No conflict",
)
@then("the result detected should be False")
def step_then_result_not_detected(context: Context) -> None:
assert context.conflict_result.detected is False
@then('the result resource_id should be "{expected}"')
def step_then_result_resource_id(context: Context, expected: str) -> None:
assert context.conflict_result.resource_id == expected
# ── VersionConflictError ───────────────────────────────────────────
@when(
'a VersionConflictError is raised for "{resource_id}" '
"expected {expected:d} actual {actual:d}"
)
def step_when_version_conflict_error(
context: Context, resource_id: str, expected: int, actual: int
) -> None:
context.conflict_error = VersionConflictError(
resource_id=resource_id,
expected_version=expected,
actual_version=actual,
)
@then('the error resource_id should be "{expected}"')
def step_then_error_resource_id(context: Context, expected: str) -> None:
assert context.conflict_error.resource_id == expected
@then("the error expected_version should be {expected:d}")
def step_then_error_expected_version(context: Context, expected: int) -> None:
assert context.conflict_error.expected_version == expected
@then("the error actual_version should be {expected:d}")
def step_then_error_actual_version(context: Context, expected: int) -> None:
assert context.conflict_error.actual_version == expected
@then("creating VersionConflictError with empty resource_id raises ValueError")
def step_then_conflict_error_empty_raises(context: Context) -> None:
raised = False
try:
VersionConflictError(resource_id="", expected_version=1, actual_version=2)
except ValueError:
raised = True
assert raised, "Expected ValueError"
# ── SessionRegistry ────────────────────────────────────────────────
@given("a session registry")
def step_given_session_registry(context: Context) -> None:
context.session_registry = SessionRegistry()
context.sessions = {}
@given('a user session "{session_id}" for user "{user_id}" on project "{project_id}"')
def step_given_named_session(
context: Context, session_id: str, user_id: str, project_id: str
) -> None:
session = UserSession(
session_id=session_id,
user_id=user_id,
display_name=user_id.title(),
project_id=project_id,
)
if not hasattr(context, "sessions") or context.sessions is None:
context.sessions = {}
context.sessions[session_id] = session
@when("the session is registered")
def step_when_session_registered(context: Context) -> None:
for session in context.sessions.values():
context.session_registry.register(session)
@when("all sessions are registered")
def step_when_all_sessions_registered(context: Context) -> None:
for session in context.sessions.values():
context.session_registry.register(session)
@then('the registry should contain the session "{session_id}"')
def step_then_registry_contains(context: Context, session_id: str) -> None:
result = context.session_registry.get(session_id)
assert result is not None, f"Session '{session_id}' not found"
@then('the registry should not contain the session "{session_id}"')
def step_then_registry_not_contains(context: Context, session_id: str) -> None:
result = context.session_registry.get(session_id)
assert result is None, f"Session '{session_id}' should not be found"
@then("the registry active count should be {expected:d}")
def step_then_registry_active_count(context: Context, expected: int) -> None:
assert context.session_registry.active_count == expected, (
f"Expected {expected}, got {context.session_registry.active_count}"
)
@when('the session "{session_id}" is unregistered')
def step_when_session_unregistered(context: Context, session_id: str) -> None:
context.session_registry.unregister(session_id)
@then('the registry should have {count:d} sessions for user "{user_id}"')
def step_then_registry_user_sessions(
context: Context, count: int, user_id: str
) -> None:
sessions = context.session_registry.get_sessions_for_user(user_id)
assert len(sessions) == count, (
f"Expected {count} sessions for user '{user_id}', got {len(sessions)}"
)
@then('the registry should have {count:d} sessions for project "{project_id}"')
def step_then_registry_project_sessions(
context: Context, count: int, project_id: str
) -> None:
sessions = context.session_registry.get_sessions_for_project(project_id)
assert len(sessions) == count, (
f"Expected {count} sessions for project '{project_id}', got {len(sessions)}"
)
@when("the registry is cleared")
def step_when_registry_cleared(context: Context) -> None:
context.session_registry.clear()
@then("registering the same session again raises ValueError")
def step_then_duplicate_registration_raises(context: Context) -> None:
session = next(iter(context.sessions.values()))
raised = False
try:
context.session_registry.register(session)
except ValueError:
raised = True
assert raised, "Expected ValueError for duplicate registration"
@then("the generated session ID should be a valid ULID")
def step_then_generated_id_valid(context: Context) -> None:
sid = context.session_registry.generate_session_id()
assert ULID_RE.match(sid), f"'{sid}' is not a valid ULID"
# ── TeamCollaborationService — member management ───────────────────
@given("a team collaboration service")
def step_given_collab_service(context: Context) -> None:
context.collab_service = TeamCollaborationService()
context.last_session = None
context.last_conflict_result = None
@when('I add member "{user_id}" with role "{role}" and email "{email}"')
def step_when_add_member(context: Context, user_id: str, role: str, email: str) -> None:
context.collab_service.add_member(
user_id=user_id,
display_name=user_id.title(),
email=email,
role=TeamRole(role),
)
@then("the service should have {count:d} members")
def step_then_service_member_count(context: Context, count: int) -> None:
members = context.collab_service.list_members()
assert len(members) == count, f"Expected {count} members, got {len(members)}"
@then('getting member "{user_id}" should return the member')
def step_then_get_member(context: Context, user_id: str) -> None:
member = context.collab_service.get_member(user_id)
assert member.user_id == user_id
@then('adding member "{user_id}" again raises TeamMemberExistsError')
def step_then_add_duplicate_raises(context: Context, user_id: str) -> None:
raised = False
try:
context.collab_service.add_member(
user_id=user_id,
display_name="Dup",
email="dup@example.com",
role=TeamRole.MEMBER,
)
except TeamMemberExistsError:
raised = True
assert raised, "Expected TeamMemberExistsError"
@when('I remove member "{user_id}"')
def step_when_remove_member(context: Context, user_id: str) -> None:
context.collab_service.remove_member(user_id)
@then('removing member "{user_id}" raises TeamMemberNotFoundError')
def step_then_remove_not_found_raises(context: Context, user_id: str) -> None:
raised = False
try:
context.collab_service.remove_member(user_id)
except TeamMemberNotFoundError:
raised = True
assert raised, "Expected TeamMemberNotFoundError"
@when('I update member "{user_id}" role to "{role}"')
def step_when_update_role(context: Context, user_id: str, role: str) -> None:
context.collab_service.update_member_role(user_id, TeamRole(role))
@then('member "{user_id}" should have role "{role}"')
def step_then_member_has_role(context: Context, user_id: str, role: str) -> None:
member = context.collab_service.get_member(user_id)
assert member.role == TeamRole(role), f"Expected role {role}, got {member.role}"
# ── TeamCollaborationService — permission checks ──────────────────
@then('member "{user_id}" should have "{permission}" team permission')
def step_then_service_member_has_perm(
context: Context, user_id: str, permission: str
) -> None:
result = context.collab_service.check_permission(
user_id, TeamPermission(permission)
)
assert result is True, f"Expected {user_id} to have {permission}"
@then('member "{user_id}" should not have "{permission}" team permission')
def step_then_service_member_no_perm(
context: Context, user_id: str, permission: str
) -> None:
result = context.collab_service.check_permission(
user_id, TeamPermission(permission)
)
assert result is False, f"Expected {user_id} NOT to have {permission}"
@then('enforcing "{permission}" on "{user_id}" raises PermissionError')
def step_then_enforce_raises(context: Context, permission: str, user_id: str) -> None:
raised = False
try:
context.collab_service.enforce_permission(user_id, TeamPermission(permission))
except PermissionError:
raised = True
assert raised, "Expected PermissionError"
# ── TeamCollaborationService — session management ─────────────────
@when('I register a session for "{user_id}" on project "{project_id}"')
def step_when_register_service_session(
context: Context, user_id: str, project_id: str
) -> None:
session = context.collab_service.register_session(
user_id=user_id,
project_id=project_id,
)
context.last_session = session
@then('the project "{project_id}" should have {count:d} active sessions')
def step_then_project_active_sessions(
context: Context, project_id: str, count: int
) -> None:
sessions = context.collab_service.get_active_sessions_for_project(project_id)
assert len(sessions) == count, (
f"Expected {count} active sessions for '{project_id}', got {len(sessions)}"
)
@when("I unregister the last session")
def step_when_unregister_last_session(context: Context) -> None:
context.collab_service.unregister_session(context.last_session.session_id)
# ── TeamCollaborationService — conflict resolution ─────────────────
@when('I create a version stamp for "{resource_id}" by "{user_id}"')
def step_when_create_stamp(context: Context, resource_id: str, user_id: str) -> None:
context.collab_service.create_version_stamp(resource_id, user_id)
@then('the version stamp for "{resource_id}" should have version {version:d}')
def step_then_stamp_has_version(
context: Context, resource_id: str, version: int
) -> None:
stamp = context.collab_service.get_version_stamp(resource_id)
assert stamp is not None, f"No stamp for '{resource_id}'"
assert stamp.version == version, f"Expected version {version}, got {stamp.version}"
@when('I update version for "{resource_id}" expecting {version:d} by "{user_id}"')
def step_when_update_version(
context: Context, resource_id: str, version: int, user_id: str
) -> None:
context.last_conflict_result = context.collab_service.update_version(
resource_id=resource_id,
expected_version=version,
user_id=user_id,
)
@then(
'updating "{resource_id}" expecting {version:d} by "{user_id}" '
"with reject raises VersionConflictError"
)
def step_then_update_reject_raises(
context: Context, resource_id: str, version: int, user_id: str
) -> None:
raised = False
try:
context.collab_service.update_version(
resource_id=resource_id,
expected_version=version,
user_id=user_id,
strategy=ConflictResolutionStrategy.REJECT,
)
except VersionConflictError:
raised = True
assert raised, "Expected VersionConflictError"
@when(
'I update "{resource_id}" expecting {version:d} by "{user_id}" '
"with last_writer_wins"
)
def step_when_update_lww(
context: Context, resource_id: str, version: int, user_id: str
) -> None:
context.last_conflict_result = context.collab_service.update_version(
resource_id=resource_id,
expected_version=version,
user_id=user_id,
strategy=ConflictResolutionStrategy.LAST_WRITER_WINS,
)
@when('I update "{resource_id}" expecting {version:d} by "{user_id}" with merge')
def step_when_update_merge(
context: Context, resource_id: str, version: int, user_id: str
) -> None:
context.last_conflict_result = context.collab_service.update_version(
resource_id=resource_id,
expected_version=version,
user_id=user_id,
strategy=ConflictResolutionStrategy.MERGE,
)
@then("the conflict result should be detected and resolved")
def step_then_conflict_detected_resolved(context: Context) -> None:
result = context.last_conflict_result
assert result.detected is True, "Expected conflict detected"
assert result.resolved is True, "Expected conflict resolved"
@then('detecting conflict on "{resource_id}" expecting {version:d} should return True')
def step_then_detect_conflict_true(
context: Context, resource_id: str, version: int
) -> None:
result = context.collab_service.detect_conflict(resource_id, version)
assert result is True, "Expected conflict detected"
@then('detecting conflict on "{resource_id}" expecting {version:d} should return False')
def step_then_detect_conflict_false(
context: Context, resource_id: str, version: int
) -> None:
result = context.collab_service.detect_conflict(resource_id, version)
assert result is False, "Expected no conflict"
# ── Error hierarchy ────────────────────────────────────────────────
@then("TeamMemberNotFoundError should be a subclass of TeamCollaborationError")
def step_then_not_found_is_subclass(context: Context) -> None:
assert issubclass(TeamMemberNotFoundError, TeamCollaborationError)
@then("TeamMemberExistsError should be a subclass of TeamCollaborationError")
def step_then_exists_is_subclass(context: Context) -> None:
assert issubclass(TeamMemberExistsError, TeamCollaborationError)
@then("VersionConflictError should be a subclass of TeamCollaborationError")
def step_then_conflict_is_subclass(context: Context) -> None:
assert issubclass(VersionConflictError, TeamCollaborationError)
+308
View File
@@ -0,0 +1,308 @@
Feature: Team collaboration — multi-user sessions, RBAC, and conflict resolution
As a CleverAgents team lead
I want team collaboration features with role-based access control
So that multiple users can work concurrently on shared projects
# TeamRole enum
Scenario: TeamRole enum contains expected members
Given the TeamRole enum
Then the TeamRole enum should contain "owner"
And the TeamRole enum should contain "admin"
And the TeamRole enum should contain "member"
And the TeamRole enum should contain "viewer"
# ── TeamPermission enum ──────────────────────────────────────────
Scenario: TeamPermission enum contains expected members
Given the TeamPermission enum
Then the TeamPermission enum should contain "read"
And the TeamPermission enum should contain "write"
And the TeamPermission enum should contain "admin"
And the TeamPermission enum should contain "manage_members"
# ── ConflictResolutionStrategy enum ──────────────────────────────
Scenario: ConflictResolutionStrategy enum contains expected members
Given the ConflictResolutionStrategy enum
Then the ConflictResolutionStrategy enum should contain "last_writer_wins"
And the ConflictResolutionStrategy enum should contain "reject"
And the ConflictResolutionStrategy enum should contain "merge"
# ── TEAM_ROLE_PERMISSIONS constant ───────────────────────────────
Scenario: OWNER role has all 4 permissions
Given the TEAM_ROLE_PERMISSIONS constant
Then the "owner" team role should have 4 allowed permissions
Scenario: ADMIN role has all 4 permissions
Given the TEAM_ROLE_PERMISSIONS constant
Then the "admin" team role should have 4 allowed permissions
Scenario: MEMBER role has read and write
Given the TEAM_ROLE_PERMISSIONS constant
Then the "member" team role should have 2 allowed permissions
Scenario: VIEWER role has only read
Given the TEAM_ROLE_PERMISSIONS constant
Then the "viewer" team role should have 1 allowed permissions
# ── TeamMember model ─────────────────────────────────────────────
Scenario: Create a valid TeamMember
Given a team member "alice" with role "owner" and email "alice@example.com"
Then the team member user_id should be "alice"
And the team member role should be "owner"
And the team member email should be "alice@example.com"
Scenario: TeamMember email validation rejects missing @
When I try to create a team member with email "invalid-email"
Then a team member validation error should be raised
Scenario: TeamMember has_permission returns True for allowed action
Given a team member "bob" with role "member" and email "bob@example.com"
Then the team member should have "read" permission
And the team member should have "write" permission
Scenario: TeamMember has_permission returns False for denied action
Given a team member "carol" with role "viewer" and email "carol@example.com"
Then the team member should not have "write" permission
And the team member should not have "admin" permission
# ── UserSession model ────────────────────────────────────────────
Scenario: Create a valid UserSession
Given a user session for user "alice" on project "proj-1"
Then the user session user_id should be "alice"
And the user session project_id should be "proj-1"
And the user session should be active
Scenario: Deactivate a UserSession
Given a user session for user "alice" on project "proj-1"
When the session is deactivated
Then the user session should not be active
Scenario: Touch updates last_active timestamp
Given a user session for user "alice" on project "proj-1"
When the session is touched
Then the last_active timestamp should be updated
# ── VersionStamp model ───────────────────────────────────────────
Scenario: Create a valid VersionStamp
Given a version stamp for resource "res-1" by user "alice"
Then the version stamp version should be 1
And the version stamp updated_by should be "alice"
Scenario: Increment a VersionStamp
Given a version stamp for resource "res-1" by user "alice"
When the version stamp is incremented by "bob"
Then the new version stamp version should be 2
And the new version stamp updated_by should be "bob"
Scenario: VersionStamp increment rejects empty user
Given a version stamp for resource "res-1" by user "alice"
Then incrementing with empty user should raise ValueError
# ── ConflictDetectionResult model ────────────────────────────────
Scenario: Create a no-conflict result
Given a conflict detection result with no conflict for "res-1"
Then the result detected should be False
And the result resource_id should be "res-1"
# ── VersionConflictError ─────────────────────────────────────────
Scenario: VersionConflictError carries version info
When a VersionConflictError is raised for "res-1" expected 1 actual 2
Then the error resource_id should be "res-1"
And the error expected_version should be 1
And the error actual_version should be 2
Scenario: VersionConflictError rejects empty resource_id
Then creating VersionConflictError with empty resource_id raises ValueError
# ── SessionRegistry ──────────────────────────────────────────────
Scenario: Register and retrieve a session
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
When the session is registered
Then the registry should contain the session "sess-1"
And the registry active count should be 1
Scenario: Unregister a session
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
When the session is registered
And the session "sess-1" is unregistered
Then the registry should not contain the session "sess-1"
And the registry active count should be 0
Scenario: Get sessions for a specific user
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
And a user session "sess-2" for user "alice" on project "proj-2"
And a user session "sess-3" for user "bob" on project "proj-1"
When all sessions are registered
Then the registry should have 2 sessions for user "alice"
And the registry should have 1 sessions for user "bob"
Scenario: Get sessions for a specific project
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
And a user session "sess-2" for user "bob" on project "proj-1"
And a user session "sess-3" for user "carol" on project "proj-2"
When all sessions are registered
Then the registry should have 2 sessions for project "proj-1"
And the registry should have 1 sessions for project "proj-2"
Scenario: Clear all sessions
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
And a user session "sess-2" for user "bob" on project "proj-2"
When all sessions are registered
And the registry is cleared
Then the registry active count should be 0
Scenario: Duplicate session registration raises ValueError
Given a session registry
And a user session "sess-1" for user "alice" on project "proj-1"
When the session is registered
Then registering the same session again raises ValueError
Scenario: Session registry generate_session_id returns valid ULID
Given a session registry
Then the generated session ID should be a valid ULID
# ── TeamCollaborationService — member management ─────────────────
Scenario: Add and retrieve a team member via service
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
Then the service should have 1 members
And getting member "alice" should return the member
Scenario: Adding duplicate member raises TeamMemberExistsError
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
Then adding member "alice" again raises TeamMemberExistsError
Scenario: Remove a team member via service
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
And I remove member "alice"
Then the service should have 0 members
Scenario: Removing non-existent member raises TeamMemberNotFoundError
Given a team collaboration service
Then removing member "ghost" raises TeamMemberNotFoundError
Scenario: Update a member role
Given a team collaboration service
When I add member "alice" with role "member" and email "alice@example.com"
And I update member "alice" role to "admin"
Then member "alice" should have role "admin"
# ── TeamCollaborationService — permission checks ─────────────────
Scenario: Owner has all permissions
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
Then member "alice" should have "read" team permission
And member "alice" should have "write" team permission
And member "alice" should have "admin" team permission
And member "alice" should have "manage_members" team permission
Scenario: Viewer has only read permission
Given a team collaboration service
When I add member "viewer-user" with role "viewer" and email "view@example.com"
Then member "viewer-user" should have "read" team permission
And member "viewer-user" should not have "write" team permission
Scenario: Enforce permission raises PermissionError for viewer writing
Given a team collaboration service
When I add member "viewer-user" with role "viewer" and email "view@example.com"
Then enforcing "write" on "viewer-user" raises PermissionError
# ── TeamCollaborationService — session management ────────────────
Scenario: Register a session for a team member
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
And I register a session for "alice" on project "proj-1"
Then the project "proj-1" should have 1 active sessions
Scenario: Multiple users have concurrent sessions on same project
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
And I add member "bob" with role "member" and email "bob@example.com"
And I register a session for "alice" on project "proj-1"
And I register a session for "bob" on project "proj-1"
Then the project "proj-1" should have 2 active sessions
Scenario: Unregister a session reduces active count
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
And I register a session for "alice" on project "proj-1"
And I unregister the last session
Then the project "proj-1" should have 0 active sessions
Scenario: Removing a member cleans up their sessions
Given a team collaboration service
When I add member "alice" with role "owner" and email "alice@example.com"
And I register a session for "alice" on project "proj-1"
And I remove member "alice"
Then the project "proj-1" should have 0 active sessions
# ── TeamCollaborationService — conflict resolution ───────────────
Scenario: Create and retrieve a version stamp
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
Then the version stamp for "res-1" should have version 1
Scenario: Update version with matching expected version succeeds
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
And I update version for "res-1" expecting 1 by "bob"
Then the version stamp for "res-1" should have version 2
Scenario: Update version with mismatched version using reject strategy raises error
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
And I update version for "res-1" expecting 1 by "bob"
Then updating "res-1" expecting 1 by "carol" with reject raises VersionConflictError
Scenario: Update version with last-writer-wins resolves conflict
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
And I update version for "res-1" expecting 1 by "bob"
And I update "res-1" expecting 1 by "carol" with last_writer_wins
Then the conflict result should be detected and resolved
And the version stamp for "res-1" should have version 3
Scenario: Detect conflict without resolving
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
And I update version for "res-1" expecting 1 by "bob"
Then detecting conflict on "res-1" expecting 1 should return True
And detecting conflict on "res-1" expecting 2 should return False
Scenario: Update version with merge strategy resolves conflict
Given a team collaboration service
When I create a version stamp for "res-1" by "alice"
And I update version for "res-1" expecting 1 by "bob"
And I update "res-1" expecting 1 by "carol" with merge
Then the conflict result should be detected and resolved
# ── Error hierarchy ──────────────────────────────────────────────
Scenario: TeamMemberNotFoundError is a TeamCollaborationError
Then TeamMemberNotFoundError should be a subclass of TeamCollaborationError
Scenario: TeamMemberExistsError is a TeamCollaborationError
Then TeamMemberExistsError should be a subclass of TeamCollaborationError
Scenario: VersionConflictError is a TeamCollaborationError
Then VersionConflictError should be a subclass of TeamCollaborationError
+140
View File
@@ -0,0 +1,140 @@
*** Settings ***
Documentation Integration tests for team collaboration features.
... Verifies multi-user session management, role-based access
... control, concurrent sessions, and conflict resolution.
... Based on issue #863.
Library Process
Library OperatingSystem
Library Collections
Suite Setup Set Suite Variable ${PYTHON} python
*** Variables ***
${PYTHON} python
*** Keywords ***
Run Python Script
[Documentation] Run a Python snippet and return stdout.
[Arguments] ${script}
${result}= Run Process ${PYTHON} -c ${script}
... env:PYTHONPATH=src
RETURN ${result}
*** Test Cases ***
TeamRole Enum Contains Expected Members
[Documentation] Verify TeamRole enum has owner, admin, member, viewer.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamRole; roles = sorted([r.value for r in TeamRole]); print(','.join(roles))
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} admin
Should Contain ${result.stdout} member
Should Contain ${result.stdout} owner
Should Contain ${result.stdout} viewer
TeamPermission Enum Contains Expected Members
[Documentation] Verify TeamPermission enum has read, write, admin, manage_members.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamPermission; perms = sorted([p.value for p in TeamPermission]); print(','.join(perms))
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} read
Should Contain ${result.stdout} write
Should Contain ${result.stdout} admin
Should Contain ${result.stdout} manage_members
Team Role Permissions Mapping Is Correct
[Documentation] Verify role-to-permission mapping counts.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TEAM_ROLE_PERMISSIONS, TeamRole; print(len(TEAM_ROLE_PERMISSIONS[TeamRole.OWNER]), len(TEAM_ROLE_PERMISSIONS[TeamRole.ADMIN]), len(TEAM_ROLE_PERMISSIONS[TeamRole.MEMBER]), len(TEAM_ROLE_PERMISSIONS[TeamRole.VIEWER]))
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} 4 4 2 1
TeamMember Model Creation And Validation
[Documentation] Create a TeamMember and verify fields.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamMember, TeamRole; m = TeamMember(user_id='alice', display_name='Alice', email='alice@example.com', role=TeamRole.OWNER); print(f'{m.user_id},{m.role},{m.email}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} alice,owner,alice@example.com
TeamMember Email Validation Rejects Invalid
[Documentation] Creating a TeamMember with invalid email fails.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamMember, TeamRole;\ntry:\n\ \ \ \ TeamMember(user_id='x', display_name='X', email='bad', role=TeamRole.MEMBER)\n\ \ \ \ print('NO_ERROR')\nexcept Exception as e:\n\ \ \ \ print('ERROR')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} ERROR
TeamMember Permission Check
[Documentation] Verify has_permission for different roles.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamMember, TeamRole, TeamPermission; m = TeamMember(user_id='v', display_name='V', email='v@x.com', role=TeamRole.VIEWER); print(m.has_permission(TeamPermission.READ), m.has_permission(TeamPermission.WRITE))
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} True False
UserSession Creation And Deactivation
[Documentation] Create a session, verify active, then deactivate.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import UserSession; s = UserSession(session_id='s1', user_id='alice', display_name='Alice', project_id='p1'); print(s.is_active); s.deactivate(); print(s.is_active)
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} True
Should Contain ${result.stdout} False
VersionStamp Creation And Increment
[Documentation] Create a version stamp and increment it.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import VersionStamp; v = VersionStamp(resource_id='r1', updated_by='alice'); v2 = v.increment('bob'); print(f'{v.version},{v2.version},{v2.updated_by}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} 1,2,bob
SessionRegistry Concurrent Session Management
[Documentation] Register multiple sessions and query by user/project.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import SessionRegistry, UserSession; r = SessionRegistry(); r.register(UserSession(session_id='s1', user_id='alice', display_name='Alice', project_id='p1')); r.register(UserSession(session_id='s2', user_id='bob', display_name='Bob', project_id='p1')); r.register(UserSession(session_id='s3', user_id='alice', display_name='Alice', project_id='p2')); print(f'active={r.active_count},alice={len(r.get_sessions_for_user("alice"))},p1={len(r.get_sessions_for_project("p1"))}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} active=3,alice=2,p1=2
TeamCollaborationService End To End Workflow
[Documentation] Full workflow: add members, register sessions, check perms, handle conflicts.
${result}= Run Python Script
... from cleveragents.application.services.team_collab_service import TeamCollaborationService; from cleveragents.domain.models.core.team_collab import TeamRole, TeamPermission, ConflictResolutionStrategy, VersionConflictError; svc = TeamCollaborationService(); svc.add_member('alice', 'Alice', 'alice@x.com', TeamRole.OWNER); svc.add_member('bob', 'Bob', 'bob@x.com', TeamRole.MEMBER); svc.add_member('carol', 'Carol', 'carol@x.com', TeamRole.VIEWER); s1 = svc.register_session('alice', 'p1'); s2 = svc.register_session('bob', 'p1'); sessions = svc.get_active_sessions_for_project('p1'); print(f'sessions={len(sessions)}'); print(f'alice_write={svc.check_permission("alice", TeamPermission.WRITE)}'); print(f'carol_write={svc.check_permission("carol", TeamPermission.WRITE)}'); svc.create_version_stamp('r1', 'alice'); svc.update_version('r1', 1, 'bob'); conflict = svc.detect_conflict('r1', 1); print(f'conflict={conflict}'); result = svc.update_version('r1', 1, 'carol', ConflictResolutionStrategy.LAST_WRITER_WINS); print(f'resolved={result.resolved}'); stamp = svc.get_version_stamp('r1'); print(f'version={stamp.version}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} sessions=2
Should Contain ${result.stdout} alice_write=True
Should Contain ${result.stdout} carol_write=False
Should Contain ${result.stdout} conflict=True
Should Contain ${result.stdout} resolved=True
Should Contain ${result.stdout} version=3
VersionConflictError With Reject Strategy
[Documentation] Verify reject strategy raises VersionConflictError on conflict.
${result}= Run Python Script
... from cleveragents.application.services.team_collab_service import TeamCollaborationService; from cleveragents.domain.models.core.team_collab import TeamRole, ConflictResolutionStrategy, VersionConflictError; svc = TeamCollaborationService(); svc.add_member('alice', 'Alice', 'alice@x.com', TeamRole.OWNER); svc.create_version_stamp('r1', 'alice'); svc.update_version('r1', 1, 'alice');\ntry:\n\ \ \ \ svc.update_version('r1', 1, 'alice', ConflictResolutionStrategy.REJECT)\n\ \ \ \ print('NO_ERROR')\nexcept VersionConflictError:\n\ \ \ \ print('CONFLICT_ERROR')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} CONFLICT_ERROR
Error Hierarchy Verification
[Documentation] Verify error class hierarchy.
${result}= Run Python Script
... from cleveragents.domain.models.core.team_collab import TeamCollaborationError, TeamMemberNotFoundError, TeamMemberExistsError, VersionConflictError; print(issubclass(TeamMemberNotFoundError, TeamCollaborationError), issubclass(TeamMemberExistsError, TeamCollaborationError), issubclass(VersionConflictError, TeamCollaborationError))
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} True True True
Remove Member Cleans Up Sessions
[Documentation] Verify removing a member cleans up their sessions.
${result}= Run Python Script
... from cleveragents.application.services.team_collab_service import TeamCollaborationService; from cleveragents.domain.models.core.team_collab import TeamRole; svc = TeamCollaborationService(); svc.add_member('alice', 'Alice', 'alice@x.com', TeamRole.OWNER); svc.register_session('alice', 'p1'); print(f'before={len(svc.get_active_sessions_for_project("p1"))}'); svc.remove_member('alice'); print(f'after={len(svc.get_active_sessions_for_project("p1"))}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} before=1
Should Contain ${result.stdout} after=0
Enforce Permission Raises PermissionError
[Documentation] Verify enforce_permission raises PermissionError for insufficient role.
${result}= Run Python Script
... from cleveragents.application.services.team_collab_service import TeamCollaborationService; from cleveragents.domain.models.core.team_collab import TeamRole, TeamPermission;\nsvc = TeamCollaborationService()\nsvc.add_member('viewer', 'Viewer', 'v@x.com', TeamRole.VIEWER)\ntry:\n\ \ \ \ svc.enforce_permission('viewer', TeamPermission.WRITE)\n\ \ \ \ print('NO_ERROR')\nexcept PermissionError:\n\ \ \ \ print('PERM_ERROR')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} PERM_ERROR
Merge Strategy Resolves Conflict
[Documentation] Verify merge strategy resolves version conflicts.
${result}= Run Python Script
... from cleveragents.application.services.team_collab_service import TeamCollaborationService; from cleveragents.domain.models.core.team_collab import TeamRole, ConflictResolutionStrategy; svc = TeamCollaborationService(); svc.add_member('alice', 'Alice', 'alice@x.com', TeamRole.OWNER); svc.create_version_stamp('r1', 'alice'); svc.update_version('r1', 1, 'alice'); result = svc.update_version('r1', 1, 'alice', ConflictResolutionStrategy.MERGE); print(f'detected={result.detected},resolved={result.resolved},strategy={result.strategy}')
Should Be Equal As Strings ${result.rc} 0
Should Contain ${result.stdout} detected=True,resolved=True,strategy=merge
@@ -0,0 +1,507 @@
"""Team collaboration service for CleverAgents.
Orchestrates multi-user connection handling, role-based access control,
concurrent session management, and optimistic-locking conflict resolution
for shared project access.
The ``TeamCollaborationService`` is the primary entry point for all
team collaboration operations. It delegates to the domain models in
``cleveragents.domain.models.core.team_collab`` and coordinates
session registration, permission checks, and version-stamp management.
Based on issue #863 — feat(server): team collaboration features.
"""
from __future__ import annotations
import structlog
from cleveragents.domain.models.core.team_collab import (
ConflictDetectionResult,
ConflictResolutionStrategy,
SessionRegistry,
TeamMember,
TeamMemberExistsError,
TeamMemberNotFoundError,
TeamPermission,
TeamRole,
UserSession,
VersionConflictError,
VersionStamp,
)
__all__ = [
"TeamCollaborationService",
]
_logger = structlog.get_logger(__name__)
class TeamCollaborationService:
"""Central service for team collaboration features.
Manages team membership, concurrent user sessions, permission
enforcement, and optimistic-locking conflict resolution.
All operations are designed for concurrent access. The underlying
``SessionRegistry`` uses thread-safe locking; the version-stamp
registry uses an internal dict guarded by the same concurrency
guarantees.
"""
def __init__(self) -> None:
"""Initialise the service with empty registries."""
self._members: dict[str, TeamMember] = {}
self._session_registry = SessionRegistry()
self._version_stamps: dict[str, VersionStamp] = {}
self._logger = _logger.bind(service="team_collab")
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def session_registry(self) -> SessionRegistry:
"""Return the underlying session registry."""
return self._session_registry
# ------------------------------------------------------------------
# Team member management
# ------------------------------------------------------------------
def add_member(
self,
user_id: str,
display_name: str,
email: str,
role: TeamRole,
) -> TeamMember:
"""Add a new member to the team.
Args:
user_id: Unique user identifier.
display_name: Human-readable name.
email: Email address.
role: Team role for the new member.
Returns:
The newly created ``TeamMember``.
Raises:
ValueError: If any argument is empty.
TeamMemberExistsError: If the user is already a member.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
if not display_name or not display_name.strip():
raise ValueError("display_name must be a non-empty string")
if not email or not email.strip():
raise ValueError("email must be a non-empty string")
if user_id in self._members:
raise TeamMemberExistsError(f"User '{user_id}' is already a team member")
member = TeamMember(
user_id=user_id,
display_name=display_name,
email=email,
role=role,
)
self._members[user_id] = member
self._logger.info(
"member_added",
user_id=user_id,
role=str(role),
)
return member
def remove_member(self, user_id: str) -> bool:
"""Remove a member from the team.
Also unregisters all active sessions for the removed user.
Args:
user_id: The user to remove.
Returns:
``True`` if the member was found and removed.
Raises:
ValueError: If *user_id* is empty.
TeamMemberNotFoundError: If the user is not a member.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
if user_id not in self._members:
raise TeamMemberNotFoundError(f"User '{user_id}' is not a team member")
# Clean up active sessions for the removed user
user_sessions = self._session_registry.get_sessions_for_user(user_id)
for session in user_sessions:
self._session_registry.unregister(session.session_id)
del self._members[user_id]
self._logger.info("member_removed", user_id=user_id)
return True
def get_member(self, user_id: str) -> TeamMember:
"""Retrieve a team member by user ID.
Args:
user_id: The user to look up.
Returns:
The ``TeamMember`` instance.
Raises:
ValueError: If *user_id* is empty.
TeamMemberNotFoundError: If the user is not a member.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
member = self._members.get(user_id)
if member is None:
raise TeamMemberNotFoundError(f"User '{user_id}' is not a team member")
return member
def list_members(self) -> list[TeamMember]:
"""Return all team members.
Returns:
A list of all ``TeamMember`` instances.
"""
return list(self._members.values())
def update_member_role(self, user_id: str, new_role: TeamRole) -> TeamMember:
"""Change a member's role.
Args:
user_id: The user whose role to change.
new_role: The new role to assign.
Returns:
The updated ``TeamMember``.
Raises:
ValueError: If *user_id* is empty.
TeamMemberNotFoundError: If the user is not a member.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
member = self.get_member(user_id)
member.role = new_role
self._logger.info(
"member_role_updated",
user_id=user_id,
new_role=str(new_role),
)
return member
# ------------------------------------------------------------------
# Permission checks
# ------------------------------------------------------------------
def check_permission(
self,
user_id: str,
permission: TeamPermission,
) -> bool:
"""Check whether a user has a specific permission.
Args:
user_id: The user to check.
permission: The permission to verify.
Returns:
``True`` if the user's role grants the permission.
Raises:
ValueError: If *user_id* is empty.
TeamMemberNotFoundError: If the user is not a member.
"""
member = self.get_member(user_id)
return member.has_permission(permission)
def enforce_permission(
self,
user_id: str,
permission: TeamPermission,
) -> None:
"""Enforce that a user has a specific permission.
Args:
user_id: The user to check.
permission: The required permission.
Raises:
ValueError: If *user_id* is empty.
TeamMemberNotFoundError: If the user is not a member.
PermissionError: If the user lacks the permission.
"""
if not self.check_permission(user_id, permission):
member = self.get_member(user_id)
raise PermissionError(
f"User '{user_id}' (role={member.role}) lacks '{permission}' permission"
)
# ------------------------------------------------------------------
# Session management
# ------------------------------------------------------------------
def register_session(
self,
user_id: str,
project_id: str,
metadata: dict[str, object] | None = None,
) -> UserSession:
"""Register a new session for a team member.
Args:
user_id: The user establishing the session.
project_id: The project the session is scoped to.
metadata: Optional session metadata.
Returns:
The newly created ``UserSession``.
Raises:
ValueError: If arguments are empty.
TeamMemberNotFoundError: If the user is not a member.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
if not project_id or not project_id.strip():
raise ValueError("project_id must be a non-empty string")
member = self.get_member(user_id)
session = UserSession(
session_id=self._session_registry.generate_session_id(),
user_id=user_id,
display_name=member.display_name,
project_id=project_id,
metadata=dict(metadata) if metadata else {},
)
self._session_registry.register(session)
self._logger.info(
"session_registered",
session_id=session.session_id,
user_id=user_id,
project_id=project_id,
)
return session
def unregister_session(self, session_id: str) -> bool:
"""Unregister (disconnect) a session.
Args:
session_id: The session to remove.
Returns:
``True`` if the session was found and removed.
"""
if not session_id or not session_id.strip():
raise ValueError("session_id must be a non-empty string")
removed = self._session_registry.unregister(session_id)
if removed:
self._logger.info("session_unregistered", session_id=session_id)
return removed
def get_active_sessions_for_project(
self,
project_id: str,
) -> list[UserSession]:
"""Return all active sessions for a project.
Args:
project_id: The project to query.
Returns:
A list of active sessions.
"""
if not project_id or not project_id.strip():
raise ValueError("project_id must be a non-empty string")
return self._session_registry.get_sessions_for_project(project_id)
# ------------------------------------------------------------------
# Version stamps and conflict resolution
# ------------------------------------------------------------------
def create_version_stamp(
self,
resource_id: str,
user_id: str,
) -> VersionStamp:
"""Create an initial version stamp for a resource.
Args:
resource_id: The resource to track.
user_id: The user creating the resource.
Returns:
A new ``VersionStamp`` at version 1.
Raises:
ValueError: If arguments are empty.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
stamp = VersionStamp(
resource_id=resource_id,
version=1,
updated_by=user_id,
)
self._version_stamps[resource_id] = stamp
self._logger.info(
"version_stamp_created",
resource_id=resource_id,
version=1,
)
return stamp
def get_version_stamp(self, resource_id: str) -> VersionStamp | None:
"""Retrieve the current version stamp for a resource.
Args:
resource_id: The resource to look up.
Returns:
The current ``VersionStamp``, or ``None`` if untracked.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
return self._version_stamps.get(resource_id)
def update_version(
self,
resource_id: str,
expected_version: int,
user_id: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.REJECT,
) -> ConflictDetectionResult:
"""Attempt to update a resource's version stamp.
Implements optimistic locking: the caller provides the version
they last read (``expected_version``). If the current version
matches, the stamp is incremented atomically. If it does not
match, the configured ``strategy`` determines the outcome.
Args:
resource_id: The resource to update.
expected_version: The version the caller expects.
user_id: The user performing the update.
strategy: How to handle conflicts.
Returns:
A ``ConflictDetectionResult`` describing the outcome.
Raises:
ValueError: If arguments are invalid.
VersionConflictError: If strategy is ``reject`` and conflict found.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
if expected_version < 1:
raise ValueError("expected_version must be >= 1")
current = self._version_stamps.get(resource_id)
if current is None:
# Resource not tracked yet — create initial stamp
stamp = self.create_version_stamp(resource_id, user_id)
return ConflictDetectionResult(
detected=False,
resource_id=resource_id,
resolved=True,
details=f"New resource created at version {stamp.version}",
)
if current.version == expected_version:
# No conflict — increment
new_stamp = current.increment(user_id)
self._version_stamps[resource_id] = new_stamp
return ConflictDetectionResult(
detected=False,
resource_id=resource_id,
resolved=True,
details=f"Updated to version {new_stamp.version}",
)
# Conflict detected
self._logger.warning(
"version_conflict",
resource_id=resource_id,
expected=expected_version,
actual=current.version,
strategy=str(strategy),
)
if strategy == ConflictResolutionStrategy.LAST_WRITER_WINS:
new_stamp = current.increment(user_id)
self._version_stamps[resource_id] = new_stamp
return ConflictDetectionResult(
detected=True,
resource_id=resource_id,
strategy=strategy,
resolved=True,
details=(
f"Conflict resolved (last-writer-wins): "
f"version {current.version} -> {new_stamp.version}"
),
)
if strategy == ConflictResolutionStrategy.MERGE:
# Merge strategy: increment but mark as merge-resolved
new_stamp = current.increment(user_id)
self._version_stamps[resource_id] = new_stamp
return ConflictDetectionResult(
detected=True,
resource_id=resource_id,
strategy=strategy,
resolved=True,
details=(
f"Conflict resolved (merge): "
f"version {current.version} -> {new_stamp.version}"
),
)
# strategy == REJECT
raise VersionConflictError(
resource_id=resource_id,
expected_version=expected_version,
actual_version=current.version,
)
def detect_conflict(
self,
resource_id: str,
expected_version: int,
) -> bool:
"""Check whether a conflict exists without resolving it.
Args:
resource_id: The resource to check.
expected_version: The version the caller expects.
Returns:
``True`` if a conflict is detected.
Raises:
ValueError: If arguments are invalid.
"""
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
if expected_version < 1:
raise ValueError("expected_version must be >= 1")
current = self._version_stamps.get(resource_id)
if current is None:
return False
return current.version != expected_version
@@ -301,6 +301,21 @@ from cleveragents.domain.models.core.skill import (
SkillResolver,
SkillToolRef,
)
from cleveragents.domain.models.core.team_collab import (
TEAM_ROLE_PERMISSIONS,
ConflictDetectionResult,
ConflictResolutionStrategy,
SessionRegistry,
TeamCollaborationError,
TeamMember,
TeamMemberExistsError,
TeamMemberNotFoundError,
TeamPermission,
TeamRole,
UserSession,
VersionConflictError,
VersionStamp,
)
from cleveragents.domain.models.core.tool import (
BindingMode,
CheckpointScope,
@@ -335,6 +350,7 @@ __all__ = [
"DEFAULT_PROVIDER_RETRY",
"DEFAULT_SAFETY_PROFILE",
"ROLE_PERMISSIONS",
"TEAM_ROLE_PERMISSIONS",
"VALID_JOB_TRANSITIONS",
"VALID_PHASES",
"ActionState",
@@ -365,6 +381,8 @@ __all__ = [
"CircuitBreakerConfig",
"CloudBillingFields",
"ConfidenceFactors",
"ConflictDetectionResult",
"ConflictResolutionStrategy",
"Context",
"ContextBudget",
"ContextConfig",
@@ -513,6 +531,7 @@ __all__ = [
"SessionImportError",
"SessionMessage",
"SessionNotFoundError",
"SessionRegistry",
"SessionService",
"SessionServiceError",
"SessionTokenUsage",
@@ -527,6 +546,12 @@ __all__ = [
"SkillToolRef",
"SpecChangeSet",
"SummaryForUpdateContextParams",
"TeamCollaborationError",
"TeamMember",
"TeamMemberExistsError",
"TeamMemberNotFoundError",
"TeamPermission",
"TeamRole",
"TemporalScope",
"TextMatchEvaluator",
"ThreadSafeOrgCostAccumulator",
@@ -541,8 +566,11 @@ __all__ = [
"UKOPrefix",
"UKOVersion",
"User",
"UserSession",
"Validation",
"ValidationMode",
"VersionConflictError",
"VersionStamp",
"build_provenance_map",
"can_transition",
"can_transition_job",
@@ -0,0 +1,538 @@
"""Team collaboration domain models for CleverAgents.
Multi-user session management, role-based access control, concurrent
session tracking, and optimistic-locking conflict resolution for
shared project access.
## Collaboration Model
Teams are collections of users who share access to projects. Each
member has a ``TeamRole`` that determines which ``TeamPermission``
actions they may perform. Active sessions are tracked in a
``SessionRegistry`` so that concurrent edits can be detected and
resolved via configurable ``ConflictResolutionStrategy`` policies.
## Role Hierarchy
| Role | Permissions |
|---------|---------------------------------------|
| owner | read, write, admin, manage_members |
| admin | read, write, admin, manage_members |
| member | read, write |
| viewer | read |
Based on issue #863 — feat(server): team collaboration features.
"""
from __future__ import annotations
import threading
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ulid import ULID
__all__ = [
"TEAM_ROLE_PERMISSIONS",
"ConflictDetectionResult",
"ConflictResolutionStrategy",
"SessionRegistry",
"TeamCollaborationError",
"TeamMember",
"TeamMemberExistsError",
"TeamMemberNotFoundError",
"TeamPermission",
"TeamRole",
"UserSession",
"VersionConflictError",
"VersionStamp",
]
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class TeamRole(StrEnum):
"""Role of a user within a collaboration team.
Determines the set of ``TeamPermission`` actions the user may
perform on shared resources.
"""
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"
class TeamPermission(StrEnum):
"""Actions gated by team role membership."""
READ = "read"
WRITE = "write"
ADMIN = "admin"
MANAGE_MEMBERS = "manage_members"
class ConflictResolutionStrategy(StrEnum):
"""Strategy for resolving concurrent edit conflicts.
- ``last_writer_wins``: The most recent write silently overwrites.
- ``reject``: The conflicting write is rejected with an error.
- ``merge``: Both changes are merged (requires caller support).
"""
LAST_WRITER_WINS = "last_writer_wins"
REJECT = "reject"
MERGE = "merge"
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
TEAM_ROLE_PERMISSIONS: dict[TeamRole, frozenset[TeamPermission]] = {
TeamRole.OWNER: frozenset(TeamPermission),
TeamRole.ADMIN: frozenset(
{
TeamPermission.READ,
TeamPermission.WRITE,
TeamPermission.ADMIN,
TeamPermission.MANAGE_MEMBERS,
}
),
TeamRole.MEMBER: frozenset(
{
TeamPermission.READ,
TeamPermission.WRITE,
}
),
TeamRole.VIEWER: frozenset(
{
TeamPermission.READ,
}
),
}
"""Mapping of each team role to its set of allowed permissions.
OWNER and ADMIN currently grant identical permissions. The semantic
distinction mirrors the ``PermissionRole`` design --- OWNER denotes the
team/project creator while ADMIN is a delegated role.
"""
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class TeamCollaborationError(Exception):
"""Base error for team collaboration operations."""
class TeamMemberNotFoundError(TeamCollaborationError):
"""Raised when a team member is not found."""
class TeamMemberExistsError(TeamCollaborationError):
"""Raised when attempting to add a member who already exists."""
class VersionConflictError(TeamCollaborationError):
"""Raised when an optimistic-locking version conflict is detected.
Attributes:
resource_id: The resource that was modified concurrently.
expected_version: The version the caller expected.
actual_version: The current version in the registry.
"""
def __init__(
self,
resource_id: str,
expected_version: int,
actual_version: int,
) -> None:
if not resource_id or not resource_id.strip():
raise ValueError("resource_id must be a non-empty string")
if expected_version < 0:
raise ValueError("expected_version must be >= 0")
if actual_version < 0:
raise ValueError("actual_version must be >= 0")
self.resource_id = resource_id
self.expected_version = expected_version
self.actual_version = actual_version
super().__init__(
f"Version conflict on resource '{resource_id}': "
f"expected version {expected_version}, "
f"actual version {actual_version}"
)
# ---------------------------------------------------------------------------
# TeamMember
# ---------------------------------------------------------------------------
class TeamMember(BaseModel):
"""A user participating in a collaboration team.
Each member has a unique ``user_id``, a display name, an email
address, and a ``TeamRole`` controlling their permissions.
"""
user_id: str = Field(
...,
min_length=1,
description="Unique identifier for the team member.",
)
display_name: str = Field(
...,
min_length=1,
description="Human-readable display name.",
)
email: str = Field(
...,
min_length=3,
description="Email address of the team member.",
)
role: TeamRole = Field(
...,
description="Team role determining permissions.",
)
joined_at: datetime = Field(
default_factory=datetime.now,
description="When the member joined the team.",
)
@field_validator("email")
@classmethod
def validate_email_format(cls: type[TeamMember], v: str) -> str:
"""Basic email format validation (must contain '@')."""
if "@" not in v:
raise ValueError("email must contain '@'")
return v
def has_permission(self, permission: TeamPermission) -> bool:
"""Check whether this member's role grants *permission*.
Args:
permission: The permission to check.
Returns:
``True`` if the member's role includes the permission.
"""
allowed = TEAM_ROLE_PERMISSIONS.get(self.role, frozenset())
return permission in allowed
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# UserSession
# ---------------------------------------------------------------------------
class UserSession(BaseModel):
"""An active user session for concurrent project access.
Tracks which user is connected, which project they are working on,
and session lifecycle timestamps.
"""
session_id: str = Field(
...,
min_length=1,
description="Unique session identifier (ULID).",
)
user_id: str = Field(
...,
min_length=1,
description="Identifier of the connected user.",
)
display_name: str = Field(
...,
min_length=1,
description="Display name of the connected user.",
)
project_id: str = Field(
...,
min_length=1,
description="Project the session is scoped to.",
)
started_at: datetime = Field(
default_factory=datetime.now,
description="When the session was established.",
)
last_active: datetime = Field(
default_factory=datetime.now,
description="When the session was last active.",
)
is_active: bool = Field(
default=True,
description="Whether the session is currently active.",
)
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Optional session metadata.",
)
def deactivate(self) -> None:
"""Mark the session as inactive."""
self.is_active = False
def touch(self) -> None:
"""Update the last-active timestamp to now."""
self.last_active = datetime.now()
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# VersionStamp
# ---------------------------------------------------------------------------
class VersionStamp(BaseModel):
"""Optimistic-locking version stamp for a shared resource.
Each write increments the ``version`` counter. Callers present
their last-known version when writing; a mismatch indicates a
concurrent modification.
"""
resource_id: str = Field(
...,
min_length=1,
description="Identifier of the versioned resource.",
)
version: int = Field(
default=1,
ge=1,
description="Monotonically increasing version counter.",
)
updated_by: str = Field(
...,
min_length=1,
description="User who last modified the resource.",
)
updated_at: datetime = Field(
default_factory=datetime.now,
description="When the resource was last modified.",
)
def increment(self, updated_by: str) -> VersionStamp:
"""Return a new stamp with an incremented version.
Args:
updated_by: The user performing the update.
Returns:
A new ``VersionStamp`` with ``version + 1``.
Raises:
ValueError: If *updated_by* is empty.
"""
if not updated_by or not updated_by.strip():
raise ValueError("updated_by must be a non-empty string")
return VersionStamp(
resource_id=self.resource_id,
version=self.version + 1,
updated_by=updated_by,
updated_at=datetime.now(),
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# ConflictDetectionResult
# ---------------------------------------------------------------------------
class ConflictDetectionResult(BaseModel):
"""Result of a conflict detection check.
Encapsulates whether a conflict was detected and, if resolved,
which strategy was applied and the outcome.
"""
detected: bool = Field(
...,
description="Whether a version conflict was detected.",
)
resource_id: str = Field(
...,
min_length=1,
description="Identifier of the checked resource.",
)
strategy: ConflictResolutionStrategy | None = Field(
default=None,
description="Strategy applied (None if no conflict).",
)
resolved: bool = Field(
default=False,
description="Whether the conflict was successfully resolved.",
)
details: str = Field(
default="",
description="Human-readable description of the outcome.",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# SessionRegistry
# ---------------------------------------------------------------------------
class SessionRegistry:
"""Thread-safe registry of active user sessions.
Tracks concurrent sessions and provides queries by user, project,
or session ID. All mutating operations are guarded by a
``threading.Lock`` for safe concurrent access.
"""
def __init__(self) -> None:
self._sessions: dict[str, UserSession] = {}
self._lock = threading.Lock()
def register(self, session: UserSession) -> None:
"""Register a new user session.
Args:
session: The session to register.
Raises:
ValueError: If a session with the same ID already exists.
"""
if not session.session_id or not session.session_id.strip():
raise ValueError("session_id must be a non-empty string")
with self._lock:
if session.session_id in self._sessions:
raise ValueError(
f"Session '{session.session_id}' is already registered"
)
self._sessions[session.session_id] = session
def unregister(self, session_id: str) -> bool:
"""Remove a session from the registry.
Args:
session_id: The session to remove.
Returns:
``True`` if the session was found and removed.
"""
if not session_id or not session_id.strip():
raise ValueError("session_id must be a non-empty string")
with self._lock:
if session_id in self._sessions:
self._sessions[session_id].deactivate()
del self._sessions[session_id]
return True
return False
def get(self, session_id: str) -> UserSession | None:
"""Retrieve a session by ID.
Args:
session_id: The session to look up.
Returns:
The session, or ``None`` if not found.
"""
if not session_id or not session_id.strip():
raise ValueError("session_id must be a non-empty string")
with self._lock:
return self._sessions.get(session_id)
def get_active_sessions(self) -> list[UserSession]:
"""Return all currently active sessions.
Returns:
A list of active ``UserSession`` instances.
"""
with self._lock:
return [s for s in self._sessions.values() if s.is_active]
def get_sessions_for_user(self, user_id: str) -> list[UserSession]:
"""Return all sessions belonging to a specific user.
Args:
user_id: The user whose sessions to retrieve.
Returns:
A list of sessions for the given user.
"""
if not user_id or not user_id.strip():
raise ValueError("user_id must be a non-empty string")
with self._lock:
return [
s
for s in self._sessions.values()
if s.user_id == user_id and s.is_active
]
def get_sessions_for_project(self, project_id: str) -> list[UserSession]:
"""Return all active sessions scoped to a project.
Args:
project_id: The project to query.
Returns:
A list of active sessions for the given project.
"""
if not project_id or not project_id.strip():
raise ValueError("project_id must be a non-empty string")
with self._lock:
return [
s
for s in self._sessions.values()
if s.project_id == project_id and s.is_active
]
@property
def active_count(self) -> int:
"""Return the number of active sessions."""
with self._lock:
return sum(1 for s in self._sessions.values() if s.is_active)
def clear(self) -> int:
"""Remove all sessions.
Returns:
The number of sessions that were removed.
"""
with self._lock:
count = len(self._sessions)
for session in self._sessions.values():
session.deactivate()
self._sessions.clear()
return count
def generate_session_id(self) -> str:
"""Generate a new unique session ID (ULID).
Returns:
A ULID string suitable for ``UserSession.session_id``.
"""
return str(ULID())
+20
View File
@@ -1224,3 +1224,23 @@ health_endpoint # noqa: B018, F821
a2a_endpoint # noqa: B018, F821
server_start # noqa: B018, F821
run_server # noqa: B018, F821
# Team collaboration module — public API entry points
TeamRole # noqa: B018, F821
TeamPermission # noqa: B018, F821
ConflictResolutionStrategy # noqa: B018, F821
TEAM_ROLE_PERMISSIONS # noqa: B018, F821
TeamMember # noqa: B018, F821
TeamMemberExistsError # noqa: B018, F821
TeamMemberNotFoundError # noqa: B018, F821
TeamCollaborationError # noqa: B018, F821
VersionConflictError # noqa: B018, F821
VersionStamp # noqa: B018, F821
UserSession # noqa: B018, F821
SessionRegistry # noqa: B018, F821
ConflictDetectionResult # noqa: B018, F821
TeamCollaborationService # noqa: B018, F821
generate_session_id # noqa: B018, F821
# Pydantic @classmethod validator parameter required by decorator protocol
cls # noqa: B018, F821