Compare commits

...

2 Commits

Author SHA1 Message Date
freemo 5ce8bbd905 feat(server): remote repo operations implementation
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16m3s
CI / lint (pull_request) Successful in 19m32s
CI / typecheck (pull_request) Successful in 23m40s
CI / integration_tests (pull_request) Successful in 23m36s
CI / unit_tests (pull_request) Successful in 23m37s
CI / e2e_tests (pull_request) Successful in 25m9s
CI / quality (pull_request) Successful in 29m49s
CI / security (pull_request) Successful in 30m13s
CI / docker (pull_request) Successful in 1m10s
CI / coverage (pull_request) Successful in 11m16s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 59m57s
Implemented server-side remote repository operations allowing clients
to push/pull/fetch/status on server-managed repositories through the
A2A protocol without needing local clones.

Domain models:
- RepoRef, RepoOperationType, RepoSyncState, ConflictStrategy enums
- RepoStatusResult, RepoFetchResult, RepoPullResult, RepoPushResult,
  ConflictResolutionResult frozen dataclasses with to_dict() serialization

RemoteRepoService (application/services/remote_repo_service.py):
- status(): query sync state (ahead/behind/diverged/detached/no_remote)
- fetch(): fetch refs from remote with prune
- pull(): fetch + merge with conflict detection and auto-abort
- push(): push local commits with rejection detection
- resolve_conflicts(): abort/ours/theirs conflict resolution strategies
- Full argument validation, timeout handling, and structured error types

