Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce8bbd905 | |||
| 5f0a545113 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user