A2A facade wiring:
- Replaced _cleveragents/sync/* stubs with real repo operation handlers
- Added _cleveragents/repo/push, /pull, /fetch, /status endpoints
- Service-absent fallback returns backward-compatible stub responses
- Updated operation count from 42 to 46

Tests:
- Behave BDD: 39 scenarios covering domain models, service construction,
  type validation, real git operations, facade wiring, and ASGI dispatch
- Robot Framework: 7 integration tests exercising end-to-end import paths,
  real git subprocess operations, facade dispatch, and ASGI endpoint routing

ISSUES CLOSED: #864
2026-03-24 01:15:17 +00:00
freemo 5f0a545113 feat(server): ASGI endpoint via uvicorn
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 20s
CI / security (pull_request) Failing after 51s
CI / lint (pull_request) Successful in 3m39s
CI / quality (pull_request) Successful in 3m46s
CI / typecheck (pull_request) Successful in 3m56s
CI / e2e_tests (pull_request) Successful in 8m39s
CI / coverage (pull_request) Successful in 11m6s
CI / integration_tests (pull_request) Failing after 16m23s
CI / unit_tests (pull_request) Failing after 16m23s
CI / benchmark-regression (pull_request) Successful in 52m29s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 1s
Implement FastAPI-based ASGI application served by uvicorn for
the CleverAgents server mode. Add health check endpoint, A2A
JSON-RPC routing, configurable host:port binding, and graceful
shutdown handling. Server launches via `agents server start`.

- Add FastAPI ASGI app factory at infrastructure/server/asgi_app.py
  with /.well-known/agent.json (Agent Card), /health (liveness),
  and /a2a (A2A JSON-RPC 2.0 dispatch via A2aLocalFacade)
- Add ServerLifecycle class at infrastructure/server/server_lifecycle.py
  wrapping uvicorn.Server with SIGTERM/SIGINT graceful shutdown
- Add `agents server start` CLI command with --host, --port, --log-level
  options, resolving defaults from Settings
- Add fastapi>=0.115.0 dependency to pyproject.toml (uvicorn already present)
- Add Behave BDD tests (features/server_lifecycle.feature, 20 scenarios)
- Add Robot Framework integration tests (robot/server_lifecycle.robot)
- Update CHANGELOG.md and vulture_whitelist.py

ISSUES CLOSED: #862
2026-03-22 23:50:04 +00:00
23 changed files with 3403 additions and 19 deletions
+15
View File
@@ -2,6 +2,21 @@
## Unreleased
- Added remote repository operations implementation for server-side
push/pull/fetch/status on managed repositories. Includes domain models
(RepoRef, RepoStatusResult, RepoFetchResult, RepoPullResult, RepoPushResult,
ConflictResolutionResult), RemoteRepoService with git subprocess management,
A2A facade wiring for `_cleveragents/repo/*` and `_cleveragents/sync/*`
endpoints, ASGI endpoint dispatch, conflict resolution strategies, and
state synchronization. Behave BDD tests (39 scenarios) and Robot Framework
integration tests (7 test cases) included. (#864)
- 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
routing (`/a2a`), configurable host:port binding via Settings, graceful
shutdown on SIGTERM/SIGINT, and `agents server start` CLI command.
Behave BDD tests and Robot Framework integration tests included. (#862)
- Added BuiltinAdapter class and MCP automatic resource slot creation.
BuiltinAdapter wraps register_file_tools/register_git_tools/register_subplan_tool
into a unified adapter interface. McpAdapter.infer_resource_slots() analyzes
+1 -1
View File
@@ -6,7 +6,7 @@ Feature: A2A CLI facade integration
Scenario: CLI bootstrap creates a wired facade
Given a facade created via the CLI bootstrap
Then the facade should support all 42 operations
Then the facade should support all 46 operations
And the facade should be cached on subsequent calls
Scenario: Session create routes through facade
+2 -2
View File
@@ -140,7 +140,7 @@ Feature: Consolidated Misc
And the operations list should contain "registry.list_tools"
And the operations list should contain "context.get"
And the operations list should contain "event.subscribe"
And the operations list should have 42 items
And the operations list should have 46 items
# -----------------------------------------------------------------------
# A2aHttpTransport — all stubs raise A2aNotAvailableError
@@ -637,7 +637,7 @@ Feature: Consolidated Misc
Then the m6 smoke operations should include "session.create"
And the m6 smoke operations should include "plan.execute"
And the m6 smoke operations should include "event.subscribe"
And the m6 smoke operations count should be 42
And the m6 smoke operations count should be 46
# --- A2A event queue ---
+266
View File
@@ -0,0 +1,266 @@
@phase2 @a2a @server @remote_repos
Feature: Remote repository operations
As a platform operator
I want server-side push/pull/fetch/status operations on managed repositories
So that clients can work with repositories hosted on the server without
local clones through the A2A protocol
# -----------------------------------------------------------------------
# Domain model — RepoRef validation
# -----------------------------------------------------------------------
Scenario: RepoRef rejects empty repo_path
When I try to create a RepoRef with an empty repo_path
Then a remote_repos ValueError should be raised containing "repo_path"
Scenario: RepoRef rejects empty remote
When I try to create a RepoRef with an empty remote
Then a remote_repos ValueError should be raised containing "remote"
Scenario: RepoRef accepts valid inputs
When I create a RepoRef with repo_path "/tmp/repo" and remote "origin"
Then the RepoRef repo_path should be "/tmp/repo"
And the RepoRef remote should be "origin"
# -----------------------------------------------------------------------
# Domain model — enumerations and result types
# -----------------------------------------------------------------------
Scenario: RepoOperationType has all expected members
Then RepoOperationType should have members: push, pull, fetch, status
Scenario: RepoSyncState has all expected members
Then RepoSyncState should have members: up_to_date, ahead, behind, diverged, unknown, detached, no_remote
Scenario: ConflictStrategy has all expected members
Then ConflictStrategy should have members: abort, ours, theirs
Scenario: RepoStatusResult serializes to dict
Given a RepoStatusResult with sync_state up_to_date
When I call to_dict on the status result
Then the dict should contain key "sync_state" with value "up_to_date"
And the dict should contain key "repo_path"
And the dict should contain key "current_branch"
Scenario: RepoFetchResult serializes to dict
Given a RepoFetchResult with success True
When I call to_dict on the fetch result
Then the dict should contain key "success" with value True
And the dict should contain key "updated_refs"
Scenario: RepoPullResult serializes to dict
Given a RepoPullResult with conflict_files
When I call to_dict on the pull result
Then the dict should contain key "conflict_files"
And the dict should contain key "success" with value False
Scenario: RepoPushResult serializes to dict
Given a RepoPushResult with rejected True
When I call to_dict on the push result
Then the dict should contain key "rejected" with value True
Scenario: ConflictResolutionResult serializes to dict
Given a ConflictResolutionResult with strategy abort
When I call to_dict on the resolution result
Then the dict should contain key "strategy" with value "abort"
# -----------------------------------------------------------------------
# RemoteRepoService — construction
# -----------------------------------------------------------------------
Scenario: RemoteRepoService rejects non-positive timeout
When I try to create a RemoteRepoService with git_timeout 0
Then a ValueError should be raised for invalid timeout
Scenario: RemoteRepoService rejects negative timeout
When I try to create a RemoteRepoService with git_timeout -5
Then a ValueError should be raised for invalid timeout
Scenario: RemoteRepoService accepts valid timeout
When I create a RemoteRepoService with git_timeout 30
Then the service should be created successfully
# -----------------------------------------------------------------------
# RemoteRepoService — type validation
# -----------------------------------------------------------------------
Scenario: status rejects non-RepoRef argument
Given a RemoteRepoService
When I call status with a non-RepoRef argument
Then a TypeError should be raised for invalid ref
Scenario: fetch rejects non-RepoRef argument
Given a RemoteRepoService
When I call fetch with a non-RepoRef argument
Then a TypeError should be raised for invalid ref
Scenario: pull rejects non-RepoRef argument
Given a RemoteRepoService
When I call pull with a non-RepoRef argument
Then a TypeError should be raised for invalid ref
Scenario: push rejects non-RepoRef argument
Given a RemoteRepoService
When I call push with a non-RepoRef argument
Then a TypeError should be raised for invalid ref
Scenario: resolve_conflicts rejects non-RepoRef argument
Given a RemoteRepoService
When I call resolve_conflicts with a non-RepoRef argument
Then a TypeError should be raised for invalid ref
Scenario: resolve_conflicts rejects non-ConflictStrategy argument
Given a RemoteRepoService
When I call resolve_conflicts with a non-ConflictStrategy argument
Then a TypeError should be raised for invalid strategy
# -----------------------------------------------------------------------
# RemoteRepoService — validation errors
# -----------------------------------------------------------------------
Scenario: status rejects non-existent repo path
Given a RemoteRepoService
When I call status with a non-existent repo path
Then a ValidationError should be raised for invalid repo
Scenario: fetch rejects non-git directory
Given a RemoteRepoService
And a temporary directory that is not a git repo
When I call fetch with the non-git directory
Then a ValidationError should be raised for invalid repo
# -----------------------------------------------------------------------
# RemoteRepoService — status on a real git repo
# -----------------------------------------------------------------------
Scenario: status returns UP_TO_DATE for a clean repo
Given a RemoteRepoService
And a temporary git repo with a remote
When I call status on the temporary repo
Then the status sync_state should be "up_to_date"
And the status has_uncommitted should be False
And the status head_commit should be non-empty
Scenario: status returns AHEAD when local has unpushed commits
Given a RemoteRepoService
And a temporary git repo with a remote
And a local commit in the temporary repo
When I call status on the temporary repo
Then the status sync_state should be "ahead"
And the status ahead_count should be greater than 0
Scenario: status detects uncommitted changes
Given a RemoteRepoService
And a temporary git repo with a remote
And an uncommitted file in the temporary repo
When I call status on the temporary repo
Then the status has_uncommitted should be True
# -----------------------------------------------------------------------
# RemoteRepoService — fetch on a real git repo
# -----------------------------------------------------------------------
Scenario: fetch succeeds on a repo with a remote
Given a RemoteRepoService
And a temporary git repo with a remote
When I call fetch on the temporary repo
Then the fetch result success should be True
# -----------------------------------------------------------------------
# RemoteRepoService — push on a real git repo
# -----------------------------------------------------------------------
Scenario: push succeeds when there are local commits
Given a RemoteRepoService
And a temporary git repo with a remote
And a local commit in the temporary repo
When I call push on the temporary repo
Then the push result success should be True
# -----------------------------------------------------------------------
# RemoteRepoService — pull on a real git repo
# -----------------------------------------------------------------------
Scenario: pull succeeds when remote has new commits
Given a RemoteRepoService
And a temporary git repo with a remote
And a remote commit on the upstream repo
When I call pull on the temporary repo
Then the pull result success should be True
# -----------------------------------------------------------------------
# A2A facade wiring — repo operations
# -----------------------------------------------------------------------
Scenario: A2A repo/status with no service returns stub
Given an A2aLocalFacade with no remote_repo_service
When I dispatch operation "_cleveragents/repo/status" with params
Then the A2A response should have status "ok"
And the A2A response data should contain "stub"
Scenario: A2A repo/status with service returns result
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/repo/status" with a valid repo_path
Then the A2A response should have status "ok"
And the A2A response data should contain "sync_state"
Scenario: A2A repo/fetch with service returns result
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/repo/fetch" with a valid repo_path
Then the A2A response should have status "ok"
And the A2A response data should contain "updated_refs"
Scenario: A2A repo/pull with service returns result
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/repo/pull" with a valid repo_path
Then the A2A response should have status "ok"
And the A2A response data should contain "branch"
Scenario: A2A repo/push with service returns result
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/repo/push" with a valid repo_path
Then the A2A response should have status "ok"
And the A2A response data should contain "pushed_commits"
Scenario: A2A sync/status maps to repo status handler
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/sync/status" with a valid repo_path
Then the A2A response should have status "ok"
And the A2A response data should contain "sync_state"
Scenario: A2A sync/pull maps to repo pull handler
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/sync/pull" with a valid repo_path
Then the A2A response should have status "ok"
Scenario: A2A sync/push maps to repo push handler
Given an A2aLocalFacade with a mock remote_repo_service
When I dispatch operation "_cleveragents/sync/push" with a valid repo_path
Then the A2A response should have status "ok"
# -----------------------------------------------------------------------
# A2A ASGI endpoint wiring
# -----------------------------------------------------------------------
Scenario: ASGI endpoint dispatches repo/status via POST /a2a
Given a running ASGI test client with remote_repo_service
When I POST a repo/status request to /a2a
Then the response status code should be 200
And the A2A response status should be "ok"
Scenario: ASGI endpoint dispatches repo/fetch via POST /a2a
Given a running ASGI test client with remote_repo_service
When I POST a repo/fetch request to /a2a
Then the response status code should be 200
And the A2A response status should be "ok"
# -----------------------------------------------------------------------
# Error handling — network / timeout
# -----------------------------------------------------------------------
Scenario: Repo operations listed in supported operations
When I list operations from a new A2aLocalFacade
Then the operations should include "_cleveragents/repo/push"
And the operations should include "_cleveragents/repo/pull"
And the operations should include "_cleveragents/repo/fetch"
And the operations should include "_cleveragents/repo/status"
+122
View File
@@ -0,0 +1,122 @@
@phase2 @a2a @server
Feature: ASGI Server Lifecycle
As a platform operator
I want to start, health-check, and shut down the CleverAgents ASGI server
So that the A2A JSON-RPC 2.0 endpoint is available for client communication
# -----------------------------------------------------------------------
# ASGI application creation
# -----------------------------------------------------------------------
Scenario: create_asgi_app returns a FastAPI application
When I create an ASGI application with default settings
Then the ASGI app should be a FastAPI instance
Scenario: create_asgi_app accepts a custom facade
Given an A2aLocalFacade with no services
When I create an ASGI application with that facade
Then the ASGI app should be a FastAPI instance
Scenario: create_asgi_app rejects invalid facade type
When I try to create an ASGI application with facade "not-a-facade"
Then a TypeError should be raised for invalid facade
Scenario: create_asgi_app rejects invalid port
When I try to create an ASGI application with port 0
Then a ValueError should be raised for invalid port
Scenario: create_asgi_app rejects invalid host
When I try to create an ASGI application with host ""
Then a ValueError should be raised for invalid host
# -----------------------------------------------------------------------
# Health check endpoint
# -----------------------------------------------------------------------
Scenario: Health endpoint returns healthy status
Given a running ASGI test client
When I request GET /health
Then the response status code should be 200
And the response body should contain "healthy"
# -----------------------------------------------------------------------
# Agent Card discovery endpoint
# -----------------------------------------------------------------------
Scenario: Agent Card endpoint returns valid agent card
Given a running ASGI test client
When I request GET /.well-known/agent.json
Then the response status code should be 200
And the response body should contain "CleverAgents"
And the response body should contain "url"
# -----------------------------------------------------------------------
# A2A JSON-RPC endpoint
# -----------------------------------------------------------------------
Scenario: A2A endpoint dispatches health check operation
Given a running ASGI test client
When I POST a JSON-RPC request to /a2a with operation "_cleveragents/health/check"
Then the response status code should be 200
And the A2A response status should be "ok"
Scenario: A2A endpoint returns error for unknown operation
Given a running ASGI test client
When I POST a JSON-RPC request to /a2a with operation "nonexistent.operation"
Then the response status code should be 404
Scenario: A2A endpoint returns 400 for malformed request
Given a running ASGI test client
When I POST a malformed JSON body to /a2a
Then the response status code should be 400
# -----------------------------------------------------------------------
# ServerLifecycle construction
# -----------------------------------------------------------------------
Scenario: ServerLifecycle accepts valid configuration
When I create a ServerLifecycle with host "127.0.0.1" and port 9000
Then the lifecycle host should be "127.0.0.1"
And the lifecycle port should be 9000
And the lifecycle should not be started
And the lifecycle should not be stopped
Scenario: ServerLifecycle rejects empty host
When I try to create a ServerLifecycle with host ""
Then a ValueError should be raised for invalid host
Scenario: ServerLifecycle rejects invalid port 0
When I try to create a ServerLifecycle with port 0
Then a ValueError should be raised for invalid port
Scenario: ServerLifecycle rejects port above 65535
When I try to create a ServerLifecycle with port 70000
Then a ValueError should be raised for invalid port
Scenario: ServerLifecycle rejects empty log_level
When I try to create a ServerLifecycle with empty log_level
Then a ValueError should be raised for invalid log_level
# -----------------------------------------------------------------------
# Server configuration from Settings
# -----------------------------------------------------------------------
Scenario: Settings provides default server host
Then the default server host from Settings should be "0.0.0.0"
Scenario: Settings provides default server port
Then the default server port from Settings should be 8080
# -----------------------------------------------------------------------
# Graceful shutdown
# -----------------------------------------------------------------------
Scenario: ServerLifecycle request_shutdown sets flag
Given a ServerLifecycle with a mock uvicorn server
When I call request_shutdown on the lifecycle
Then the mock server should_exit flag should be true
Scenario: ServerLifecycle cannot be started twice
Given a ServerLifecycle that has already been started
When I try to start the lifecycle again
Then a RuntimeError should be raised for double start
@@ -121,10 +121,10 @@ def step_call_notify_facade(context: Any, operation: str) -> None:
# ---------------------------------------------------------------------------
@then("the facade should support all 42 operations")
@then("the facade should support all 46 operations")
def step_check_42_operations(context: Any) -> None:
ops = context.facade.list_operations()
assert len(ops) == 42, f"Expected 42 operations, got {len(ops)}: {ops}"
assert len(ops) == 46, f"Expected 46 operations, got {len(ops)}: {ops}"
@then("the facade should be cached on subsequent calls")
+722
View File
@@ -0,0 +1,722 @@
"""Step definitions for ``remote_repos.feature``.
Tests domain models, RemoteRepoService operations (status, fetch,
push, pull, conflict resolution), A2A facade wiring for repo
operations, and ASGI endpoint dispatch.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
from cleveragents.application.services.remote_repo_service import RemoteRepoService
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.remote_repo import (
ConflictResolutionResult,
ConflictStrategy,
RepoFetchResult,
RepoOperationType,
RepoPullResult,
RepoPushResult,
RepoRef,
RepoStatusResult,
RepoSyncState,
)
# ---------------------------------------------------------------------------
# Helper: create a temporary git repo with a bare remote
# ---------------------------------------------------------------------------
def _create_temp_git_repos() -> tuple[str, str]:
"""Create a bare remote repo and a clone.
Returns a tuple ``(clone_path, bare_path)``.
"""
bare_dir = tempfile.mkdtemp(prefix="ca-test-bare-")
subprocess.run(
["git", "init", "--bare", bare_dir],
check=True,
capture_output=True,
)
clone_dir = tempfile.mkdtemp(prefix="ca-test-clone-")
subprocess.run(
["git", "clone", bare_dir, clone_dir],
check=True,
capture_output=True,
)
# Configure user for commits
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=clone_dir,
check=True,
capture_output=True,
)
# Make an initial commit so the repo is not empty
readme = os.path.join(clone_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(
["git", "add", "README.md"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "push", "origin", "master"],
cwd=clone_dir,
check=True,
capture_output=True,
)
return clone_dir, bare_dir
# ---------------------------------------------------------------------------
# Domain model — RepoRef
# ---------------------------------------------------------------------------
@when("I try to create a RepoRef with an empty repo_path")
def step_reporef_empty_path(context: Any) -> None:
context.caught_exception = None
try:
RepoRef(repo_path="", remote="origin")
except ValueError as exc:
context.caught_exception = exc
@when("I try to create a RepoRef with an empty remote")
def step_reporef_empty_remote(context: Any) -> None:
context.caught_exception = None
try:
RepoRef(repo_path="/tmp/repo", remote="")
except ValueError as exc:
context.caught_exception = exc
@then('a remote_repos ValueError should be raised containing "{text}"')
def step_check_valueerror_message_rr(context: Any, text: str) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
assert text in str(context.caught_exception)
@when('I create a RepoRef with repo_path "{path}" and remote "{remote}"')
def step_create_valid_reporef(context: Any, path: str, remote: str) -> None:
context.repo_ref = RepoRef(repo_path=path, remote=remote)
@then('the RepoRef repo_path should be "{expected}"')
def step_check_reporef_path(context: Any, expected: str) -> None:
assert context.repo_ref.repo_path == expected
@then('the RepoRef remote should be "{expected}"')
def step_check_reporef_remote(context: Any, expected: str) -> None:
assert context.repo_ref.remote == expected
# ---------------------------------------------------------------------------
# Domain model — enumerations
# ---------------------------------------------------------------------------
@then("RepoOperationType should have members: push, pull, fetch, status")
def step_check_operation_type_members(context: Any) -> None:
expected = {"push", "pull", "fetch", "status"}
actual = {m.value for m in RepoOperationType}
assert expected == actual, f"Expected {expected}, got {actual}"
@then(
"RepoSyncState should have members: up_to_date, ahead, behind, "
"diverged, unknown, detached, no_remote"
)
def step_check_sync_state_members(context: Any) -> None:
expected = {
"up_to_date",
"ahead",
"behind",
"diverged",
"unknown",
"detached",
"no_remote",
}
actual = {m.value for m in RepoSyncState}
assert expected == actual, f"Expected {expected}, got {actual}"
@then("ConflictStrategy should have members: abort, ours, theirs")
def step_check_conflict_strategy_members(context: Any) -> None:
expected = {"abort", "ours", "theirs"}
actual = {m.value for m in ConflictStrategy}
assert expected == actual, f"Expected {expected}, got {actual}"
# ---------------------------------------------------------------------------
# Domain model — result serialization
# ---------------------------------------------------------------------------
@given("a RepoStatusResult with sync_state up_to_date")
def step_given_status_result(context: Any) -> None:
context.result_obj = RepoStatusResult(
repo_path="/tmp/repo",
current_branch="main",
sync_state=RepoSyncState.UP_TO_DATE,
head_commit="abc1234",
)
@when("I call to_dict on the status result")
def step_call_status_to_dict(context: Any) -> None:
context.result_dict = context.result_obj.to_dict()
@given("a RepoFetchResult with success True")
def step_given_fetch_result(context: Any) -> None:
context.result_obj = RepoFetchResult(
repo_path="/tmp/repo",
remote="origin",
updated_refs=["origin/main"],
success=True,
)
@when("I call to_dict on the fetch result")
def step_call_fetch_to_dict(context: Any) -> None:
context.result_dict = context.result_obj.to_dict()
@given("a RepoPullResult with conflict_files")
def step_given_pull_result_conflict(context: Any) -> None:
context.result_obj = RepoPullResult(
repo_path="/tmp/repo",
remote="origin",
branch="main",
success=False,
conflict_files=["src/foo.py"],
)
@when("I call to_dict on the pull result")
def step_call_pull_to_dict(context: Any) -> None:
context.result_dict = context.result_obj.to_dict()
@given("a RepoPushResult with rejected True")
def step_given_push_result_rejected(context: Any) -> None:
context.result_obj = RepoPushResult(
repo_path="/tmp/repo",
remote="origin",
branch="main",
success=False,
rejected=True,
error="non-fast-forward",
)
@when("I call to_dict on the push result")
def step_call_push_to_dict(context: Any) -> None:
context.result_dict = context.result_obj.to_dict()
@given("a ConflictResolutionResult with strategy abort")
def step_given_resolution_result(context: Any) -> None:
context.result_obj = ConflictResolutionResult(
repo_path="/tmp/repo",
strategy=ConflictStrategy.ABORT,
success=True,
)
@when("I call to_dict on the resolution result")
def step_call_resolution_to_dict(context: Any) -> None:
context.result_dict = context.result_obj.to_dict()
@then('the dict should contain key "{key}" with value "{value}"')
def step_check_dict_key_value_str(context: Any, key: str, value: str) -> None:
d = context.result_dict
assert key in d, f"Key '{key}' not in dict: {d}"
assert str(d[key]) == value, f"Expected '{value}', got '{d[key]}'"
@then('the dict should contain key "{key}" with value True')
def step_check_dict_key_value_true(context: Any, key: str) -> None:
d = context.result_dict
assert key in d, f"Key '{key}' not in dict: {d}"
assert d[key] is True, f"Expected True, got {d[key]}"
@then('the dict should contain key "{key}" with value False')
def step_check_dict_key_value_false(context: Any, key: str) -> None:
d = context.result_dict
assert key in d, f"Key '{key}' not in dict: {d}"
assert d[key] is False, f"Expected False, got {d[key]}"
@then('the dict should contain key "{key}"')
def step_check_dict_has_key(context: Any, key: str) -> None:
d = context.result_dict
assert key in d, f"Key '{key}' not in dict: {d}"
# ---------------------------------------------------------------------------
# RemoteRepoService — construction
# ---------------------------------------------------------------------------
@when("I try to create a RemoteRepoService with git_timeout {timeout:d}")
def step_create_service_bad_timeout(context: Any, timeout: int) -> None:
context.caught_exception = None
try:
RemoteRepoService(git_timeout=timeout)
except ValueError as exc:
context.caught_exception = exc
@then("a ValueError should be raised for invalid timeout")
def step_check_valueerror_timeout(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
@when("I create a RemoteRepoService with git_timeout {timeout:d}")
def step_create_service_ok(context: Any, timeout: int) -> None:
context.repo_service = RemoteRepoService(git_timeout=timeout)
@then("the service should be created successfully")
def step_check_service_created(context: Any) -> None:
assert context.repo_service is not None
# ---------------------------------------------------------------------------
# RemoteRepoService — type validation
# ---------------------------------------------------------------------------
@given("a RemoteRepoService")
def step_given_service(context: Any) -> None:
context.repo_service = RemoteRepoService()
@when("I call status with a non-RepoRef argument")
def step_status_non_reporef(context: Any) -> None:
context.caught_exception = None
try:
context.repo_service.status("not-a-ref") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@when("I call fetch with a non-RepoRef argument")
def step_fetch_non_reporef(context: Any) -> None:
context.caught_exception = None
try:
context.repo_service.fetch("not-a-ref") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@when("I call pull with a non-RepoRef argument")
def step_pull_non_reporef(context: Any) -> None:
context.caught_exception = None
try:
context.repo_service.pull("not-a-ref") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@when("I call push with a non-RepoRef argument")
def step_push_non_reporef(context: Any) -> None:
context.caught_exception = None
try:
context.repo_service.push("not-a-ref") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@when("I call resolve_conflicts with a non-RepoRef argument")
def step_resolve_non_reporef(context: Any) -> None:
context.caught_exception = None
try:
context.repo_service.resolve_conflicts(
"not-a-ref", # type: ignore[arg-type]
ConflictStrategy.ABORT,
)
except TypeError as exc:
context.caught_exception = exc
@when("I call resolve_conflicts with a non-ConflictStrategy argument")
def step_resolve_non_strategy(context: Any) -> None:
context.caught_exception = None
try:
ref = RepoRef(repo_path="/tmp/nonexistent", remote="origin")
context.repo_service.resolve_conflicts(
ref,
"not-a-strategy", # type: ignore[arg-type]
)
except TypeError as exc:
context.caught_exception = exc
@then("a TypeError should be raised for invalid ref")
def step_check_typeerror_ref(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
@then("a TypeError should be raised for invalid strategy")
def step_check_typeerror_strategy(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
# ---------------------------------------------------------------------------
# RemoteRepoService — validation errors
# ---------------------------------------------------------------------------
@when("I call status with a non-existent repo path")
def step_status_nonexistent(context: Any) -> None:
context.caught_exception = None
try:
ref = RepoRef(repo_path="/tmp/nonexistent-repo-12345")
context.repo_service.status(ref)
except ValidationError as exc:
context.caught_exception = exc
@given("a temporary directory that is not a git repo")
def step_given_non_git_dir(context: Any) -> None:
context.non_git_dir = tempfile.mkdtemp(prefix="ca-test-nongit-")
@when("I call fetch with the non-git directory")
def step_fetch_non_git(context: Any) -> None:
context.caught_exception = None
try:
ref = RepoRef(repo_path=context.non_git_dir)
context.repo_service.fetch(ref)
except ValidationError as exc:
context.caught_exception = exc
@then("a ValidationError should be raised for invalid repo")
def step_check_validation_error_repo(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValidationError"
assert isinstance(context.caught_exception, ValidationError)
# ---------------------------------------------------------------------------
# RemoteRepoService — real git repo operations
# ---------------------------------------------------------------------------
@given("a temporary git repo with a remote")
def step_given_temp_git_repo(context: Any) -> None:
clone_dir, bare_dir = _create_temp_git_repos()
context.temp_repo_path = clone_dir
context.temp_bare_path = bare_dir
context.temp_repo_ref = RepoRef(repo_path=clone_dir, remote="origin")
@given("a local commit in the temporary repo")
def step_given_local_commit(context: Any) -> None:
fpath = os.path.join(context.temp_repo_path, "local_change.txt")
with open(fpath, "w") as f:
f.write("local change\n")
subprocess.run(
["git", "add", "local_change.txt"],
cwd=context.temp_repo_path,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "local commit"],
cwd=context.temp_repo_path,
check=True,
capture_output=True,
)
@given("an uncommitted file in the temporary repo")
def step_given_uncommitted_file(context: Any) -> None:
fpath = os.path.join(context.temp_repo_path, "uncommitted.txt")
with open(fpath, "w") as f:
f.write("uncommitted\n")
@given("a remote commit on the upstream repo")
def step_given_remote_commit(context: Any) -> None:
"""Push a commit from a second clone to simulate remote changes."""
second_clone = tempfile.mkdtemp(prefix="ca-test-clone2-")
subprocess.run(
["git", "clone", context.temp_bare_path, second_clone],
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=second_clone,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Other User"],
cwd=second_clone,
check=True,
capture_output=True,
)
fpath = os.path.join(second_clone, "remote_change.txt")
with open(fpath, "w") as f:
f.write("remote change\n")
subprocess.run(
["git", "add", "remote_change.txt"],
cwd=second_clone,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "remote commit"],
cwd=second_clone,
check=True,
capture_output=True,
)
subprocess.run(
["git", "push", "origin", "master"],
cwd=second_clone,
check=True,
capture_output=True,
)
# Fetch in our clone so we know the remote is ahead
subprocess.run(
["git", "fetch", "origin"],
cwd=context.temp_repo_path,
check=True,
capture_output=True,
)
@when("I call status on the temporary repo")
def step_call_status(context: Any) -> None:
context.status_result = context.repo_service.status(context.temp_repo_ref)
@then('the status sync_state should be "{expected}"')
def step_check_sync_state(context: Any, expected: str) -> None:
actual = context.status_result.sync_state.value
assert actual == expected, f"Expected sync_state '{expected}', got '{actual}'"
@then("the status has_uncommitted should be False")
def step_check_no_uncommitted(context: Any) -> None:
assert context.status_result.has_uncommitted is False
@then("the status has_uncommitted should be True")
def step_check_has_uncommitted(context: Any) -> None:
assert context.status_result.has_uncommitted is True
@then("the status head_commit should be non-empty")
def step_check_head_commit(context: Any) -> None:
assert context.status_result.head_commit, "head_commit should be non-empty"
@then("the status ahead_count should be greater than 0")
def step_check_ahead_count(context: Any) -> None:
assert context.status_result.ahead_count > 0, (
f"Expected ahead_count > 0, got {context.status_result.ahead_count}"
)
@when("I call fetch on the temporary repo")
def step_call_fetch(context: Any) -> None:
context.fetch_result = context.repo_service.fetch(context.temp_repo_ref)
@then("the fetch result success should be True")
def step_check_fetch_success(context: Any) -> None:
assert context.fetch_result.success is True, (
f"Expected fetch success, got error: {context.fetch_result.error}"
)
@when("I call push on the temporary repo")
def step_call_push(context: Any) -> None:
context.push_result = context.repo_service.push(context.temp_repo_ref)
@then("the push result success should be True")
def step_check_push_success(context: Any) -> None:
assert context.push_result.success is True, (
f"Expected push success, got error: {context.push_result.error}"
)
@when("I call pull on the temporary repo")
def step_call_pull(context: Any) -> None:
context.pull_result = context.repo_service.pull(context.temp_repo_ref)
@then("the pull result success should be True")
def step_check_pull_success(context: Any) -> None:
assert context.pull_result.success is True, (
f"Expected pull success, got error: {context.pull_result.error}"
)
# ---------------------------------------------------------------------------
# A2A facade wiring
# ---------------------------------------------------------------------------
def _create_mock_repo_service() -> MagicMock:
"""Create a mock RemoteRepoService that returns valid result objects."""
mock_svc = MagicMock(spec=RemoteRepoService)
mock_svc.status.return_value = RepoStatusResult(
repo_path="/tmp/test",
current_branch="main",
sync_state=RepoSyncState.UP_TO_DATE,
head_commit="abc1234",
)
mock_svc.fetch.return_value = RepoFetchResult(
repo_path="/tmp/test",
remote="origin",
updated_refs=["origin/main"],
success=True,
)
mock_svc.pull.return_value = RepoPullResult(
repo_path="/tmp/test",
remote="origin",
branch="main",
success=True,
new_head="def5678",
)
mock_svc.push.return_value = RepoPushResult(
repo_path="/tmp/test",
remote="origin",
branch="main",
success=True,
pushed_commits=1,
)
return mock_svc
@given("an A2aLocalFacade with no remote_repo_service")
def step_given_facade_no_repo(context: Any) -> None:
context.facade = A2aLocalFacade()
@when('I dispatch operation "{operation}" with params')
def step_dispatch_no_params(context: Any, operation: str) -> None:
req = A2aRequest(operation=operation, params={})
context.a2a_response = context.facade.dispatch(req)
@then('the A2A response should have status "{expected}"')
def step_check_a2a_response_status(context: Any, expected: str) -> None:
assert context.a2a_response.status == expected, (
f"Expected '{expected}', got '{context.a2a_response.status}'"
)
@given("an A2aLocalFacade with a mock remote_repo_service")
def step_given_facade_with_mock(context: Any) -> None:
mock_svc = _create_mock_repo_service()
context.facade = A2aLocalFacade(services={"remote_repo_service": mock_svc})
@when('I dispatch operation "{operation}" with a valid repo_path')
def step_dispatch_with_repo_path(context: Any, operation: str) -> None:
req = A2aRequest(
operation=operation,
params={"repo_path": "/tmp/test"},
)
context.a2a_response = context.facade.dispatch(req)
@then('the A2A response data should contain "{key}"')
def step_check_a2a_data_key(context: Any, key: str) -> None:
data = context.a2a_response.data
assert key in data, f"Key '{key}' not in A2A data: {data}"
# ---------------------------------------------------------------------------
# ASGI endpoint wiring
# ---------------------------------------------------------------------------
@given("a running ASGI test client with remote_repo_service")
def step_given_asgi_client_with_repo(context: Any) -> None:
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
mock_svc = _create_mock_repo_service()
facade = A2aLocalFacade(services={"remote_repo_service": mock_svc})
app = create_asgi_app(facade=facade)
context.test_client = TestClient(app)
@when("I POST a repo/status request to /a2a")
def step_post_repo_status(context: Any) -> None:
payload = {
"operation": "_cleveragents/repo/status",
"params": {"repo_path": "/tmp/test"},
}
context.response = context.test_client.post("/a2a", json=payload)
@when("I POST a repo/fetch request to /a2a")
def step_post_repo_fetch(context: Any) -> None:
payload = {
"operation": "_cleveragents/repo/fetch",
"params": {"repo_path": "/tmp/test"},
}
context.response = context.test_client.post("/a2a", json=payload)
# ---------------------------------------------------------------------------
# Operations listing
# ---------------------------------------------------------------------------
@when("I list operations from a new A2aLocalFacade")
def step_list_operations(context: Any) -> None:
facade = A2aLocalFacade()
context.operations_list = facade.list_operations()
@then('the operations should include "{operation}"')
def step_check_operation_listed(context: Any, operation: str) -> None:
assert operation in context.operations_list, f"'{operation}' not in operations list"
+286
View File
@@ -0,0 +1,286 @@
"""Step definitions for ``server_lifecycle.feature``.
Tests the ASGI application factory, health/agent-card endpoints,
A2A JSON-RPC dispatch, ServerLifecycle construction and shutdown,
and Settings-based server configuration.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, use_step_matcher, when # type: ignore[import-untyped]
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
# ---------------------------------------------------------------------------
# ASGI application creation
# ---------------------------------------------------------------------------
@when("I create an ASGI application with default settings")
def step_create_asgi_default(context: Any) -> None:
context.asgi_app = create_asgi_app()
@given("an A2aLocalFacade with no services")
def step_given_facade(context: Any) -> None:
context.facade = A2aLocalFacade()
@when("I create an ASGI application with that facade")
def step_create_asgi_with_facade(context: Any) -> None:
context.asgi_app = create_asgi_app(facade=context.facade)
@then("the ASGI app should be a FastAPI instance")
def step_check_fastapi_instance(context: Any) -> None:
from fastapi import FastAPI
assert isinstance(context.asgi_app, FastAPI), (
f"Expected FastAPI, got {type(context.asgi_app)}"
)
@when('I try to create an ASGI application with facade "{value}"')
def step_create_asgi_bad_facade(context: Any, value: str) -> None:
context.caught_exception = None
try:
create_asgi_app(facade=value) # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@then("a TypeError should be raised for invalid facade")
def step_check_type_error_facade(context: Any) -> None:
assert context.caught_exception is not None, "Expected TypeError"
assert isinstance(context.caught_exception, TypeError)
@when("I try to create an ASGI application with port {port:d}")
def step_create_asgi_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
try:
create_asgi_app(port=port)
except ValueError as exc:
context.caught_exception = exc
@then("a ValueError should be raised for invalid port")
def step_check_value_error_port(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
use_step_matcher("re")
@when('I try to create an ASGI application with host "(?P<host>[^"]*)"')
def step_create_asgi_bad_host(context: Any, host: str) -> None:
context.caught_exception = None
try:
create_asgi_app(host=host)
except ValueError as exc:
context.caught_exception = exc
use_step_matcher("parse")
@then("a ValueError should be raised for invalid host")
def step_check_value_error_host(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
# ---------------------------------------------------------------------------
# Test client helpers
# ---------------------------------------------------------------------------
@given("a running ASGI test client")
def step_given_test_client(context: Any) -> None:
from fastapi.testclient import TestClient
app = create_asgi_app()
context.test_client = TestClient(app)
@when("I request GET /health")
def step_get_health(context: Any) -> None:
context.response = context.test_client.get("/health")
@when("I request GET /.well-known/agent.json")
def step_get_agent_card(context: Any) -> None:
context.response = context.test_client.get("/.well-known/agent.json")
@when('I POST a JSON-RPC request to /a2a with operation "{operation}"')
def step_post_a2a(context: Any, operation: str) -> None:
payload = {"operation": operation, "params": {}}
context.response = context.test_client.post("/a2a", json=payload)
@when("I POST a malformed JSON body to /a2a")
def step_post_a2a_malformed(context: Any) -> None:
# Send a body that cannot be parsed into A2aRequest (missing operation)
context.response = context.test_client.post("/a2a", json={"bad": "data"})
@then("the response status code should be {code:d}")
def step_check_status_code(context: Any, code: int) -> None:
assert context.response.status_code == code, (
f"Expected {code}, got {context.response.status_code}: {context.response.text}"
)
@then('the response body should contain "{text}"')
def step_check_response_body(context: Any, text: str) -> None:
body = context.response.text
assert text in body, f"Expected '{text}' in response body: {body}"
@then('the A2A response status should be "{expected_status}"')
def step_check_a2a_status(context: Any, expected_status: str) -> None:
data = context.response.json()
assert data.get("status") == expected_status, (
f"Expected A2A status '{expected_status}', got {data}"
)
# ---------------------------------------------------------------------------
# ServerLifecycle construction
# ---------------------------------------------------------------------------
@when('I create a ServerLifecycle with host "{host}" and port {port:d}')
def step_create_lifecycle(context: Any, host: str, port: int) -> None:
context.lifecycle = ServerLifecycle(host=host, port=port)
@then('the lifecycle host should be "{expected}"')
def step_check_lifecycle_host(context: Any, expected: str) -> None:
assert context.lifecycle.host == expected
@then("the lifecycle port should be {expected:d}")
def step_check_lifecycle_port(context: Any, expected: int) -> None:
assert context.lifecycle.port == expected
@then("the lifecycle should not be started")
def step_check_not_started(context: Any) -> None:
assert not context.lifecycle.is_started
@then("the lifecycle should not be stopped")
def step_check_not_stopped(context: Any) -> None:
assert not context.lifecycle.is_stopped
use_step_matcher("re")
@when('I try to create a ServerLifecycle with host "(?P<host>[^"]*)"')
def step_create_lifecycle_bad_host(context: Any, host: str) -> None:
context.caught_exception = None
try:
ServerLifecycle(host=host)
except ValueError as exc:
context.caught_exception = exc
use_step_matcher("parse")
@when("I try to create a ServerLifecycle with port {port:d}")
def step_create_lifecycle_bad_port(context: Any, port: int) -> None:
context.caught_exception = None
try:
ServerLifecycle(port=port)
except ValueError as exc:
context.caught_exception = exc
@when("I try to create a ServerLifecycle with empty log_level")
def step_create_lifecycle_bad_log_level(context: Any) -> None:
context.caught_exception = None
try:
ServerLifecycle(log_level="")
except ValueError as exc:
context.caught_exception = exc
@then("a ValueError should be raised for invalid log_level")
def step_check_value_error_log_level(context: Any) -> None:
assert context.caught_exception is not None, "Expected ValueError"
assert isinstance(context.caught_exception, ValueError)
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@then('the default server host from Settings should be "{expected}"')
def step_check_settings_host(context: Any, expected: str) -> None:
settings = Settings()
assert settings.server_host == expected
@then("the default server port from Settings should be {expected:d}")
def step_check_settings_port(context: Any, expected: int) -> None:
settings = Settings()
assert settings.server_port == expected
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
@given("a ServerLifecycle with a mock uvicorn server")
def step_lifecycle_with_mock_server(context: Any) -> None:
lifecycle = ServerLifecycle(host="127.0.0.1", port=9999)
mock_server = MagicMock()
mock_server.should_exit = False
lifecycle._server = mock_server
context.lifecycle = lifecycle
context.mock_server = mock_server
@when("I call request_shutdown on the lifecycle")
def step_call_request_shutdown(context: Any) -> None:
context.lifecycle.request_shutdown()
@then("the mock server should_exit flag should be true")
def step_check_should_exit(context: Any) -> None:
assert context.mock_server.should_exit is True
@given("a ServerLifecycle that has already been started")
def step_lifecycle_already_started(context: Any) -> None:
lifecycle = ServerLifecycle(host="127.0.0.1", port=9998)
lifecycle._started = True
context.lifecycle = lifecycle
@when("I try to start the lifecycle again")
def step_try_double_start(context: Any) -> None:
context.caught_exception = None
try:
context.lifecycle.start()
except RuntimeError as exc:
context.caught_exception = exc
@then("a RuntimeError should be raised for double start")
def step_check_runtime_error(context: Any) -> None:
assert context.caught_exception is not None, "Expected RuntimeError"
assert isinstance(context.caught_exception, RuntimeError)
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
# CLI Framework (ADR-009)
"typer>=0.9.0",
"uvicorn>=0.30.1",
"fastapi>=0.115.0",
"watchdog>=4.0.0",
"faiss-cpu>=1.7.4", # Vector store backend
"rx>=3.2.0", # Reactive streams for routing
+1 -1
View File
@@ -93,7 +93,7 @@ def list_operations() -> None:
facade = A2aLocalFacade()
ops = facade.list_operations()
expected = {"session.create", "plan.create", "plan.execute", "context.get"}
if expected.issubset(set(ops)) and len(ops) == 42:
if expected.issubset(set(ops)) and len(ops) == 46:
print("a2a-list-operations-ok")
else:
print(f"FAIL: ops={ops}", file=sys.stderr)
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Integration test helper for remote repo operations.
Each sub-command exercises a real import / instantiation path and prints
a unique sentinel on success. Robot tests parse the output for that
sentinel.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
def _create_temp_git_repos() -> tuple[str, str]:
"""Create a bare remote repo and a clone with an initial commit."""
bare_dir = tempfile.mkdtemp(prefix="ca-robot-bare-")
subprocess.run(
["git", "init", "--bare", bare_dir],
check=True,
capture_output=True,
)
clone_dir = tempfile.mkdtemp(prefix="ca-robot-clone-")
subprocess.run(
["git", "clone", bare_dir, clone_dir],
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=clone_dir,
check=True,
capture_output=True,
)
readme = os.path.join(clone_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "add", "README.md"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=clone_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "push", "origin", "master"],
cwd=clone_dir,
check=True,
capture_output=True,
)
return clone_dir, bare_dir
def _test_domain_models() -> None:
"""Verify domain model imports and basic construction."""
from cleveragents.domain.models.core.remote_repo import (
ConflictStrategy,
RepoFetchResult,
RepoOperationType,
RepoPullResult,
RepoPushResult,
RepoRef,
RepoStatusResult,
RepoSyncState,
)
ref = RepoRef(repo_path="/tmp/test", remote="origin")
assert ref.repo_path == "/tmp/test"
assert ref.remote == "origin"
assert len(RepoOperationType) == 4
assert len(RepoSyncState) == 7
assert len(ConflictStrategy) == 3
status = RepoStatusResult(
repo_path="/tmp/test",
current_branch="main",
sync_state=RepoSyncState.UP_TO_DATE,
)
d = status.to_dict()
assert d["sync_state"] == "up_to_date"
fetch = RepoFetchResult(repo_path="/tmp/test", remote="origin")
assert fetch.to_dict()["success"] is True
pull = RepoPullResult(repo_path="/tmp/test", remote="origin", branch="main")
assert pull.to_dict()["branch"] == "main"
push = RepoPushResult(repo_path="/tmp/test", remote="origin", branch="main")
assert push.to_dict()["success"] is True
print("domain-models-ok")
def _test_service_construction() -> None:
"""Verify RemoteRepoService can be constructed."""
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
svc = RemoteRepoService(git_timeout=30)
assert svc is not None
try:
RemoteRepoService(git_timeout=0)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
print("service-construction-ok")
def _test_service_status() -> None:
"""Exercise status on a real git repo."""
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.domain.models.core.remote_repo import RepoRef
clone_dir, _bare_dir = _create_temp_git_repos()
svc = RemoteRepoService()
ref = RepoRef(repo_path=clone_dir, remote="origin")
result = svc.status(ref)
assert result.sync_state.value == "up_to_date"
assert result.head_commit
print("service-status-ok")
def _test_service_fetch() -> None:
"""Exercise fetch on a real git repo."""
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.domain.models.core.remote_repo import RepoRef
clone_dir, _bare_dir = _create_temp_git_repos()
svc = RemoteRepoService()
ref = RepoRef(repo_path=clone_dir, remote="origin")
result = svc.fetch(ref)
assert result.success is True
print("service-fetch-ok")
def _test_service_push() -> None:
"""Exercise push on a real git repo."""
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.domain.models.core.remote_repo import RepoRef
clone_dir, _bare_dir = _create_temp_git_repos()
# Create a local commit to push
fpath = os.path.join(clone_dir, "push_test.txt")
with open(fpath, "w") as f:
f.write("push test\n")
subprocess.run(["git", "add", "."], cwd=clone_dir, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "push test"],
cwd=clone_dir,
check=True,
capture_output=True,
)
svc = RemoteRepoService()
ref = RepoRef(repo_path=clone_dir, remote="origin")
result = svc.push(ref)
assert result.success is True
print("service-push-ok")
def _test_facade_wiring() -> None:
"""Verify A2A facade dispatches repo operations to the service."""
from unittest.mock import MagicMock
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.domain.models.core.remote_repo import (
RepoStatusResult,
RepoSyncState,
)
mock_svc = MagicMock(spec=RemoteRepoService)
mock_svc.status.return_value = RepoStatusResult(
repo_path="/tmp/test",
current_branch="main",
sync_state=RepoSyncState.UP_TO_DATE,
head_commit="abc",
)
facade = A2aLocalFacade(services={"remote_repo_service": mock_svc})
req = A2aRequest(
operation="_cleveragents/repo/status",
params={"repo_path": "/tmp/test"},
)
resp = facade.dispatch(req)
assert resp.status == "ok"
assert "sync_state" in resp.data
print("facade-wiring-ok")
def _test_asgi_endpoint() -> None:
"""Verify repo operations route through the ASGI /a2a endpoint."""
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.domain.models.core.remote_repo import (
RepoStatusResult,
RepoSyncState,
)
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
mock_svc = MagicMock(spec=RemoteRepoService)
mock_svc.status.return_value = RepoStatusResult(
repo_path="/tmp/test",
current_branch="main",
sync_state=RepoSyncState.UP_TO_DATE,
head_commit="abc",
)
facade = A2aLocalFacade(services={"remote_repo_service": mock_svc})
app = create_asgi_app(facade=facade)
client = TestClient(app)
payload = {
"operation": "_cleveragents/repo/status",
"params": {"repo_path": "/tmp/test"},
}
resp = client.post("/a2a", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "sync_state" in data["data"]
print("asgi-endpoint-ok")
_COMMANDS: dict[str, object] = {
"domain-models": _test_domain_models,
"service-construction": _test_service_construction,
"service-status": _test_service_status,
"service-fetch": _test_service_fetch,
"service-push": _test_service_push,
"facade-wiring": _test_facade_wiring,
"asgi-endpoint": _test_asgi_endpoint,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
handler = _COMMANDS[sys.argv[1]]
handler() # type: ignore[operator]
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Integration test helper for server lifecycle tests.
Each sub-command exercises a real import / instantiation path and prints
a unique sentinel on success. Robot tests parse the output for that
sentinel.
"""
from __future__ import annotations
import sys
def _test_create_app() -> None:
"""Verify ``create_asgi_app`` returns a FastAPI instance."""
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
assert app is not None
print("create-app-ok")
def _test_health_endpoint() -> None:
"""Exercise the /health endpoint via FastAPI TestClient."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
print("health-endpoint-ok")
def _test_agent_card() -> None:
"""Exercise the /.well-known/agent.json endpoint."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
resp = client.get("/.well-known/agent.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "CleverAgents"
assert "url" in data
print("agent-card-ok")
def _test_a2a_dispatch() -> None:
"""Exercise the /a2a POST endpoint with a health check operation."""
from fastapi.testclient import TestClient
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
app = create_asgi_app()
client = TestClient(app)
payload = {"operation": "_cleveragents/health/check", "params": {}}
resp = client.post("/a2a", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
print("a2a-dispatch-ok")
def _test_lifecycle_construction() -> None:
"""Verify ServerLifecycle can be constructed and inspected."""
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
lc = ServerLifecycle(host="127.0.0.1", port=9123)
assert lc.host == "127.0.0.1"
assert lc.port == 9123
assert not lc.is_started
assert not lc.is_stopped
print("lifecycle-construction-ok")
def _test_lifecycle_shutdown() -> None:
"""Verify request_shutdown sets the should_exit flag on a mock server."""
from unittest.mock import MagicMock
from cleveragents.infrastructure.server.server_lifecycle import ServerLifecycle
lc = ServerLifecycle(host="127.0.0.1", port=9124)
mock_server = MagicMock()
mock_server.should_exit = False
lc._server = mock_server
lc.request_shutdown()
assert mock_server.should_exit is True
print("lifecycle-shutdown-ok")
_COMMANDS: dict[str, object] = {
"create-app": _test_create_app,
"health-endpoint": _test_health_endpoint,
"agent-card": _test_agent_card,
"a2a-dispatch": _test_a2a_dispatch,
"lifecycle-construction": _test_lifecycle_construction,
"lifecycle-shutdown": _test_lifecycle_shutdown,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
handler = _COMMANDS[sys.argv[1]]
handler() # type: ignore[operator]
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
*** Settings ***
Documentation Integration tests for remote repository operations
...
... Tests verify that the domain models, RemoteRepoService,
... A2A facade wiring, and ASGI endpoint dispatch all work
... end-to-end through real imports, git operations, and HTTP.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_remote_repos.py
*** Test Cases ***
Domain Models Import And Construction
[Documentation] Domain model classes import and construct correctly
${result}= Run Process ${PYTHON} ${HELPER} domain-models cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} domain-models-ok
RemoteRepoService Construction
[Documentation] RemoteRepoService validates timeout and constructs
${result}= Run Process ${PYTHON} ${HELPER} service-construction cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} service-construction-ok
RemoteRepoService Status
[Documentation] status returns sync state for a real git repo
${result}= Run Process ${PYTHON} ${HELPER} service-status cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} service-status-ok
RemoteRepoService Fetch
[Documentation] fetch succeeds on a real git repo with a remote
${result}= Run Process ${PYTHON} ${HELPER} service-fetch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} service-fetch-ok
RemoteRepoService Push
[Documentation] push sends local commits to a bare remote
${result}= Run Process ${PYTHON} ${HELPER} service-push cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} service-push-ok
A2A Facade Wiring
[Documentation] Facade dispatches repo operations to service
${result}= Run Process ${PYTHON} ${HELPER} facade-wiring cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} facade-wiring-ok
ASGI Endpoint Dispatch
[Documentation] POST /a2a routes repo operations through ASGI app
${result}= Run Process ${PYTHON} ${HELPER} asgi-endpoint cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} asgi-endpoint-ok
+61
View File
@@ -0,0 +1,61 @@
*** Settings ***
Documentation Integration tests for ASGI server lifecycle
...
... Tests verify that the ASGI application, health endpoint,
... agent card, A2A dispatch, and ServerLifecycle construction
... all work end-to-end through real imports and instantiation.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_server_lifecycle.py
*** Test Cases ***
Create ASGI Application
[Documentation] create_asgi_app returns a FastAPI instance
${result}= Run Process ${PYTHON} ${HELPER} create-app cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} create-app-ok
Health Endpoint Returns Healthy
[Documentation] GET /health returns 200 with status healthy
${result}= Run Process ${PYTHON} ${HELPER} health-endpoint cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} health-endpoint-ok
Agent Card Discovery Endpoint
[Documentation] GET /.well-known/agent.json returns valid agent card
${result}= Run Process ${PYTHON} ${HELPER} agent-card cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} agent-card-ok
A2A JSON-RPC Dispatch
[Documentation] POST /a2a dispatches health check operation via facade
${result}= Run Process ${PYTHON} ${HELPER} a2a-dispatch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-dispatch-ok
ServerLifecycle Construction
[Documentation] ServerLifecycle construction sets host, port, and initial state
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-construction cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lifecycle-construction-ok
ServerLifecycle Graceful Shutdown
[Documentation] request_shutdown sets the should_exit flag
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-shutdown cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lifecycle-shutdown-ok
+81 -7
View File
@@ -13,6 +13,7 @@ constructor. Expected keys:
| ``tool_registry`` | ``ToolRegistry`` | registry.list_tools |
| ``resource_registry_service``| ``ResourceRegistryService`` | registry.list_resources|
| ``event_queue`` | ``A2aEventQueue`` | event.subscribe |
| ``remote_repo_service`` | ``RemoteRepoService`` | repo.* operations |
When a service is absent the handler falls back to a safe stub
response so the facade never crashes due to missing wiring.
@@ -42,6 +43,9 @@ if TYPE_CHECKING:
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.application.services.remote_repo_service import (
RemoteRepoService,
)
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
@@ -89,6 +93,11 @@ _EXTENSION_OPERATIONS: list[str] = [
"_cleveragents/sync/pull",
"_cleveragents/sync/push",
"_cleveragents/sync/status",
# Repo operations
"_cleveragents/repo/push",
"_cleveragents/repo/pull",
"_cleveragents/repo/fetch",
"_cleveragents/repo/status",
# Namespace (stub)
"_cleveragents/namespace/list",
"_cleveragents/namespace/show",
@@ -155,6 +164,11 @@ class A2aLocalFacade:
def _event_queue(self) -> A2aEventQueue | None:
return self._services.get("event_queue") # type: ignore[return-value]
@property
def _remote_repo_service(self) -> RemoteRepoService | None:
svc: object | None = self._services.get("remote_repo_service")
return svc # type: ignore[return-value]
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@@ -281,10 +295,15 @@ class A2aLocalFacade:
# Health / Diagnostics
"_cleveragents/health/check": self._handle_health_check,
"_cleveragents/diagnostics/run": self._handle_diagnostics_run,
# Sync (stub)
"_cleveragents/sync/pull": self._handle_sync_stub,
"_cleveragents/sync/push": self._handle_sync_stub,
"_cleveragents/sync/status": self._handle_sync_stub,
# Sync
"_cleveragents/sync/pull": self._handle_repo_pull,
"_cleveragents/sync/push": self._handle_repo_push,
"_cleveragents/sync/status": self._handle_repo_status,
# Repo operations
"_cleveragents/repo/push": self._handle_repo_push,
"_cleveragents/repo/pull": self._handle_repo_pull,
"_cleveragents/repo/fetch": self._handle_repo_fetch,
"_cleveragents/repo/status": self._handle_repo_status,
# Namespace (stub)
"_cleveragents/namespace/list": self._handle_namespace_stub,
"_cleveragents/namespace/show": self._handle_namespace_stub,
@@ -584,11 +603,66 @@ class A2aLocalFacade:
return {"status": "ok", "diagnostics": {}, "stub": True}
# ------------------------------------------------------------------
# Extension handlers — _cleveragents/sync/* (stubs)
# Extension handlers — _cleveragents/repo/* and sync/*
# ------------------------------------------------------------------
def _handle_sync_stub(self, params: dict[str, Any]) -> dict[str, Any]:
return {"status": "not_implemented", "stub": True}
def _handle_repo_status(self, params: dict[str, Any]) -> dict[str, Any]:
svc = self._remote_repo_service
if svc is None:
return {"status": "not_implemented", "stub": True}
repo_path = params.get("repo_path", "")
if not repo_path:
raise ValueError("repo_path is required")
remote = params.get("remote", "origin")
from cleveragents.domain.models.core.remote_repo import RepoRef
ref = RepoRef(repo_path=repo_path, remote=remote)
result = svc.status(ref)
return result.to_dict()
def _handle_repo_fetch(self, params: dict[str, Any]) -> dict[str, Any]:
svc = self._remote_repo_service
if svc is None:
return {"status": "not_implemented", "stub": True}
repo_path = params.get("repo_path", "")
if not repo_path:
raise ValueError("repo_path is required")
remote = params.get("remote", "origin")
from cleveragents.domain.models.core.remote_repo import RepoRef
ref = RepoRef(repo_path=repo_path, remote=remote)
result = svc.fetch(ref)
return result.to_dict()
def _handle_repo_pull(self, params: dict[str, Any]) -> dict[str, Any]:
svc = self._remote_repo_service
if svc is None:
return {"status": "not_implemented", "stub": True}
repo_path = params.get("repo_path", "")
if not repo_path:
raise ValueError("repo_path is required")
remote = params.get("remote", "origin")
branch = params.get("branch")
from cleveragents.domain.models.core.remote_repo import RepoRef
ref = RepoRef(repo_path=repo_path, remote=remote, branch=branch)
result = svc.pull(ref)
return result.to_dict()
def _handle_repo_push(self, params: dict[str, Any]) -> dict[str, Any]:
svc = self._remote_repo_service
if svc is None:
return {"status": "not_implemented", "stub": True}
repo_path = params.get("repo_path", "")
if not repo_path:
raise ValueError("repo_path is required")
remote = params.get("remote", "origin")
branch = params.get("branch")
from cleveragents.domain.models.core.remote_repo import RepoRef
ref = RepoRef(repo_path=repo_path, remote=remote, branch=branch)
result = svc.push(ref)
return result.to_dict()
# ------------------------------------------------------------------
# Extension handlers — _cleveragents/namespace/* (stubs)
@@ -0,0 +1,659 @@
"""Remote repository operations service.
Provides server-side push, pull, fetch, and status operations on
git repositories managed by the CleverAgents server. Clients
interact with repositories through the A2A protocol without
needing local clones all git commands are executed on the server.
The service wraps git subprocess calls with timeout handling,
validation, and structured result types so that the A2A facade can
relay operation outcomes back to clients in a uniform format.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from cleveragents.core.exceptions import (
InfrastructureError,
ValidationError,
)
from cleveragents.domain.models.core.remote_repo import (
ConflictResolutionResult,
ConflictStrategy,
RepoFetchResult,
RepoPullResult,
RepoPushResult,
RepoRef,
RepoStatusResult,
RepoSyncState,
)
logger = logging.getLogger(__name__)
_GIT_TIMEOUT: int = 60
_AHEAD_BEHIND_RE: re.Pattern[str] = re.compile(
r"\[ahead\s+(\d+)(?:,\s*behind\s+(\d+))?\]"
r"|\[behind\s+(\d+)\]"
)
def _run_git(
args: list[str],
cwd: str,
timeout: int = _GIT_TIMEOUT,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Execute a git command with timeout and output capture.
Args:
args: Git sub-command and arguments.
cwd: Working directory (must be a git repository root).
timeout: Maximum seconds to wait.
check: Whether to raise on non-zero return code.
Returns:
Completed process result.
Raises:
subprocess.TimeoutExpired: If the command exceeds *timeout*.
subprocess.CalledProcessError: If *check* is True and rc != 0.
"""
cmd = ["git", *args]
logger.debug("git: %s (cwd=%s)", " ".join(cmd), cwd)
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=check,
timeout=timeout,
)
def _validate_git_repo(repo_path: str) -> None:
"""Verify that *repo_path* is the root of a git repository.
Args:
repo_path: Filesystem path to validate.
Raises:
ValidationError: If *repo_path* does not exist or is not a git
repository root.
"""
if not repo_path:
raise ValidationError("repo_path must be a non-empty string")
if not os.path.isdir(repo_path):
raise ValidationError(f"repo_path does not exist: {repo_path}")
git_dir = os.path.join(repo_path, ".git")
if not os.path.exists(git_dir):
raise ValidationError(f"repo_path is not a git repository: {repo_path}")
class RemoteRepoService:
"""Server-side remote repository operations.
All operations accept a :class:`RepoRef` identifying the target
repository. Git commands are executed as subprocesses with a
configurable timeout (default 60 s).
Example::
svc = RemoteRepoService()
ref = RepoRef(repo_path="/srv/repos/my-project")
status = svc.status(ref)
print(status.sync_state)
The service never mutates global git configuration.
"""
def __init__(self, git_timeout: int = _GIT_TIMEOUT) -> None:
"""Initialise the service.
Args:
git_timeout: Default timeout in seconds for git commands.
Raises:
ValueError: If *git_timeout* is not positive.
"""
if not isinstance(git_timeout, int) or git_timeout <= 0:
raise ValueError("git_timeout must be a positive integer")
self._git_timeout = git_timeout
# ------------------------------------------------------------------
# status
# ------------------------------------------------------------------
def status(self, ref: RepoRef) -> RepoStatusResult:
"""Query the synchronization state of a repository.
Args:
ref: Repository reference.
Returns:
A :class:`RepoStatusResult` describing the current state.
Raises:
ValidationError: If the repository path is invalid.
InfrastructureError: On git command failures.
"""
if not isinstance(ref, RepoRef):
raise TypeError("ref must be a RepoRef instance")
_validate_git_repo(ref.repo_path)
try:
# Current branch
branch_result = _run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
current_branch = branch_result.stdout.strip()
# HEAD commit
head_result = _run_git(
["rev-parse", "--short", "HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
head_commit = head_result.stdout.strip()
# Uncommitted changes
status_result = _run_git(
["status", "--porcelain"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
has_uncommitted = bool(status_result.stdout.strip())
# Remote URL
remote_url = ""
try:
url_result = _run_git(
["remote", "get-url", ref.remote],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
remote_url = url_result.stdout.strip()
except subprocess.CalledProcessError:
# Remote does not exist
return RepoStatusResult(
repo_path=ref.repo_path,
current_branch=current_branch,
sync_state=RepoSyncState.NO_REMOTE,
has_uncommitted=has_uncommitted,
remote=ref.remote,
remote_url="",
head_commit=head_commit,
)
# Detached HEAD
if current_branch == "HEAD":
return RepoStatusResult(
repo_path=ref.repo_path,
current_branch=current_branch,
sync_state=RepoSyncState.DETACHED,
has_uncommitted=has_uncommitted,
remote=ref.remote,
remote_url=remote_url,
head_commit=head_commit,
)
# Ahead/behind counts
tracking = f"{ref.remote}/{current_branch}"
ahead = 0
behind = 0
try:
ab_result = _run_git(
[
"rev-list",
"--left-right",
"--count",
f"{tracking}...HEAD",
],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
parts = ab_result.stdout.strip().split()
if len(parts) == 2:
behind = int(parts[0])
ahead = int(parts[1])
except (subprocess.CalledProcessError, ValueError):
return RepoStatusResult(
repo_path=ref.repo_path,
current_branch=current_branch,
sync_state=RepoSyncState.UNKNOWN,
has_uncommitted=has_uncommitted,
remote=ref.remote,
remote_url=remote_url,
head_commit=head_commit,
)
# Determine sync state
if ahead > 0 and behind > 0:
sync_state = RepoSyncState.DIVERGED
elif ahead > 0:
sync_state = RepoSyncState.AHEAD
elif behind > 0:
sync_state = RepoSyncState.BEHIND
else:
sync_state = RepoSyncState.UP_TO_DATE
return RepoStatusResult(
repo_path=ref.repo_path,
current_branch=current_branch,
sync_state=sync_state,
ahead_count=ahead,
behind_count=behind,
has_uncommitted=has_uncommitted,
remote=ref.remote,
remote_url=remote_url,
head_commit=head_commit,
)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Git command timed out after {self._git_timeout}s "
f"while querying status of {ref.repo_path}"
) from exc
except subprocess.CalledProcessError as exc:
raise InfrastructureError(
f"Git status failed for {ref.repo_path}: {exc.stderr.strip()}"
) from exc
# ------------------------------------------------------------------
# fetch
# ------------------------------------------------------------------
def fetch(self, ref: RepoRef) -> RepoFetchResult:
"""Fetch refs from the remote without merging.
Args:
ref: Repository reference.
Returns:
A :class:`RepoFetchResult` describing updated refs.
Raises:
ValidationError: If the repository path is invalid.
InfrastructureError: On git command failures.
"""
if not isinstance(ref, RepoRef):
raise TypeError("ref must be a RepoRef instance")
_validate_git_repo(ref.repo_path)
try:
result = _run_git(
["fetch", ref.remote, "--prune"],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
if result.returncode != 0:
return RepoFetchResult(
repo_path=ref.repo_path,
remote=ref.remote,
success=False,
error=result.stderr.strip() or "fetch failed",
)
# Parse updated refs from stderr (git fetch reports to stderr)
updated_refs: list[str] = []
for line in result.stderr.splitlines():
stripped = line.strip()
if stripped and "->" in stripped:
# Lines like " abc1234..def5678 main -> origin/main"
parts = stripped.split()
for part in parts:
if "/" in part and not part.startswith("("):
updated_refs.append(part)
return RepoFetchResult(
repo_path=ref.repo_path,
remote=ref.remote,
updated_refs=updated_refs,
success=True,
)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Git fetch timed out after {self._git_timeout}s for {ref.repo_path}"
) from exc
# ------------------------------------------------------------------
# pull
# ------------------------------------------------------------------
def pull(self, ref: RepoRef) -> RepoPullResult:
"""Pull (fetch + merge) from the remote.
If a merge conflict occurs, the merge is aborted and the
conflict files are reported in the result.
Args:
ref: Repository reference.
Returns:
A :class:`RepoPullResult` describing the outcome.
Raises:
ValidationError: If the repository path is invalid.
InfrastructureError: On git command timeout.
"""
if not isinstance(ref, RepoRef):
raise TypeError("ref must be a RepoRef instance")
_validate_git_repo(ref.repo_path)
branch = ref.branch
if not branch:
try:
br_result = _run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
branch = br_result.stdout.strip()
except subprocess.CalledProcessError as exc:
raise InfrastructureError(
f"Cannot determine current branch: {exc.stderr.strip()}"
) from exc
try:
result = _run_git(
["pull", ref.remote, branch, "--no-edit"],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
if result.returncode != 0:
# Check if it's a merge conflict
conflict_files = self._detect_conflicts(ref.repo_path)
if conflict_files:
# Abort the failed merge to leave the repo clean
_run_git(
["merge", "--abort"],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
return RepoPullResult(
repo_path=ref.repo_path,
remote=ref.remote,
branch=branch,
success=False,
conflict_files=conflict_files,
error="Merge conflict detected",
)
return RepoPullResult(
repo_path=ref.repo_path,
remote=ref.remote,
branch=branch,
success=False,
error=(
result.stderr.strip() or result.stdout.strip() or "pull failed"
),
)
# Parse updated files from output
updated_files = self._parse_updated_files(result.stdout)
# Get new HEAD
head_result = _run_git(
["rev-parse", "--short", "HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
return RepoPullResult(
repo_path=ref.repo_path,
remote=ref.remote,
branch=branch,
updated_files=updated_files,
success=True,
new_head=head_result.stdout.strip(),
)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Git pull timed out after {self._git_timeout}s for {ref.repo_path}"
) from exc
# ------------------------------------------------------------------
# push
# ------------------------------------------------------------------
def push(self, ref: RepoRef) -> RepoPushResult:
"""Push local commits to the remote.
Args:
ref: Repository reference.
Returns:
A :class:`RepoPushResult` describing the outcome.
Raises:
ValidationError: If the repository path is invalid.
InfrastructureError: On git command timeout.
"""
if not isinstance(ref, RepoRef):
raise TypeError("ref must be a RepoRef instance")
_validate_git_repo(ref.repo_path)
branch = ref.branch
if not branch:
try:
br_result = _run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
branch = br_result.stdout.strip()
except subprocess.CalledProcessError as exc:
raise InfrastructureError(
f"Cannot determine current branch: {exc.stderr.strip()}"
) from exc
# Count commits to push
pushed_commits = 0
try:
tracking = f"{ref.remote}/{branch}"
log_result = _run_git(
["rev-list", "--count", f"{tracking}..HEAD"],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
if log_result.returncode == 0:
pushed_commits = int(log_result.stdout.strip())
except (subprocess.CalledProcessError, ValueError):
pass
try:
result = _run_git(
["push", ref.remote, branch],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.strip()
rejected = (
"rejected" in stderr.lower() or "non-fast-forward" in stderr.lower()
)
return RepoPushResult(
repo_path=ref.repo_path,
remote=ref.remote,
branch=branch,
success=False,
rejected=rejected,
error=stderr or "push failed",
)
return RepoPushResult(
repo_path=ref.repo_path,
remote=ref.remote,
branch=branch,
success=True,
pushed_commits=pushed_commits,
)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Git push timed out after {self._git_timeout}s for {ref.repo_path}"
) from exc
# ------------------------------------------------------------------
# resolve_conflicts
# ------------------------------------------------------------------
def resolve_conflicts(
self,
ref: RepoRef,
strategy: ConflictStrategy,
) -> ConflictResolutionResult:
"""Resolve merge conflicts using the specified strategy.
This method is intended to be called after a failed
:meth:`pull` that reported conflict files.
Args:
ref: Repository reference.
strategy: Conflict resolution strategy.
Returns:
A :class:`ConflictResolutionResult`.
Raises:
ValidationError: If the repository path is invalid.
InfrastructureError: On git command failures.
"""
if not isinstance(ref, RepoRef):
raise TypeError("ref must be a RepoRef instance")
if not isinstance(strategy, ConflictStrategy):
raise TypeError("strategy must be a ConflictStrategy instance")
_validate_git_repo(ref.repo_path)
if strategy == ConflictStrategy.ABORT:
try:
_run_git(
["merge", "--abort"],
cwd=ref.repo_path,
timeout=self._git_timeout,
check=False,
)
return ConflictResolutionResult(
repo_path=ref.repo_path,
strategy=strategy,
success=True,
)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Merge abort timed out for {ref.repo_path}"
) from exc
# For ours/theirs strategies, checkout the conflicting files
# with the chosen resolution, then add and continue the merge.
conflict_files = self._detect_conflicts(ref.repo_path)
if not conflict_files:
return ConflictResolutionResult(
repo_path=ref.repo_path,
strategy=strategy,
success=True,
)
checkout_flag = "--ours" if strategy == ConflictStrategy.OURS else "--theirs"
resolved: list[str] = []
remaining: list[str] = []
for cf in conflict_files:
try:
_run_git(
["checkout", checkout_flag, "--", cf],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
_run_git(
["add", cf],
cwd=ref.repo_path,
timeout=self._git_timeout,
)
resolved.append(cf)
except subprocess.CalledProcessError:
remaining.append(cf)
except subprocess.TimeoutExpired as exc:
raise InfrastructureError(
f"Conflict resolution timed out for {cf}"
) from exc
success = len(remaining) == 0
return ConflictResolutionResult(
repo_path=ref.repo_path,
strategy=strategy,
resolved_files=resolved,
remaining_conflicts=remaining,
success=success,
error=None if success else f"{len(remaining)} conflicts remain",
)
# ------------------------------------------------------------------
# internal helpers
# ------------------------------------------------------------------
def _detect_conflicts(self, repo_path: str) -> list[str]:
"""Return a list of files with merge conflicts.
Args:
repo_path: Repository root path.
Returns:
A list of conflicting file paths (relative to the repo root).
"""
try:
result = _run_git(
["diff", "--name-only", "--diff-filter=U"],
cwd=repo_path,
timeout=self._git_timeout,
check=False,
)
if result.stdout.strip():
return result.stdout.strip().splitlines()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pass
return []
@staticmethod
def _parse_updated_files(output: str) -> list[str]:
"""Extract file names from git pull output.
Args:
output: Stdout from ``git pull``.
Returns:
A list of file paths mentioned in the output.
"""
files: list[str] = []
for line in output.splitlines():
stripped = line.strip()
if "|" in stripped:
# "src/foo.py | 4 ++--" format
name = stripped.split("|")[0].strip()
if name:
files.append(name)
return files
__all__ = [
"RemoteRepoService",
]
+43 -5
View File
@@ -1,8 +1,8 @@
"""Server connection CLI commands.
"""Server CLI commands — connection management and ASGI server start.
Provides the ``agents server connect`` stub command and server-mode
status helpers. All actual server communication is stubbed out
commands persist configuration and print explicit warnings.
Provides the ``agents server connect`` stub command, the
``agents server start`` command that launches the uvicorn-backed
ASGI server, and server-mode status helpers.
"""
from __future__ import annotations
@@ -20,7 +20,7 @@ from cleveragents.application.container import get_container
from cleveragents.application.services.config_service import ConfigService
from cleveragents.cli.formatting import OutputFormat, format_output
app: Any = typer.Typer(help="Server connection management (stub).")
app: Any = typer.Typer(help="Server management — start, connect, and status.")
console: Console = Console()
# ---------------------------------------------------------------------------
@@ -213,9 +213,47 @@ def server_status(
)
@app.command("start")
def server_start(
host: Annotated[
str | None,
typer.Option("--host", "-H", help="Bind address (default: from settings)"),
] = None,
port: Annotated[
int | None,
typer.Option("--port", "-p", help="Bind port (default: from settings)"),
] = None,
log_level: Annotated[
str | None,
typer.Option("--log-level", help="Uvicorn log level"),
] = None,
) -> None:
"""Start the CleverAgents ASGI server.
Launches a uvicorn server hosting the A2A JSON-RPC 2.0 endpoint.
The server binds to host:port from Settings or CLI overrides and
handles graceful shutdown on SIGTERM/SIGINT.
Examples::
agents server start
agents server start --host 127.0.0.1 --port 9000
agents server start --log-level debug
"""
from cleveragents.infrastructure.server import run_server
console.print("[bold]Starting CleverAgents ASGI server...[/bold]")
run_server(
host=host,
port=port,
log_level=log_level,
)
__all__ = [
"app",
"resolve_server_mode",
"server_connect",
"server_start",
"server_status",
]
+1 -1
View File
@@ -206,7 +206,7 @@ def _register_subcommands() -> None:
app.add_typer(
server_app,
name="server",
help="Server connection management (stub)",
help="Server management — start, connect, and status",
)
app.add_typer(
repo.app,
@@ -0,0 +1,265 @@
"""Domain models for remote repository operations.
Provides value objects and result types for push/pull/fetch/status
operations on server-managed repositories. These models are used by
:class:`~cleveragents.application.services.remote_repo_service.RemoteRepoService`
and the A2A facade wiring to represent operation outcomes, conflict
descriptions, and synchronization state.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import UTC, datetime
class RepoOperationType(enum.Enum):
"""Enumeration of supported remote repository operations."""
PUSH = "push"
PULL = "pull"
FETCH = "fetch"
STATUS = "status"
class RepoSyncState(enum.Enum):
"""Synchronization state of a repository relative to its remote."""
UP_TO_DATE = "up_to_date"
AHEAD = "ahead"
BEHIND = "behind"
DIVERGED = "diverged"
UNKNOWN = "unknown"
DETACHED = "detached"
NO_REMOTE = "no_remote"
class ConflictStrategy(enum.Enum):
"""Strategies for resolving merge conflicts during pull."""
ABORT = "abort"
OURS = "ours"
THEIRS = "theirs"
@dataclass(frozen=True)
class RepoRef:
"""Reference identifying a repository managed by the server.
Attributes:
repo_path: Absolute filesystem path to the repository root.
remote: Remote name (e.g. ``origin``).
branch: Branch name, or *None* for the current branch.
"""
repo_path: str
remote: str = "origin"
branch: str | None = None
def __post_init__(self) -> None:
if not self.repo_path:
raise ValueError("repo_path must be a non-empty string")
if not self.remote:
raise ValueError("remote must be a non-empty string")
@dataclass(frozen=True)
class RepoStatusResult:
"""Result of a repository status query.
Attributes:
repo_path: Path to the queried repository.
current_branch: Name of the checked-out branch (or ``HEAD``
if detached).
sync_state: Synchronization state relative to the tracking remote.
ahead_count: Number of local commits ahead of the remote.
behind_count: Number of remote commits ahead of local.
has_uncommitted: Whether the working tree has uncommitted changes.
remote: The remote being tracked.
remote_url: URL of the tracking remote, or empty string if none.
head_commit: Short SHA of the current HEAD.
timestamp: When the status was computed.
"""
repo_path: str
current_branch: str
sync_state: RepoSyncState
ahead_count: int = 0
behind_count: int = 0
has_uncommitted: bool = False
remote: str = "origin"
remote_url: str = ""
head_commit: str = ""
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dictionary."""
return {
"repo_path": self.repo_path,
"current_branch": self.current_branch,
"sync_state": self.sync_state.value,
"ahead_count": self.ahead_count,
"behind_count": self.behind_count,
"has_uncommitted": self.has_uncommitted,
"remote": self.remote,
"remote_url": self.remote_url,
"head_commit": self.head_commit,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class RepoFetchResult:
"""Result of a fetch operation.
Attributes:
repo_path: Path to the repository.
remote: Remote that was fetched.
updated_refs: List of refs that were updated.
success: Whether the operation completed without error.
error: Human-readable error message, or *None* on success.
timestamp: When the operation completed.
"""
repo_path: str
remote: str
updated_refs: list[str] = field(default_factory=list)
success: bool = True
error: str | None = None
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dictionary."""
return {
"repo_path": self.repo_path,
"remote": self.remote,
"updated_refs": self.updated_refs,
"success": self.success,
"error": self.error,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class RepoPullResult:
"""Result of a pull (fetch + merge) operation.
Attributes:
repo_path: Path to the repository.
remote: Remote that was pulled from.
branch: Branch that was merged.
updated_files: List of files that were modified.
success: Whether the pull completed cleanly.
conflict_files: Files with merge conflicts (empty on success).
error: Human-readable error message, or *None* on success.
new_head: HEAD commit SHA after the pull.
timestamp: When the operation completed.
"""
repo_path: str
remote: str
branch: str
updated_files: list[str] = field(default_factory=list)
success: bool = True
conflict_files: list[str] = field(default_factory=list)
error: str | None = None
new_head: str = ""
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dictionary."""
return {
"repo_path": self.repo_path,
"remote": self.remote,
"branch": self.branch,
"updated_files": self.updated_files,
"success": self.success,
"conflict_files": self.conflict_files,
"error": self.error,
"new_head": self.new_head,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class RepoPushResult:
"""Result of a push operation.
Attributes:
repo_path: Path to the repository.
remote: Remote that was pushed to.
branch: Branch that was pushed.
success: Whether the push completed without error.
rejected: Whether the remote rejected the push (e.g. non-fast-forward).
error: Human-readable error message, or *None* on success.
pushed_commits: Number of commits pushed.
timestamp: When the operation completed.
"""
repo_path: str
remote: str
branch: str
success: bool = True
rejected: bool = False
error: str | None = None
pushed_commits: int = 0
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dictionary."""
return {
"repo_path": self.repo_path,
"remote": self.remote,
"branch": self.branch,
"success": self.success,
"rejected": self.rejected,
"error": self.error,
"pushed_commits": self.pushed_commits,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class ConflictResolutionResult:
"""Result of a conflict resolution attempt.
Attributes:
repo_path: Path to the repository.
strategy: Strategy used for resolution.
resolved_files: Files that were successfully resolved.
remaining_conflicts: Files that still have conflicts.
success: Whether all conflicts were resolved.
error: Human-readable error message, or *None* on success.
"""
repo_path: str
strategy: ConflictStrategy
resolved_files: list[str] = field(default_factory=list)
remaining_conflicts: list[str] = field(default_factory=list)
success: bool = True
error: str | None = None
def to_dict(self) -> dict[str, object]:
"""Serialize to a JSON-compatible dictionary."""
return {
"repo_path": self.repo_path,
"strategy": self.strategy.value,
"resolved_files": self.resolved_files,
"remaining_conflicts": self.remaining_conflicts,
"success": self.success,
"error": self.error,
}
__all__ = [
"ConflictResolutionResult",
"ConflictStrategy",
"RepoFetchResult",
"RepoOperationType",
"RepoPullResult",
"RepoPushResult",
"RepoRef",
"RepoStatusResult",
"RepoSyncState",
]
@@ -0,0 +1,25 @@
"""ASGI server infrastructure for CleverAgents.
Provides the FastAPI-based ASGI application, A2A JSON-RPC 2.0 endpoint
routing, and uvicorn server lifecycle management. The server's sole
client-facing interface is the A2A JSON-RPC 2.0 endpoint there is no
REST API (ADR-048).
Public surface:
- :func:`create_asgi_app` build a configured FastAPI application
- :func:`run_server` launch uvicorn with graceful shutdown
- :class:`ServerLifecycle` start/stop lifecycle manager
"""
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
from cleveragents.infrastructure.server.server_lifecycle import (
ServerLifecycle,
run_server,
)
__all__ = [
"ServerLifecycle",
"create_asgi_app",
"run_server",
]
@@ -0,0 +1,171 @@
"""FastAPI-based ASGI application for the CleverAgents server.
The server's sole client-facing interface is an A2A JSON-RPC 2.0
endpoint (ADR-048). This module builds the FastAPI application,
wires the health-check / Agent Card discovery endpoint, and exposes
the A2A JSON-RPC dispatch route.
All A2A operations both standard (``message/send``,
``message/stream``) and ``_cleveragents/`` extension methods flow
through the single ``/a2a`` POST endpoint. The Agent Card is served
at ``/.well-known/agent.json`` per the A2A specification.
"""
from __future__ import annotations
from typing import Any
import structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from cleveragents.a2a.errors import A2aOperationNotFoundError
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest, A2aVersion
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
# ------------------------------------------------------------------
# Agent Card (served at /.well-known/agent.json per A2A spec)
# ------------------------------------------------------------------
_AGENT_CARD: dict[str, Any] = {
"name": "CleverAgents",
"description": "AI-powered development assistant (actor-first)",
"url": "",
"version": A2aVersion.CURRENT,
"capabilities": {
"streaming": False,
"pushNotifications": False,
},
"skills": [],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
}
def _build_agent_card(host: str, port: int) -> dict[str, Any]:
"""Return the Agent Card with the server URL populated."""
card = dict(_AGENT_CARD)
card["url"] = f"http://{host}:{port}/a2a"
return card
# ------------------------------------------------------------------
# Application factory
# ------------------------------------------------------------------
def create_asgi_app(
*,
facade: A2aLocalFacade | None = None,
host: str = "0.0.0.0",
port: int = 8080,
) -> FastAPI:
"""Create and return a configured FastAPI ASGI application.
Parameters
----------
facade:
The :class:`A2aLocalFacade` used to dispatch A2A operations.
When *None* a default facade with no services is created.
host:
Bind address used to populate the Agent Card URL.
port:
Bind port used to populate the Agent Card URL.
Returns
-------
FastAPI
A fully wired ASGI application ready for ``uvicorn.run()``.
"""
if facade is not None and not isinstance(facade, A2aLocalFacade):
raise TypeError("facade must be an A2aLocalFacade instance or None")
if not isinstance(host, str) or not host:
raise ValueError("host must be a non-empty string")
if not isinstance(port, int) or port < 1 or port > 65535:
raise ValueError("port must be an integer between 1 and 65535")
resolved_facade = facade if facade is not None else A2aLocalFacade()
agent_card = _build_agent_card(host, port)
application = FastAPI(
title="CleverAgents A2A Server",
description="A2A JSON-RPC 2.0 endpoint for CleverAgents",
version=A2aVersion.CURRENT,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
# --- Health / Agent Card endpoint ---
@application.get("/.well-known/agent.json")
async def agent_card_endpoint() -> JSONResponse:
"""Serve the A2A Agent Card for capability discovery."""
return JSONResponse(content=agent_card)
@application.get("/health")
async def health_endpoint() -> JSONResponse:
"""Simple liveness probe."""
return JSONResponse(content={"status": "healthy"})
# --- A2A JSON-RPC 2.0 endpoint ---
@application.post("/a2a")
async def a2a_endpoint(request: Request) -> JSONResponse:
"""Handle an inbound A2A JSON-RPC 2.0 request.
Accepts a JSON body conforming to the :class:`A2aRequest`
schema, dispatches it through the facade, and returns the
:class:`A2aResponse` envelope.
"""
body: dict[str, Any] = await request.json()
try:
a2a_request = A2aRequest(**body)
except Exception as exc:
logger.warning("a2a.server.bad_request", error=str(exc))
return JSONResponse(
status_code=400,
content={
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": f"Invalid A2A request: {exc}",
},
},
)
try:
response = resolved_facade.dispatch(a2a_request)
return JSONResponse(content=response.model_dump())
except A2aOperationNotFoundError as exc:
logger.warning(
"a2a.server.method_not_found",
operation=a2a_request.operation,
)
return JSONResponse(
status_code=404,
content={
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": str(exc),
},
},
)
logger.info(
"a2a.server.app_created",
host=host,
port=port,
operations=len(resolved_facade.list_operations()),
)
return application
__all__ = [
"create_asgi_app",
]
@@ -0,0 +1,208 @@
"""Uvicorn server lifecycle management with graceful shutdown.
Provides :class:`ServerLifecycle` which wraps ``uvicorn.Server`` with
SIGTERM / SIGINT handling so that the server shuts down cleanly when
the process is terminated or the user presses Ctrl-C.
The public :func:`run_server` convenience function creates the ASGI
application, configures uvicorn, and blocks until shutdown.
"""
from __future__ import annotations
import signal
import threading
from types import FrameType
import structlog
import uvicorn
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.server.asgi_app import create_asgi_app
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
class ServerLifecycle:
"""Manage the lifecycle of the uvicorn ASGI server.
Parameters
----------
host:
Network address to bind to.
port:
TCP port to listen on.
facade:
The A2A facade used for request dispatch. *None* creates a
default facade with no wired services.
log_level:
Uvicorn log level (``"info"``, ``"warning"``, etc.).
"""
def __init__(
self,
*,
host: str = "0.0.0.0",
port: int = 8080,
facade: A2aLocalFacade | None = None,
log_level: str = "info",
) -> None:
if not isinstance(host, str) or not host:
raise ValueError("host must be a non-empty string")
if not isinstance(port, int) or port < 1 or port > 65535:
raise ValueError("port must be an integer between 1 and 65535")
if not isinstance(log_level, str) or not log_level:
raise ValueError("log_level must be a non-empty string")
self._host: str = host
self._port: int = port
self._facade: A2aLocalFacade = (
facade if facade is not None else A2aLocalFacade()
)
self._log_level: str = log_level.lower()
self._server: uvicorn.Server | None = None
self._started: bool = False
self._stopped: bool = False
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def host(self) -> str:
"""Bound host address."""
return self._host
@property
def port(self) -> int:
"""Bound port number."""
return self._port
@property
def is_started(self) -> bool:
"""Whether :meth:`start` has been called."""
return self._started
@property
def is_stopped(self) -> bool:
"""Whether the server has stopped."""
return self._stopped
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Build the ASGI app and run uvicorn (blocking).
Installs SIGTERM/SIGINT handlers for graceful shutdown. This
method blocks until the server exits.
"""
if self._started:
raise RuntimeError("Server has already been started")
self._started = True
app = create_asgi_app(
facade=self._facade,
host=self._host,
port=self._port,
)
config = uvicorn.Config(
app=app,
host=self._host,
port=self._port,
log_level=self._log_level,
)
server = uvicorn.Server(config)
self._server = server
logger.info(
"a2a.server.starting",
host=self._host,
port=self._port,
)
# Install signal handlers for graceful shutdown.
self._install_signal_handlers()
server.run()
self._stopped = True
logger.info("a2a.server.stopped")
def request_shutdown(self) -> None:
"""Request a graceful shutdown of the running server.
Safe to call from signal handlers or other threads.
"""
if self._server is not None:
self._server.should_exit = True
logger.info("a2a.server.shutdown_requested")
# ------------------------------------------------------------------
# Signal handling
# ------------------------------------------------------------------
def _install_signal_handlers(self) -> None:
"""Install SIGTERM and SIGINT handlers when running on main thread."""
if threading.current_thread() is not threading.main_thread():
return
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
sig_name = signal.Signals(signum).name
logger.info("a2a.server.signal_received", signal=sig_name)
self.request_shutdown()
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# ------------------------------------------------------------------
# Convenience runner
# ------------------------------------------------------------------
def run_server(
*,
host: str | None = None,
port: int | None = None,
facade: A2aLocalFacade | None = None,
log_level: str | None = None,
) -> None:
"""Create and run the ASGI server (blocking).
Settings are resolved from :class:`Settings` when parameters are
*None*.
Parameters
----------
host:
Override for ``Settings.server_host``.
port:
Override for ``Settings.server_port``.
facade:
A2A facade for operation dispatch.
log_level:
Override for ``Settings.log_level``.
"""
settings = Settings.get_settings()
resolved_host = host if host is not None else settings.server_host
resolved_port = port if port is not None else settings.server_port
resolved_log_level = log_level if log_level is not None else settings.log_level
lifecycle = ServerLifecycle(
host=resolved_host,
port=resolved_port,
facade=facade,
log_level=resolved_log_level,
)
lifecycle.start()
__all__ = [
"ServerLifecycle",
"run_server",
]
+7
View File
@@ -1177,3 +1177,10 @@ timeout_seconds # noqa: B018, F821
workspace_folder # noqa: B018, F821
execute_tool # noqa: B018, F821
wrap_service_method # noqa: B018, F821
# ASGI server infrastructure — FastAPI route handlers and ServerLifecycle public API
agent_card_endpoint # noqa: B018, F821
health_endpoint # noqa: B018, F821
a2a_endpoint # noqa: B018, F821
server_start # noqa: B018, F821
run_server # noqa: B018, F821