Merge pull request #1 from AIOSAI/ai/routing-module-v1.0.0

feat(routing): Routing & Discovery module v1.0.0
This commit is contained in:
AIPass
2026-03-01 20:23:29 -08:00
committed by GitHub
13 changed files with 2676 additions and 3 deletions
+98
View File
@@ -16,6 +16,104 @@ pip install aipass
> **Note:** This package is not yet published to PyPI. This repo is in early development.
## Features
### Routing & Discovery (v1.0)
Symbolic addressing for multi-agent systems. Instead of hard-coded paths, agents use `@branch` symbolic names that resolve to actual locations at runtime.
**Quick Start:**
```python
from aipass.routing import initialize_registry, register_branch, resolve_branch
# Initialize registry (first time only)
initialize_registry()
# Register your agents
register_branch("my_agent", "/path/to/agents/my_agent", branch_type="agent")
register_branch("researcher", "/path/to/agents/researcher", branch_type="agent")
register_branch("monitor", "/path/to/services/monitor", branch_type="service")
# Resolve symbolic names to paths
agent_path = resolve_branch("@my_agent")
# Returns: "/path/to/agents/my_agent"
# Works with or without @ prefix
researcher_path = resolve_branch("researcher")
# Returns: "/path/to/agents/researcher"
```
**Discovery:**
```python
from aipass.routing import list_branches, branch_exists, get_branch_info
# Check if a branch exists
if branch_exists("@my_agent"):
print("Agent found!")
# List all registered branches
all_branches = list_branches()
# Returns: ["@my_agent", "@researcher", "@monitor"]
# List branches by type
agents_only = list_branches(branch_type="agent")
# Returns: ["@my_agent", "@researcher"]
# Get full branch metadata
info = get_branch_info("@my_agent")
# Returns: {
# "name": "my_agent",
# "path": "/path/to/agents/my_agent",
# "type": "agent",
# "status": "active",
# "created": "2026-03-01T10:00:00Z"
# }
```
**Configuration:**
By default, the registry is stored at `~/.aipass/BRANCH_REGISTRY.json`. You can customize this:
```python
from aipass.routing import set_registry_path
# Set custom registry location
set_registry_path("/custom/path/to/registry.json")
```
Or via environment variable:
```bash
export AIPASS_REGISTRY_PATH=/custom/path/to/registry.json
```
**Integration with Trinity Pattern:**
```python
from trinity_pattern import Agent
from aipass.routing import resolve_branch
# Before: hard-coded paths
agent = Agent(directory="/home/user/agents/my_agent")
# After: symbolic addressing
agent_dir = resolve_branch("@my_agent")
agent = Agent(directory=agent_dir)
```
**Error Handling:**
```python
from aipass.routing import resolve_branch, BranchNotFoundError
try:
path = resolve_branch("@nonexistent")
except BranchNotFoundError as e:
print(f"Branch not found: {e}")
```
## License
MIT
+26 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "0.1.0-dev"
version = "1.0.0"
description = "Orchestration framework for autonomous AI agent ecosystems"
readme = "README.md"
license = "MIT"
@@ -12,15 +12,38 @@ requires-python = ">=3.10"
authors = [
{ name = "AIPass", email = "aipass.system@gmail.com" },
]
dependencies = [
"trinity-pattern>=1.0.0",
keywords = ["ai", "agents", "routing", "discovery", "multi-agent", "orchestration"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = []
[project.urls]
Homepage = "https://github.com/AIOSAI/AIPass"
Repository = "https://github.com/AIOSAI/AIPass"
"Bug Tracker" = "https://github.com/AIOSAI/AIPass/issues"
[project.optional-dependencies]
trinity = [
"trinity-pattern>=1.0.0",
]
dev = [
"pytest",
"pytest-cov",
"ruff",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 120
+97
View File
@@ -0,0 +1,97 @@
"""
AIPass Routing & Discovery Module.
Provides symbolic addressing for multi-agent systems. Resolves @branch names
to absolute paths at runtime.
Core API:
resolve_branch(name) -> str # @branch → absolute path
register_branch(name, path, type) # Add branch to registry
list_branches(type, status) # List all branches
branch_exists(name) -> bool # Check if branch exists
get_branch_info(name) -> dict # Get branch metadata
Command Routing (Phase 2):
route_command(target, command, args, timeout) -> CommandResult
discover_modules(target) -> list # Available commands for a branch
get_help(target, command) -> HelpResult # Structured help for a branch
@all Operations (Phase 3):
route_all(command, args, timeout) -> dict[str, CommandResult]
get_system_help() -> dict[str, HelpResult] # Help across all branches
Registry Management:
initialize_registry() # Create empty registry
set_registry_path(path) # Set custom registry location
get_registry_path() -> Path # Get current registry path
Example:
>>> from aipass.routing import resolve_branch, register_branch
>>> register_branch("my_agent", "/path/to/agent", "agent")
>>> path = resolve_branch("@my_agent")
>>> print(path)
/path/to/agent
>>> from aipass.routing import route_command
>>> result = route_command("@my_agent", "status")
>>> print(result.stdout)
>>> from aipass.routing import route_all
>>> results = route_all("status")
>>> for branch, r in results.items():
... print(f"{branch}: exit={r.exit_code}")
"""
from .config import get_registry_path, reset_registry_path, set_registry_path
from .discovery import HelpResult, discover_modules, get_help, get_system_help
from .exceptions import (
BranchAlreadyExistsError,
BranchNotFoundError,
CommandExecutionError,
InvalidPathError,
RegistryCorruptError,
RegistryError,
RegistryNotFoundError,
RegistryPermissionError,
RoutingError,
)
from .executor import CommandResult
from .registry import add_branch as register_branch
from .registry import initialize_registry
from .resolver import branch_exists, get_branch_info, list_branches, resolve_branch
from .router import route_all, route_command
__version__ = "1.0.0"
__all__ = [
# Core API
"resolve_branch",
"register_branch",
"list_branches",
"branch_exists",
"get_branch_info",
# Command routing (Phase 2)
"route_command",
"discover_modules",
"get_help",
"CommandResult",
# Help & discovery (Phase 3)
"HelpResult",
"get_system_help",
"route_all",
# Registry management
"initialize_registry",
"set_registry_path",
"get_registry_path",
"reset_registry_path",
# Exceptions
"RoutingError",
"BranchNotFoundError",
"BranchAlreadyExistsError",
"InvalidPathError",
"RegistryError",
"RegistryNotFoundError",
"RegistryCorruptError",
"RegistryPermissionError",
"CommandExecutionError",
]
+55
View File
@@ -0,0 +1,55 @@
"""
Registry configuration management.
Handles registry path configuration with environment variable support.
"""
import os
from pathlib import Path
from typing import Optional
_registry_path: Optional[Path] = None
def get_registry_path() -> Path:
"""
Get the current registry path.
Priority:
1. Explicitly set path via set_registry_path()
2. AIPASS_REGISTRY_PATH environment variable
3. Default: ~/.aipass/BRANCH_REGISTRY.json
Returns:
Path to the registry file
"""
global _registry_path
if _registry_path is not None:
return _registry_path
env_path = os.environ.get("AIPASS_REGISTRY_PATH")
if env_path:
return Path(env_path)
return Path.home() / ".aipass" / "BRANCH_REGISTRY.json"
def set_registry_path(path: str | Path) -> None:
"""
Set a custom registry path.
Args:
path: Path to the registry file
"""
global _registry_path
_registry_path = Path(path)
def reset_registry_path() -> None:
"""
Reset registry path to default (useful for testing).
"""
global _registry_path
_registry_path = None
+278
View File
@@ -0,0 +1,278 @@
"""
Module and command discovery for AIPass branch introspection.
Introspects branch capabilities by querying entry points for help text
and scanning module directories as a fallback.
"""
import logging
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from .exceptions import CommandExecutionError
from .resolver import list_branches, resolve_branch
logger = logging.getLogger(__name__)
@dataclass
class HelpResult:
"""
Structured result from a help query.
Attributes:
branch: The branch name the help was retrieved from.
command: The specific command queried, or None for top-level help.
text: Raw help text (stdout or stderr from the entry point).
commands_found: List of command names parsed from the help text.
"""
branch: str
command: Optional[str]
text: str
commands_found: List[str] = field(default_factory=list)
def _get_entry_point(branch_path: str, branch_name: str) -> Optional[Path]:
"""
Return the apps/{branch_name}.py entry point path if it exists, else None.
Args:
branch_path: Absolute path to the branch directory.
branch_name: Lowercase branch name.
Returns:
Path to the entry point, or None if it does not exist.
"""
entry_point = Path(branch_path) / "apps" / f"{branch_name}.py"
return entry_point if entry_point.exists() else None
def _scan_modules_directory(branch_path: str) -> List[str]:
"""
Scan apps/modules/ for .py files and return their stems as command names.
Used as a fallback when the entry point's --help output cannot be parsed.
Args:
branch_path: Absolute path to the branch directory.
Returns:
Sorted list of module stem names (without .py extension), excluding
__init__ and __main__.
"""
modules_dir = Path(branch_path) / "apps" / "modules"
if not modules_dir.is_dir():
return []
excluded = {"__init__", "__main__"}
return sorted(
f.stem
for f in modules_dir.glob("*.py")
if f.stem not in excluded
)
def _parse_help_for_commands(help_text: str) -> List[str]:
"""
Parse --help output to extract a list of available commands.
Handles multiple CLI framework formats:
- Section-based (custom CLIs, argparse subparsers): looks for headers
labelled "commands", "subcommands", or "available commands".
- Click-style: looks for indented entries under "Commands:" or "Options:".
- Positional args listing: lines matching ``{command}`` or ``[command]``
at the start of usage lines.
Falls back to returning an empty list if no recognisable pattern is found.
Args:
help_text: Raw stdout from ``python3 entry.py --help``.
Returns:
List of command names parsed from the help text.
"""
commands: List[str] = []
in_commands_section = False
section_markers = {"commands", "subcommands", "available commands"}
for line in help_text.splitlines():
stripped = line.strip()
# Detect section header lines (e.g. "Commands:", "Available commands:")
if any(marker in stripped.lower() for marker in section_markers):
in_commands_section = True
continue
# A blank line ends the current section.
if in_commands_section and not stripped:
in_commands_section = False
continue
if in_commands_section:
# Indented lines that start with a word are command entries.
if line.startswith((" ", "\t")) and stripped:
# Take the first token as the command name.
token = stripped.split()[0]
# Skip lines that look like option flags.
if not token.startswith("-"):
commands.append(token)
return commands
def discover_modules(target: str) -> List[str]:
"""
Discover available commands for a branch.
Resolution order:
1. Resolve @target to its absolute path via the registry.
2. Attempt to run ``python3 apps/{name}.py --help`` and parse the output
for a commands section.
3. If parsing yields nothing, fall back to scanning ``apps/modules/`` for
.py file stems.
Args:
target: Symbolic branch name with or without @ prefix (e.g., "@flow").
Returns:
List of discovered command/module names (may be empty).
Raises:
BranchNotFoundError: If the target branch is not in the registry.
"""
# Step 1: resolve — BranchNotFoundError propagates to caller.
branch_path = resolve_branch(target)
branch_name = target.lstrip("@").lower()
# Step 2: try --help on the entry point.
entry_point = _get_entry_point(branch_path, branch_name)
if entry_point is not None:
try:
result = subprocess.run(
["python3", str(entry_point.relative_to(branch_path)), "--help"],
cwd=branch_path,
capture_output=True,
timeout=10,
shell=False,
)
help_text = result.stdout.decode("utf-8", errors="replace")
if not help_text:
help_text = result.stderr.decode("utf-8", errors="replace")
commands = _parse_help_for_commands(help_text)
if commands:
return commands
except (subprocess.TimeoutExpired, OSError):
pass
# Step 3: fallback — scan modules directory.
return _scan_modules_directory(branch_path)
def get_help(target: str, command: Optional[str] = None) -> HelpResult:
"""
Get structured help for a branch or a specific command.
Executes:
python3 apps/{name}.py --help (when command is None)
python3 apps/{name}.py {command} --help (when command is given)
Args:
target: Symbolic branch name with or without @ prefix (e.g., "@flow").
command: Specific command to get help for (default: None).
Returns:
HelpResult with branch, command, text, and commands_found populated.
Raises:
BranchNotFoundError: If the target branch is not in the registry.
CommandExecutionError: If the entry point does not exist or execution
fails.
"""
branch_path = resolve_branch(target)
branch_name = target.lstrip("@").lower()
entry_point = _get_entry_point(branch_path, branch_name)
if entry_point is None:
raise CommandExecutionError(
f"Entry point not found for branch '{branch_name}': "
f"{Path(branch_path) / 'apps' / (branch_name + '.py')}"
)
relative_entry = str(entry_point.relative_to(branch_path))
if command is None:
cmd_args = [relative_entry, "--help"]
else:
cmd_args = [relative_entry, command, "--help"]
try:
result = subprocess.run(
["python3"] + cmd_args,
cwd=branch_path,
capture_output=True,
timeout=10,
shell=False,
)
except subprocess.TimeoutExpired as e:
raise CommandExecutionError(
f"Help command timed out for branch '{branch_name}'"
) from e
except OSError as e:
raise CommandExecutionError(
f"OS error getting help for branch '{branch_name}': {e}"
) from e
stdout = result.stdout.decode("utf-8", errors="replace")
stderr = result.stderr.decode("utf-8", errors="replace")
# Many CLIs write help to stderr; return whichever is non-empty.
text = stdout if stdout.strip() else stderr
commands_found = _parse_help_for_commands(text)
return HelpResult(
branch=branch_name,
command=command,
text=text,
commands_found=commands_found,
)
def get_system_help() -> Dict[str, HelpResult]:
"""
Aggregate help across all active branches in the registry.
Iterates over every active branch and attempts to retrieve top-level help
via ``get_help()``. Branches whose entry point is missing or whose help
command fails are silently skipped (the error is logged at DEBUG level).
Returns:
Mapping of branch name (without @ prefix) to its HelpResult.
Branches that cannot be queried are omitted from the result.
Example:
>>> results = get_system_help()
>>> for branch, help_result in results.items():
... print(f"{branch}: {help_result.commands_found}")
"""
results: Dict[str, HelpResult] = {}
# list_branches returns names with @ prefix; iterate all active branches.
active_branches = list_branches(status="active")
for symbolic_name in active_branches:
branch_name = symbolic_name.lstrip("@")
try:
help_result = get_help(symbolic_name)
results[branch_name] = help_result
except Exception as exc:
logger.debug(
"get_system_help: skipping branch '%s': %s",
branch_name,
exc,
)
return results
+50
View File
@@ -0,0 +1,50 @@
"""
Routing module custom exceptions.
Defines the exception hierarchy for routing and branch resolution errors.
"""
class RoutingError(Exception):
"""Base exception for all routing-related errors."""
pass
class BranchNotFoundError(RoutingError):
"""Raised when a branch cannot be found in the registry."""
pass
class BranchAlreadyExistsError(RoutingError):
"""Raised when attempting to register a branch that already exists."""
pass
class InvalidPathError(RoutingError):
"""Raised when a path is invalid or doesn't exist."""
pass
class RegistryError(RoutingError):
"""Base exception for registry-related errors."""
pass
class RegistryNotFoundError(RegistryError):
"""Raised when the registry file doesn't exist."""
pass
class RegistryCorruptError(RegistryError):
"""Raised when the registry file is corrupted or invalid JSON."""
pass
class RegistryPermissionError(RegistryError):
"""Raised when there are permission issues accessing the registry."""
pass
class CommandExecutionError(RoutingError):
"""Raised when command execution fails (Phase 2 feature)."""
pass
+97
View File
@@ -0,0 +1,97 @@
"""
Safe subprocess execution for branch command routing.
Wraps subprocess.run with safety guards: timeout enforcement, no shell injection,
captured output, and consistent error wrapping via CommandExecutionError.
Defines CommandResult, the shared return type for all routing operations.
"""
import subprocess
from dataclasses import dataclass
from typing import List
from .exceptions import CommandExecutionError
@dataclass
class CommandResult:
"""
Result of a routed command execution.
Attributes:
stdout: Captured standard output from the command.
stderr: Captured standard error from the command.
exit_code: Process exit code (0 typically indicates success).
branch: The branch name the command was routed to.
command: The command string that was executed.
"""
stdout: str
stderr: str
exit_code: int
branch: str
command: str
def execute_command(
executable: str,
args: List[str],
cwd: str,
timeout: int = 30,
) -> CommandResult:
"""
Execute a command via subprocess with safety guards.
Args:
executable: The executable to run (e.g., "python3").
args: List of arguments (e.g., ["apps/flow.py", "status"]).
cwd: Working directory for command execution (absolute path).
timeout: Maximum execution time in seconds (default: 30).
Returns:
CommandResult with stdout, stderr, exit_code populated.
branch and command fields are empty strings; route_command populates them.
Raises:
CommandExecutionError: If the process times out, the executable is not
found, or any other OS-level error prevents execution.
Notes:
- Never uses shell=True to prevent shell injection attacks.
- Always captures stdout and stderr separately.
- Decodes output as UTF-8, replacing undecodable bytes.
"""
full_cmd = [executable] + list(args)
try:
result = subprocess.run(
full_cmd,
cwd=cwd,
capture_output=True,
timeout=timeout,
shell=False,
)
except subprocess.TimeoutExpired as e:
raise CommandExecutionError(
f"Command timed out after {timeout}s: {' '.join(full_cmd)}"
) from e
except FileNotFoundError as e:
raise CommandExecutionError(
f"Executable not found: {executable!r}"
) from e
except OSError as e:
raise CommandExecutionError(
f"OS error executing command: {e}"
) from e
stdout = result.stdout.decode("utf-8", errors="replace")
stderr = result.stderr.decode("utf-8", errors="replace")
return CommandResult(
stdout=stdout,
stderr=stderr,
exit_code=result.returncode,
branch="",
command="",
)
+260
View File
@@ -0,0 +1,260 @@
"""
Registry operations for branch management.
Handles loading, saving, and managing the BRANCH_REGISTRY.json file.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from .config import get_registry_path
from .exceptions import (
BranchAlreadyExistsError,
InvalidPathError,
RegistryCorruptError,
RegistryNotFoundError,
RegistryPermissionError,
)
def load_registry() -> Dict[str, Any]:
"""
Load the branch registry from disk.
Returns:
Registry dictionary with version, branches, and metadata
Raises:
RegistryNotFoundError: If registry file doesn't exist
RegistryCorruptError: If registry file is invalid JSON
RegistryPermissionError: If registry file cannot be read
"""
registry_path = get_registry_path()
if not registry_path.exists():
raise RegistryNotFoundError(
f"Registry not found at {registry_path}. "
"Run initialize_registry() to create one."
)
try:
with open(registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
except PermissionError as e:
raise RegistryPermissionError(f"Permission denied reading registry: {e}")
except json.JSONDecodeError as e:
raise RegistryCorruptError(f"Registry file is corrupted: {e}")
except Exception as e:
raise RegistryCorruptError(f"Failed to read registry: {e}")
# Validate structure
if not isinstance(data, dict):
raise RegistryCorruptError("Registry must be a JSON object")
if "branches" not in data:
raise RegistryCorruptError("Registry missing 'branches' field")
return data
def save_registry(registry: Dict[str, Any]) -> None:
"""
Save the branch registry to disk.
Args:
registry: Registry dictionary to save
Raises:
RegistryPermissionError: If registry file cannot be written
"""
registry_path = get_registry_path()
# Ensure directory exists
registry_path.parent.mkdir(parents=True, exist_ok=True)
# Update metadata timestamp
if "metadata" not in registry:
registry["metadata"] = {}
registry["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
registry["metadata"]["managed_by"] = "aipass.routing"
try:
with open(registry_path, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2)
except PermissionError as e:
raise RegistryPermissionError(f"Permission denied writing registry: {e}")
except Exception as e:
raise RegistryPermissionError(f"Failed to write registry: {e}")
def initialize_registry() -> None:
"""
Create a new empty registry file.
Creates the registry directory and file if they don't exist.
If registry already exists, does nothing.
"""
registry_path = get_registry_path()
if registry_path.exists():
return
registry = {
"version": "1.0",
"branches": {},
"metadata": {
"last_updated": datetime.now(timezone.utc).isoformat(),
"managed_by": "aipass.routing",
},
}
save_registry(registry)
def add_branch(
name: str,
path: str | Path,
branch_type: str = "agent",
status: str = "active",
) -> None:
"""
Add a branch to the registry.
Args:
name: Branch name (without @ prefix)
path: Absolute path to branch directory
branch_type: Type of branch (agent, service, module, etc.)
status: Branch status (active, inactive, archived)
Raises:
BranchAlreadyExistsError: If branch name already exists
InvalidPathError: If path doesn't exist or isn't a directory
RegistryNotFoundError: If registry doesn't exist
"""
# Validate path
path_obj = Path(path).resolve()
if not path_obj.exists():
raise InvalidPathError(f"Path does not exist: {path}")
if not path_obj.is_dir():
raise InvalidPathError(f"Path is not a directory: {path}")
# Load registry
try:
registry = load_registry()
except RegistryNotFoundError:
# Auto-initialize if registry doesn't exist
initialize_registry()
registry = load_registry()
# Check if branch already exists
if name in registry["branches"]:
raise BranchAlreadyExistsError(
f"Branch '{name}' already exists in registry"
)
# Add branch entry
registry["branches"][name] = {
"name": name,
"path": str(path_obj),
"type": branch_type,
"status": status,
"created": datetime.now(timezone.utc).isoformat(),
}
save_registry(registry)
def remove_branch(name: str) -> None:
"""
Remove a branch from the registry.
Args:
name: Branch name (without @ prefix)
Raises:
RegistryNotFoundError: If registry doesn't exist
"""
registry = load_registry()
if name in registry["branches"]:
del registry["branches"][name]
save_registry(registry)
def update_branch_status(name: str, status: str) -> None:
"""
Update the status of a branch.
Args:
name: Branch name (without @ prefix)
status: New status (active, inactive, archived)
Raises:
RegistryNotFoundError: If registry doesn't exist
"""
registry = load_registry()
if name in registry["branches"]:
registry["branches"][name]["status"] = status
registry["branches"][name]["last_seen"] = datetime.now(timezone.utc).isoformat()
save_registry(registry)
def get_all_branches(
branch_type: Optional[str] = None,
status: str = "active",
) -> List[Dict[str, Any]]:
"""
Get all branches from the registry, optionally filtered.
Args:
branch_type: Filter by branch type (optional)
status: Filter by status (default: "active")
Returns:
List of branch dictionaries
Raises:
RegistryNotFoundError: If registry doesn't exist
"""
try:
registry = load_registry()
except RegistryNotFoundError:
return []
branches = registry.get("branches", {}).values()
# Apply filters
filtered = []
for branch in branches:
if status and branch.get("status") != status:
continue
if branch_type and branch.get("type") != branch_type:
continue
filtered.append(branch)
return filtered
def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]:
"""
Get a single branch by name.
Args:
name: Branch name (without @ prefix)
Returns:
Branch dictionary or None if not found
Raises:
RegistryNotFoundError: If registry doesn't exist
"""
try:
registry = load_registry()
except RegistryNotFoundError:
return None
return registry.get("branches", {}).get(name)
+116
View File
@@ -0,0 +1,116 @@
"""
Branch resolution logic.
Resolves symbolic @branch names to absolute paths and metadata.
"""
from typing import Any, Dict, List, Optional
from .exceptions import BranchNotFoundError
from .registry import get_all_branches, get_branch_by_name, load_registry
def normalize_branch_name(symbolic_name: str) -> str:
"""
Normalize a symbolic branch name.
Strips @ prefix if present and returns clean branch name.
Args:
symbolic_name: Branch name with or without @ prefix
Returns:
Clean branch name without @ prefix
"""
if symbolic_name.startswith("@"):
return symbolic_name[1:]
return symbolic_name
def resolve_branch(symbolic_name: str) -> str:
"""
Resolve a symbolic branch name to its absolute path.
Args:
symbolic_name: Branch name with or without @ prefix (e.g., "@my_agent" or "my_agent")
Returns:
Absolute path to branch directory as string
Raises:
BranchNotFoundError: If branch not in registry
RegistryNotFoundError: If registry file missing or corrupt
"""
# Let RegistryNotFoundError propagate from load_registry
registry = load_registry()
name = normalize_branch_name(symbolic_name)
branch = registry.get("branches", {}).get(name)
if branch is None:
raise BranchNotFoundError(
f"Branch '{symbolic_name}' not found in registry"
)
return branch["path"]
def branch_exists(symbolic_name: str) -> bool:
"""
Check if a branch exists in the registry.
Args:
symbolic_name: Branch name with or without @ prefix
Returns:
True if branch exists, False otherwise
"""
name = normalize_branch_name(symbolic_name)
branch = get_branch_by_name(name)
return branch is not None
def get_branch_info(symbolic_name: str) -> Dict[str, Any]:
"""
Get full metadata for a branch.
Args:
symbolic_name: Branch name with or without @ prefix
Returns:
Dictionary with branch metadata (name, path, type, status, timestamps)
Raises:
BranchNotFoundError: If branch not in registry
RegistryNotFoundError: If registry file missing or corrupt
"""
# Let RegistryNotFoundError propagate from load_registry
registry = load_registry()
name = normalize_branch_name(symbolic_name)
branch = registry.get("branches", {}).get(name)
if branch is None:
raise BranchNotFoundError(
f"Branch '{symbolic_name}' not found in registry"
)
return branch
def list_branches(
branch_type: Optional[str] = None,
status: str = "active",
) -> List[str]:
"""
List all registered branches, optionally filtered by type and status.
Args:
branch_type: Filter by branch type (optional)
status: Filter by status (default: "active")
Returns:
List of branch names with @ prefix
"""
branches = get_all_branches(branch_type=branch_type, status=status)
return [f"@{branch['name']}" for branch in branches]
+159
View File
@@ -0,0 +1,159 @@
"""
Command routing logic for the AIPass routing module.
Routes commands to branch entry points by resolving symbolic @branch names,
locating the branch's apps/{name}.py entry point, and executing via subprocess.
"""
import logging
from pathlib import Path
from typing import Dict, List, Optional
from .exceptions import CommandExecutionError
from .executor import CommandResult, execute_command
from .resolver import list_branches, resolve_branch
logger = logging.getLogger(__name__)
def _find_entry_point(branch_path: str, branch_name: str) -> Path:
"""
Locate the apps/{branch_name}.py entry point for a branch.
Args:
branch_path: Absolute path to the branch directory.
branch_name: Lowercase branch name from the registry.
Returns:
Path to the entry point file.
Raises:
CommandExecutionError: If the entry point does not exist.
"""
entry_point = Path(branch_path) / "apps" / f"{branch_name}.py"
if not entry_point.exists():
raise CommandExecutionError(
f"Entry point not found for branch '{branch_name}': {entry_point}"
)
return entry_point
def route_command(
target: str,
command: str,
args: Optional[List[str]] = None,
timeout: int = 30,
) -> CommandResult:
"""
Route a command to a branch's entry point.
Resolves @target to an absolute path, locates the branch entry point at
{path}/apps/{branch_name}.py, then executes:
python3 apps/{name}.py {command} [args...]
The process runs with cwd set to the branch root so relative paths inside
the entry point (e.g. imports of sibling modules) resolve correctly.
Args:
target: Symbolic branch name with or without @ prefix (e.g., "@flow").
command: Command to pass to the branch entry point (e.g., "status").
args: Optional list of additional arguments (default: None).
timeout: Maximum execution time in seconds (default: 30).
Returns:
CommandResult with stdout, stderr, exit_code, branch, and command.
Raises:
BranchNotFoundError: If the target branch is not in the registry.
CommandExecutionError: If the entry point is missing or execution fails.
"""
if args is None:
args = []
# Step 1: Resolve @target → absolute path. BranchNotFoundError propagates.
branch_path = resolve_branch(target)
# Derive the canonical branch name (strip leading @).
branch_name = target.lstrip("@").lower()
# Step 2: Locate entry point.
entry_point = _find_entry_point(branch_path, branch_name)
# Step 3: Build argument list and execute.
# Use a relative path to apps/{name}.py so the subprocess cwd is the branch root.
relative_entry = str(entry_point.relative_to(branch_path))
cmd_args = [relative_entry, command] + list(args)
result = execute_command(
executable="python3",
args=cmd_args,
cwd=branch_path,
timeout=timeout,
)
# Annotate result with routing context.
return CommandResult(
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
branch=branch_name,
command=command,
)
def route_all(
command: str,
args: Optional[List[str]] = None,
timeout: int = 30,
) -> Dict[str, CommandResult]:
"""
Route the same command to ALL active branches in the registry.
Iterates over every active branch and calls ``route_command`` on each.
Individual branch failures (missing entry point, execution error) are
logged at WARNING level and recorded as a CommandResult with exit_code=-1
and the error message in stderr. Processing continues regardless of
per-branch failures.
Args:
command: Command to pass to every branch entry point (e.g., "status").
args: Optional list of additional arguments forwarded to each branch.
timeout: Maximum execution time per branch in seconds (default: 30).
Returns:
Dictionary mapping branch name (without @ prefix) to its CommandResult.
Branches that fail are included with exit_code=-1 and error in stderr.
Example:
>>> results = route_all("status")
>>> for branch, result in results.items():
... print(f"{branch}: exit={result.exit_code}")
"""
if args is None:
args = []
results: Dict[str, CommandResult] = {}
active_branches = list_branches(status="active")
for symbolic_name in active_branches:
branch_name = symbolic_name.lstrip("@")
try:
result = route_command(symbolic_name, command, args=list(args), timeout=timeout)
results[branch_name] = result
except Exception as exc:
logger.warning(
"route_all: branch '%s' failed for command '%s': %s",
branch_name,
command,
exc,
)
results[branch_name] = CommandResult(
stdout="",
stderr=str(exc),
exit_code=-1,
branch=branch_name,
command=command,
)
return results
+390
View File
@@ -0,0 +1,390 @@
"""
Unit tests for routing core functionality.
Tests all Phase 1 routing functions with >80% coverage target.
"""
import json
from pathlib import Path
import pytest
from aipass.routing import (
BranchAlreadyExistsError,
BranchNotFoundError,
InvalidPathError,
RegistryNotFoundError,
branch_exists,
get_branch_info,
get_registry_path,
initialize_registry,
list_branches,
register_branch,
reset_registry_path,
resolve_branch,
set_registry_path,
)
@pytest.fixture
def temp_registry(tmp_path):
"""Create a temporary registry for testing."""
registry_path = tmp_path / "test_registry.json"
set_registry_path(registry_path)
initialize_registry()
yield registry_path
reset_registry_path()
@pytest.fixture
def sample_branches(tmp_path):
"""Create sample branch directories for testing."""
branches = {}
for name in ["agent1", "agent2", "service1"]:
branch_dir = tmp_path / "branches" / name
branch_dir.mkdir(parents=True, exist_ok=True)
branches[name] = branch_dir
return branches
class TestConfiguration:
"""Test registry configuration."""
def test_default_registry_path(self):
"""Test default registry path is ~/.aipass/BRANCH_REGISTRY.json."""
reset_registry_path()
path = get_registry_path()
assert path == Path.home() / ".aipass" / "BRANCH_REGISTRY.json"
def test_set_custom_registry_path(self, tmp_path):
"""Test setting custom registry path."""
custom_path = tmp_path / "custom_registry.json"
set_registry_path(custom_path)
assert get_registry_path() == custom_path
reset_registry_path()
def test_reset_registry_path(self, tmp_path):
"""Test resetting registry path to default."""
custom_path = tmp_path / "custom_registry.json"
set_registry_path(custom_path)
reset_registry_path()
path = get_registry_path()
assert path == Path.home() / ".aipass" / "BRANCH_REGISTRY.json"
class TestRegistryInitialization:
"""Test registry initialization."""
def test_initialize_creates_registry(self, tmp_path):
"""Test initialize_registry creates valid registry file."""
registry_path = tmp_path / "new_registry.json"
set_registry_path(registry_path)
initialize_registry()
assert registry_path.exists()
with open(registry_path, encoding="utf-8") as f:
data = json.load(f)
assert data["version"] == "1.0"
assert "branches" in data
assert isinstance(data["branches"], dict)
assert "metadata" in data
reset_registry_path()
def test_initialize_idempotent(self, temp_registry):
"""Test initialize_registry is idempotent (safe to call multiple times)."""
# First initialization done by fixture
first_content = temp_registry.read_text()
# Second initialization should not change anything
initialize_registry()
second_content = temp_registry.read_text()
assert first_content == second_content
class TestBranchRegistration:
"""Test branch registration functionality."""
def test_register_branch_basic(self, temp_registry, sample_branches):
"""Test basic branch registration."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
# Verify branch was added
with open(temp_registry, encoding="utf-8") as f:
data = json.load(f)
assert "agent1" in data["branches"]
branch = data["branches"]["agent1"]
assert branch["name"] == "agent1"
assert Path(branch["path"]) == branch_path
assert branch["type"] == "agent"
assert branch["status"] == "active"
assert "created" in branch
def test_register_branch_with_path_object(self, temp_registry, sample_branches):
"""Test branch registration with Path object."""
branch_path = sample_branches["agent1"]
register_branch("agent1", branch_path, "agent")
assert branch_exists("agent1")
def test_register_branch_duplicate_raises_error(self, temp_registry, sample_branches):
"""Test registering duplicate branch raises error."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
with pytest.raises(BranchAlreadyExistsError):
register_branch("agent1", str(branch_path), "agent")
def test_register_branch_nonexistent_path_raises_error(self, temp_registry):
"""Test registering nonexistent path raises error."""
fake_path = "/nonexistent/path/to/branch"
with pytest.raises(InvalidPathError):
register_branch("fake_branch", fake_path, "agent")
def test_register_branch_file_not_directory_raises_error(self, temp_registry, tmp_path):
"""Test registering a file instead of directory raises error."""
file_path = tmp_path / "file.txt"
file_path.write_text("test")
with pytest.raises(InvalidPathError):
register_branch("bad_branch", str(file_path), "agent")
def test_register_branch_auto_initializes_registry(self, tmp_path):
"""Test registering branch auto-initializes registry if it doesn't exist."""
registry_path = tmp_path / "new_registry.json"
set_registry_path(registry_path)
# Registry doesn't exist yet
assert not registry_path.exists()
# Register branch should auto-initialize
branch_path = tmp_path / "agent"
branch_path.mkdir()
register_branch("agent", str(branch_path), "agent")
# Registry should now exist
assert registry_path.exists()
assert branch_exists("agent")
reset_registry_path()
class TestBranchResolution:
"""Test branch resolution functionality."""
def test_resolve_branch_with_at_prefix(self, temp_registry, sample_branches):
"""Test resolving branch with @ prefix."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
resolved = resolve_branch("@agent1")
assert Path(resolved) == branch_path
def test_resolve_branch_without_at_prefix(self, temp_registry, sample_branches):
"""Test resolving branch without @ prefix."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
resolved = resolve_branch("agent1")
assert Path(resolved) == branch_path
def test_resolve_branch_not_found_raises_error(self, temp_registry):
"""Test resolving nonexistent branch raises error."""
with pytest.raises(BranchNotFoundError):
resolve_branch("@nonexistent")
def test_resolve_branch_no_registry_raises_error(self, tmp_path):
"""Test resolving branch without registry raises error."""
registry_path = tmp_path / "missing_registry.json"
set_registry_path(registry_path)
with pytest.raises(RegistryNotFoundError):
resolve_branch("@agent1")
reset_registry_path()
class TestBranchExists:
"""Test branch existence checking."""
def test_branch_exists_true(self, temp_registry, sample_branches):
"""Test branch_exists returns True for registered branch."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
assert branch_exists("@agent1")
assert branch_exists("agent1")
def test_branch_exists_false(self, temp_registry):
"""Test branch_exists returns False for unregistered branch."""
assert not branch_exists("@nonexistent")
assert not branch_exists("nonexistent")
def test_branch_exists_no_registry_returns_false(self, tmp_path):
"""Test branch_exists returns False when registry doesn't exist."""
registry_path = tmp_path / "missing_registry.json"
set_registry_path(registry_path)
assert not branch_exists("@agent1")
reset_registry_path()
class TestBranchInfo:
"""Test branch metadata retrieval."""
def test_get_branch_info_basic(self, temp_registry, sample_branches):
"""Test getting branch info."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
info = get_branch_info("@agent1")
assert info["name"] == "agent1"
assert Path(info["path"]) == branch_path
assert info["type"] == "agent"
assert info["status"] == "active"
assert "created" in info
def test_get_branch_info_without_at_prefix(self, temp_registry, sample_branches):
"""Test getting branch info without @ prefix."""
branch_path = sample_branches["agent1"]
register_branch("agent1", str(branch_path), "agent")
info = get_branch_info("agent1")
assert info["name"] == "agent1"
def test_get_branch_info_not_found_raises_error(self, temp_registry):
"""Test getting info for nonexistent branch raises error."""
with pytest.raises(BranchNotFoundError):
get_branch_info("@nonexistent")
class TestListBranches:
"""Test branch listing functionality."""
def test_list_branches_all(self, temp_registry, sample_branches):
"""Test listing all branches."""
register_branch("agent1", str(sample_branches["agent1"]), "agent")
register_branch("agent2", str(sample_branches["agent2"]), "agent")
register_branch("service1", str(sample_branches["service1"]), "service")
branches = list_branches()
assert "@agent1" in branches
assert "@agent2" in branches
assert "@service1" in branches
assert len(branches) == 3
def test_list_branches_by_type(self, temp_registry, sample_branches):
"""Test listing branches filtered by type."""
register_branch("agent1", str(sample_branches["agent1"]), "agent")
register_branch("agent2", str(sample_branches["agent2"]), "agent")
register_branch("service1", str(sample_branches["service1"]), "service")
agents = list_branches(branch_type="agent")
services = list_branches(branch_type="service")
assert "@agent1" in agents
assert "@agent2" in agents
assert "@service1" not in agents
assert len(agents) == 2
assert "@service1" in services
assert "@agent1" not in services
assert len(services) == 1
def test_list_branches_empty_registry(self, temp_registry):
"""Test listing branches with empty registry."""
branches = list_branches()
assert branches == []
def test_list_branches_no_registry(self, tmp_path):
"""Test listing branches when registry doesn't exist."""
registry_path = tmp_path / "missing_registry.json"
set_registry_path(registry_path)
branches = list_branches()
assert branches == []
reset_registry_path()
class TestErrorHandling:
"""Test error handling scenarios."""
def test_corrupt_registry_raises_error(self, tmp_path):
"""Test corrupted registry file raises error."""
registry_path = tmp_path / "corrupt_registry.json"
registry_path.write_text("invalid json {{{")
set_registry_path(registry_path)
with pytest.raises(Exception): # RegistryCorruptError or similar
resolve_branch("@agent1")
reset_registry_path()
def test_missing_branches_field_raises_error(self, tmp_path):
"""Test registry missing 'branches' field raises error."""
registry_path = tmp_path / "bad_registry.json"
registry_path.write_text(json.dumps({"version": "1.0"}))
set_registry_path(registry_path)
with pytest.raises(Exception): # RegistryCorruptError
resolve_branch("@agent1")
reset_registry_path()
class TestEndToEnd:
"""End-to-end integration tests."""
def test_full_workflow(self, tmp_path):
"""Test complete workflow: initialize → register → resolve → list → exists."""
# Setup
registry_path = tmp_path / "registry.json"
set_registry_path(registry_path)
initialize_registry()
# Create branch directories
agent1_path = tmp_path / "agent1"
agent1_path.mkdir()
agent2_path = tmp_path / "agent2"
agent2_path.mkdir()
# Register branches
register_branch("agent1", str(agent1_path), "agent")
register_branch("agent2", str(agent2_path), "agent")
# Verify existence
assert branch_exists("@agent1")
assert branch_exists("@agent2")
assert not branch_exists("@agent3")
# Resolve paths
resolved1 = resolve_branch("@agent1")
resolved2 = resolve_branch("agent2")
assert Path(resolved1) == agent1_path
assert Path(resolved2) == agent2_path
# Get info
info = get_branch_info("@agent1")
assert info["name"] == "agent1"
assert info["type"] == "agent"
# List branches
branches = list_branches()
assert len(branches) == 2
assert "@agent1" in branches
assert "@agent2" in branches
# Cleanup
reset_registry_path()
+504
View File
@@ -0,0 +1,504 @@
"""
Integration tests for the AIPass routing module (Phase 3).
End-to-end tests that exercise the full workflow using real temp directories
with proper branch file structures. All subprocess calls are mocked so no
real Python processes are spawned.
"""
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from aipass.routing import (
BranchNotFoundError,
CommandExecutionError,
CommandResult,
HelpResult,
branch_exists,
discover_modules,
get_branch_info,
get_help,
initialize_registry,
list_branches,
register_branch,
reset_registry_path,
resolve_branch,
route_all,
route_command,
set_registry_path,
)
from aipass.routing.discovery import get_system_help
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_cp(stdout=b"", stderr=b"", returncode=0):
"""Build a mock subprocess.CompletedProcess."""
cp = MagicMock(spec=subprocess.CompletedProcess)
cp.stdout = stdout
cp.stderr = stderr
cp.returncode = returncode
return cp
def _make_branch(root: Path, name: str) -> Path:
"""
Create a minimal branch directory layout under root.
Layout:
root/{name}/
apps/
{name}.py ← entry point
modules/
status.py
info.py
"""
branch_dir = root / name
apps_dir = branch_dir / "apps"
modules_dir = apps_dir / "modules"
modules_dir.mkdir(parents=True)
(apps_dir / f"{name}.py").write_text(f"# {name} entry point\n")
(modules_dir / "status.py").write_text("")
(modules_dir / "info.py").write_text("")
return branch_dir
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry(tmp_path):
"""Isolated registry backed by a temp directory."""
registry_path = tmp_path / "registry.json"
set_registry_path(registry_path)
initialize_registry()
yield tmp_path
reset_registry_path()
@pytest.fixture
def multi_branch_registry(tmp_path):
"""
Registry pre-populated with three branches: alpha, beta, gamma.
Each has a full apps/{name}.py + apps/modules/ structure.
"""
registry_path = tmp_path / "registry.json"
set_registry_path(registry_path)
initialize_registry()
for name in ("alpha", "beta", "gamma"):
branch_dir = _make_branch(tmp_path, name)
register_branch(name, str(branch_dir), "agent")
yield tmp_path
reset_registry_path()
# ---------------------------------------------------------------------------
# 1. Full workflow: initialize → register → resolve → discover → help
# ---------------------------------------------------------------------------
class TestFullWorkflow:
"""End-to-end happy-path tests."""
def test_initialize_register_resolve(self, tmp_path):
"""Complete init → register → resolve cycle."""
registry_path = tmp_path / "reg.json"
set_registry_path(registry_path)
initialize_registry()
branch_dir = _make_branch(tmp_path, "mybot")
register_branch("mybot", str(branch_dir), "agent")
resolved = resolve_branch("@mybot")
assert Path(resolved) == branch_dir
reset_registry_path()
def test_register_multiple_branches_and_list(self, registry, tmp_path):
"""Register several branches and verify list_branches returns all."""
names = ["alice", "bob", "charlie"]
for name in names:
bd = _make_branch(tmp_path, name)
register_branch(name, str(bd), "agent")
found = list_branches()
for name in names:
assert f"@{name}" in found
assert len(found) == 3
def test_resolve_then_discover_then_help(self, multi_branch_registry):
"""Resolve a branch, discover its modules, then get help."""
# Resolve
path = resolve_branch("@alpha")
assert Path(path).is_dir()
# Discover — mock help output with a Commands section
help_bytes = b"Usage: alpha.py\n\nCommands:\n status Check status\n info Show info\n"
with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)):
modules = discover_modules("@alpha")
assert "status" in modules
assert "info" in modules
# Help — returns structured HelpResult
with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)):
result = get_help("@alpha")
assert isinstance(result, HelpResult)
assert result.branch == "alpha"
assert result.command is None
assert "status" in result.commands_found
def test_route_command_after_register(self, multi_branch_registry):
"""route_command reaches a registered branch successfully."""
cp = _make_cp(stdout=b"alpha is running\n", returncode=0)
with patch("subprocess.run", return_value=cp):
result = route_command("@alpha", "status")
assert result.exit_code == 0
assert "alpha is running" in result.stdout
assert result.branch == "alpha"
assert result.command == "status"
def test_branch_exists_after_register(self, registry, tmp_path):
"""branch_exists returns True immediately after registration."""
bd = _make_branch(tmp_path, "sentinel")
register_branch("sentinel", str(bd), "service")
assert branch_exists("@sentinel")
assert branch_exists("sentinel")
assert not branch_exists("@ghost")
def test_get_branch_info_after_register(self, registry, tmp_path):
"""get_branch_info returns correct metadata after registration."""
bd = _make_branch(tmp_path, "worker")
register_branch("worker", str(bd), "service")
info = get_branch_info("@worker")
assert info["name"] == "worker"
assert info["type"] == "service"
assert info["status"] == "active"
assert Path(info["path"]) == bd
assert "created" in info
# ---------------------------------------------------------------------------
# 2. HelpResult structure
# ---------------------------------------------------------------------------
class TestHelpResult:
"""Verify HelpResult dataclass fields and behaviour."""
def test_help_result_fields(self, multi_branch_registry):
"""HelpResult exposes branch, command, text, commands_found."""
help_bytes = b"Usage: beta.py\n\nCommands:\n run Run a task\n stop Stop\n"
with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)):
result = get_help("@beta")
assert result.branch == "beta"
assert result.command is None
assert "Usage" in result.text
assert isinstance(result.commands_found, list)
assert "run" in result.commands_found
assert "stop" in result.commands_found
def test_help_result_command_level(self, multi_branch_registry):
"""HelpResult.command is set when a specific command is queried."""
help_bytes = b"Usage: beta.py run [options]\n --verbose Verbose output\n"
with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)):
result = get_help("@beta", command="run")
assert result.command == "run"
assert "Usage" in result.text
def test_help_result_stderr_fallback(self, multi_branch_registry):
"""HelpResult.text comes from stderr when stdout is empty."""
with patch("subprocess.run", return_value=_make_cp(stdout=b"", stderr=b"from stderr\n")):
result = get_help("@gamma")
assert "from stderr" in result.text
def test_help_result_empty_commands_found(self, multi_branch_registry):
"""commands_found is empty when help text has no commands section."""
with patch("subprocess.run", return_value=_make_cp(stdout=b"No commands here.\n")):
result = get_help("@gamma")
assert result.commands_found == []
def test_help_result_importable_from_package(self):
"""HelpResult is importable directly from aipass.routing."""
from aipass.routing import HelpResult as HR
assert HR is HelpResult
# ---------------------------------------------------------------------------
# 3. @all routing: route_all
# ---------------------------------------------------------------------------
class TestRouteAll:
"""Tests for route_all — fan-out command to all active branches."""
def test_route_all_reaches_all_branches(self, multi_branch_registry):
"""route_all returns a result for every registered branch."""
cp = _make_cp(stdout=b"ok\n", returncode=0)
with patch("subprocess.run", return_value=cp):
results = route_all("status")
assert set(results.keys()) == {"alpha", "beta", "gamma"}
def test_route_all_returns_command_results(self, multi_branch_registry):
"""Each value in route_all output is a CommandResult."""
cp = _make_cp(stdout=b"running\n", returncode=0)
with patch("subprocess.run", return_value=cp):
results = route_all("status")
for branch_name, result in results.items():
assert isinstance(result, CommandResult)
assert result.branch == branch_name
assert result.command == "status"
def test_route_all_with_args(self, multi_branch_registry):
"""route_all forwards extra args to every branch."""
cp = _make_cp(stdout=b"verbose\n", returncode=0)
with patch("subprocess.run", return_value=cp) as mock_run:
route_all("run", args=["--verbose"])
# Every call should include --verbose
for call in mock_run.call_args_list:
cmd_list = call[0][0]
assert "--verbose" in cmd_list
def test_route_all_continues_on_branch_failure(self, multi_branch_registry):
"""route_all continues even when one branch raises CommandExecutionError."""
call_count = 0
def side_effect(*a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
raise CommandExecutionError("branch exploded")
return _make_cp(stdout=b"ok\n", returncode=0)
with patch("subprocess.run", side_effect=side_effect):
results = route_all("status")
# All three branches present in results
assert len(results) == 3
def test_route_all_failed_branch_has_negative_exit_code(self, multi_branch_registry):
"""A branch that raises an error gets exit_code=-1 in route_all result."""
def side_effect(*a, **kw):
raise CommandExecutionError("no entry point")
with patch("subprocess.run", side_effect=side_effect):
results = route_all("status")
for result in results.values():
assert result.exit_code == -1
assert result.stderr != ""
def test_route_all_empty_registry(self, registry):
"""route_all returns empty dict when no branches are registered."""
results = route_all("status")
assert results == {}
def test_route_all_timeout_per_branch(self, multi_branch_registry):
"""route_all forwards the timeout to each branch call."""
cp = _make_cp(stdout=b"ok\n", returncode=0)
with patch("subprocess.run", return_value=cp) as mock_run:
route_all("status", timeout=5)
for call in mock_run.call_args_list:
_, kwargs = call
assert kwargs.get("timeout") == 5
def test_route_all_importable_from_package(self):
"""route_all is importable from aipass.routing."""
from aipass.routing import route_all as ra
assert callable(ra)
# ---------------------------------------------------------------------------
# 4. get_system_help — aggregated help across all branches
# ---------------------------------------------------------------------------
class TestGetSystemHelp:
"""Tests for get_system_help — fan-out help query across all branches."""
def test_system_help_returns_dict(self, multi_branch_registry):
"""get_system_help returns a dict keyed by branch name."""
help_bytes = b"Usage: x.py\n\nCommands:\n status Check\n"
with patch("subprocess.run", return_value=_make_cp(stdout=help_bytes)):
results = get_system_help()
assert isinstance(results, dict)
assert set(results.keys()) == {"alpha", "beta", "gamma"}
def test_system_help_values_are_help_results(self, multi_branch_registry):
"""Each value in get_system_help output is a HelpResult."""
with patch("subprocess.run", return_value=_make_cp(stdout=b"help\n")):
results = get_system_help()
for result in results.values():
assert isinstance(result, HelpResult)
def test_system_help_skips_failing_branches(self, multi_branch_registry):
"""get_system_help omits branches whose help command fails."""
call_count = 0
def side_effect(*a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
raise CommandExecutionError("boom")
return _make_cp(stdout=b"help\n")
with patch("subprocess.run", side_effect=side_effect):
results = get_system_help()
# At least two of the three branches should succeed
assert len(results) >= 2
def test_system_help_empty_when_no_branches(self, registry):
"""get_system_help returns empty dict with no registered branches."""
results = get_system_help()
assert results == {}
# ---------------------------------------------------------------------------
# 5. Error recovery — branches disappear mid-operation
# ---------------------------------------------------------------------------
class TestErrorRecovery:
"""Tests for graceful handling of unexpected runtime conditions."""
def test_resolve_nonexistent_branch_raises(self, registry):
"""Resolving an unregistered branch raises BranchNotFoundError."""
with pytest.raises(BranchNotFoundError):
resolve_branch("@ghost")
def test_route_command_nonexistent_branch_raises(self, registry):
"""Routing to an unregistered branch raises BranchNotFoundError."""
with pytest.raises(BranchNotFoundError):
route_command("@ghost", "status")
def test_discover_modules_nonexistent_branch_raises(self, registry):
"""discover_modules on unregistered branch raises BranchNotFoundError."""
with pytest.raises(BranchNotFoundError):
discover_modules("@ghost")
def test_get_help_nonexistent_branch_raises(self, registry):
"""get_help on unregistered branch raises BranchNotFoundError."""
with pytest.raises(BranchNotFoundError):
get_help("@ghost")
def test_branch_without_entry_point_raises_on_route(self, tmp_path):
"""route_command raises CommandExecutionError when entry point missing."""
registry_path = tmp_path / "reg.json"
set_registry_path(registry_path)
initialize_registry()
# Register a branch that has NO apps/{name}.py
no_entry = tmp_path / "bare"
no_entry.mkdir()
register_branch("bare", str(no_entry), "agent")
with pytest.raises(CommandExecutionError, match="Entry point not found"):
route_command("@bare", "status")
reset_registry_path()
def test_branch_without_entry_point_raises_on_help(self, tmp_path):
"""get_help raises CommandExecutionError when entry point missing."""
registry_path = tmp_path / "reg.json"
set_registry_path(registry_path)
initialize_registry()
no_entry = tmp_path / "bare2"
no_entry.mkdir()
register_branch("bare2", str(no_entry), "agent")
with pytest.raises(CommandExecutionError, match="Entry point not found"):
get_help("@bare2")
reset_registry_path()
# ---------------------------------------------------------------------------
# 6. Registry persistence across multiple operations
# ---------------------------------------------------------------------------
class TestRegistryPersistence:
"""Verify the registry file survives multiple read/write cycles."""
def test_registry_persists_across_register_calls(self, tmp_path):
"""Each register_branch call is durable in the JSON file."""
registry_path = tmp_path / "persist.json"
set_registry_path(registry_path)
initialize_registry()
for name in ("p1", "p2", "p3"):
bd = _make_branch(tmp_path, name)
register_branch(name, str(bd), "agent")
# Read the raw JSON and verify all branches are there
with open(registry_path, encoding="utf-8") as fh:
data = json.load(fh)
assert "p1" in data["branches"]
assert "p2" in data["branches"]
assert "p3" in data["branches"]
reset_registry_path()
def test_registry_metadata_updated_on_write(self, tmp_path):
"""Registry metadata.last_updated is refreshed on each write."""
registry_path = tmp_path / "meta.json"
set_registry_path(registry_path)
initialize_registry()
bd = _make_branch(tmp_path, "meta_agent")
register_branch("meta_agent", str(bd), "agent")
with open(registry_path, encoding="utf-8") as fh:
data = json.load(fh)
assert "last_updated" in data["metadata"]
assert data["metadata"]["managed_by"] == "aipass.routing"
reset_registry_path()
def test_list_branches_reflects_all_registrations(self, tmp_path):
"""list_branches always reflects the on-disk state."""
registry_path = tmp_path / "list_test.json"
set_registry_path(registry_path)
initialize_registry()
bd1 = _make_branch(tmp_path, "lx1")
bd2 = _make_branch(tmp_path, "lx2")
register_branch("lx1", str(bd1), "agent")
assert len(list_branches()) == 1
register_branch("lx2", str(bd2), "agent")
assert len(list_branches()) == 2
assert "@lx1" in list_branches()
assert "@lx2" in list_branches()
reset_registry_path()
+546
View File
@@ -0,0 +1,546 @@
"""
Unit tests for Phase 2 routing functionality.
Tests command routing, subprocess execution, and module discovery with >80%
coverage on new code. All subprocess calls are mocked — no real processes run.
"""
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from aipass.routing import (
BranchNotFoundError,
CommandExecutionError,
CommandResult,
discover_modules,
get_help,
initialize_registry,
register_branch,
reset_registry_path,
route_command,
set_registry_path,
)
from aipass.routing.executor import execute_command
from aipass.routing.router import _find_entry_point
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def temp_registry(tmp_path):
"""Temporary registry backed by a tmp directory."""
registry_path = tmp_path / "test_registry.json"
set_registry_path(registry_path)
initialize_registry()
yield tmp_path
reset_registry_path()
@pytest.fixture
def branch_with_entry(tmp_path):
"""
A registered branch that has a valid apps/{name}.py entry point.
Layout:
tmp_path/
myagent/
apps/
myagent.py
modules/
status.py
info.py
"""
branch_dir = tmp_path / "myagent"
apps_dir = branch_dir / "apps"
modules_dir = apps_dir / "modules"
modules_dir.mkdir(parents=True)
entry = apps_dir / "myagent.py"
entry.write_text("# stub entry point\n")
(modules_dir / "status.py").write_text("")
(modules_dir / "info.py").write_text("")
(modules_dir / "__init__.py").write_text("")
registry_path = tmp_path / "registry.json"
set_registry_path(registry_path)
initialize_registry()
register_branch("myagent", str(branch_dir), "agent")
yield branch_dir
reset_registry_path()
@pytest.fixture
def branch_without_entry(tmp_path):
"""A registered branch that has NO apps/{name}.py entry point."""
branch_dir = tmp_path / "noentry"
apps_dir = branch_dir / "apps"
modules_dir = apps_dir / "modules"
modules_dir.mkdir(parents=True)
(modules_dir / "do_thing.py").write_text("")
registry_path = tmp_path / "registry.json"
set_registry_path(registry_path)
initialize_registry()
register_branch("noentry", str(branch_dir), "agent")
yield branch_dir
reset_registry_path()
def _make_completed_process(stdout=b"", stderr=b"", returncode=0):
"""Helper to build a subprocess.CompletedProcess mock."""
cp = MagicMock(spec=subprocess.CompletedProcess)
cp.stdout = stdout
cp.stderr = stderr
cp.returncode = returncode
return cp
# ---------------------------------------------------------------------------
# CommandResult structure
# ---------------------------------------------------------------------------
class TestCommandResult:
"""Verify CommandResult is a proper dataclass with expected fields."""
def test_fields_present(self):
"""CommandResult exposes stdout, stderr, exit_code, branch, command."""
result = CommandResult(
stdout="hello",
stderr="",
exit_code=0,
branch="myagent",
command="status",
)
assert result.stdout == "hello"
assert result.stderr == ""
assert result.exit_code == 0
assert result.branch == "myagent"
assert result.command == "status"
def test_non_zero_exit_code(self):
"""CommandResult stores non-zero exit codes without raising."""
result = CommandResult(
stdout="", stderr="error", exit_code=1, branch="b", command="c"
)
assert result.exit_code == 1
def test_dataclass_equality(self):
"""Two CommandResult instances with identical values compare equal."""
a = CommandResult("out", "err", 0, "b", "c")
b = CommandResult("out", "err", 0, "b", "c")
assert a == b
# ---------------------------------------------------------------------------
# executor.execute_command
# ---------------------------------------------------------------------------
class TestExecuteCommand:
"""Tests for the low-level subprocess wrapper."""
def test_successful_execution(self, tmp_path):
"""execute_command returns CommandResult on success."""
cp = _make_completed_process(stdout=b"ok\n", returncode=0)
with patch("subprocess.run", return_value=cp) as mock_run:
result = execute_command("python3", ["script.py"], cwd=str(tmp_path))
assert result.stdout == "ok\n"
assert result.exit_code == 0
mock_run.assert_called_once()
def test_no_shell_true(self, tmp_path):
"""execute_command never passes shell=True to subprocess.run."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
execute_command("python3", ["x.py"], cwd=str(tmp_path))
_, kwargs = mock_run.call_args
assert kwargs.get("shell") is False
def test_timeout_raises_command_execution_error(self, tmp_path):
"""TimeoutExpired is wrapped in CommandExecutionError."""
with patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=5),
):
with pytest.raises(CommandExecutionError, match="timed out"):
execute_command("python3", ["x.py"], cwd=str(tmp_path), timeout=5)
def test_file_not_found_raises_command_execution_error(self, tmp_path):
"""FileNotFoundError is wrapped in CommandExecutionError."""
with patch("subprocess.run", side_effect=FileNotFoundError):
with pytest.raises(CommandExecutionError, match="not found"):
execute_command("nonexistent_bin", [], cwd=str(tmp_path))
def test_os_error_raises_command_execution_error(self, tmp_path):
"""Generic OSError is wrapped in CommandExecutionError."""
with patch("subprocess.run", side_effect=OSError("permission denied")):
with pytest.raises(CommandExecutionError, match="OS error"):
execute_command("python3", [], cwd=str(tmp_path))
def test_stderr_captured(self, tmp_path):
"""execute_command captures stderr separately from stdout."""
cp = _make_completed_process(stdout=b"", stderr=b"warn\n", returncode=1)
with patch("subprocess.run", return_value=cp):
result = execute_command("python3", ["x.py"], cwd=str(tmp_path))
assert result.stderr == "warn\n"
assert result.exit_code == 1
def test_branch_and_command_empty_strings(self, tmp_path):
"""execute_command sets branch and command to empty strings."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp):
result = execute_command("python3", [], cwd=str(tmp_path))
assert result.branch == ""
assert result.command == ""
def test_timeout_forwarded(self, tmp_path):
"""Custom timeout value is passed through to subprocess.run."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
execute_command("python3", [], cwd=str(tmp_path), timeout=99)
_, kwargs = mock_run.call_args
assert kwargs["timeout"] == 99
# ---------------------------------------------------------------------------
# router.route_command
# ---------------------------------------------------------------------------
class TestRouteCommand:
"""Tests for the high-level command routing function."""
def test_route_command_success(self, branch_with_entry):
"""route_command returns populated CommandResult on success."""
cp = _make_completed_process(stdout=b"running\n", returncode=0)
with patch("subprocess.run", return_value=cp):
result = route_command("@myagent", "status")
assert result.stdout == "running\n"
assert result.exit_code == 0
assert result.branch == "myagent"
assert result.command == "status"
def test_route_command_without_at_prefix(self, branch_with_entry):
"""route_command accepts branch names without the @ prefix."""
cp = _make_completed_process(stdout=b"ok\n", returncode=0)
with patch("subprocess.run", return_value=cp):
result = route_command("myagent", "status")
assert result.branch == "myagent"
def test_route_command_with_args(self, branch_with_entry):
"""route_command forwards extra args to subprocess."""
cp = _make_completed_process(stdout=b"done\n", returncode=0)
with patch("subprocess.run", return_value=cp) as mock_run:
route_command("@myagent", "run", args=["--verbose", "--dry-run"])
call_args = mock_run.call_args[0][0] # positional list
assert "--verbose" in call_args
assert "--dry-run" in call_args
def test_route_command_missing_branch_raises_error(self, temp_registry):
"""route_command raises BranchNotFoundError for unknown branch."""
with pytest.raises(BranchNotFoundError):
route_command("@ghost", "status")
def test_route_command_missing_entry_point_raises_error(self, branch_without_entry):
"""route_command raises CommandExecutionError when entry point absent."""
with pytest.raises(CommandExecutionError, match="Entry point not found"):
route_command("@noentry", "status")
def test_route_command_timeout(self, branch_with_entry):
"""route_command propagates timeout as CommandExecutionError."""
with patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=1),
):
with pytest.raises(CommandExecutionError, match="timed out"):
route_command("@myagent", "status", timeout=1)
def test_route_command_nonzero_exit_does_not_raise(self, branch_with_entry):
"""Non-zero exit code is returned, not raised."""
cp = _make_completed_process(stderr=b"fail\n", returncode=2)
with patch("subprocess.run", return_value=cp):
result = route_command("@myagent", "status")
assert result.exit_code == 2
assert result.stderr == "fail\n"
def test_route_command_default_timeout_is_30(self, branch_with_entry):
"""Default timeout forwarded to subprocess is 30 seconds."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
route_command("@myagent", "status")
_, kwargs = mock_run.call_args
assert kwargs["timeout"] == 30
# ---------------------------------------------------------------------------
# router._find_entry_point
# ---------------------------------------------------------------------------
class TestFindEntryPoint:
"""Tests for the internal entry-point locator."""
def test_finds_existing_entry_point(self, branch_with_entry):
"""_find_entry_point returns path when entry point exists."""
ep = _find_entry_point(str(branch_with_entry), "myagent")
assert ep.exists()
assert ep.name == "myagent.py"
def test_raises_when_entry_point_missing(self, branch_without_entry):
"""_find_entry_point raises CommandExecutionError when file absent."""
with pytest.raises(CommandExecutionError, match="Entry point not found"):
_find_entry_point(str(branch_without_entry), "noentry")
# ---------------------------------------------------------------------------
# discovery.discover_modules
# ---------------------------------------------------------------------------
class TestDiscoverModules:
"""Tests for branch capability discovery."""
def test_discover_from_help_output(self, branch_with_entry):
"""discover_modules parses commands from --help output."""
help_output = (
b"Usage: myagent.py [command]\n\n"
b"Commands:\n"
b" status Show current status\n"
b" deploy Deploy the agent\n"
)
cp = _make_completed_process(stdout=help_output, returncode=0)
with patch("subprocess.run", return_value=cp):
modules = discover_modules("@myagent")
assert "status" in modules
assert "deploy" in modules
def test_discover_falls_back_to_modules_dir(self, branch_with_entry):
"""discover_modules falls back to scanning modules/ when help unparseable."""
cp = _make_completed_process(stdout=b"No commands here.\n", returncode=0)
with patch("subprocess.run", return_value=cp):
modules = discover_modules("@myagent")
# Falls back to scanning apps/modules/ which contains status.py, info.py
assert "status" in modules
assert "info" in modules
# __init__ is excluded
assert "__init__" not in modules
def test_discover_falls_back_when_subprocess_fails(self, branch_with_entry):
"""discover_modules falls back to modules/ on subprocess OSError."""
with patch("subprocess.run", side_effect=OSError("boom")):
modules = discover_modules("@myagent")
assert "status" in modules
assert "info" in modules
def test_discover_falls_back_on_timeout(self, branch_with_entry):
"""discover_modules falls back to modules/ on help timeout."""
with patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=10),
):
modules = discover_modules("@myagent")
assert isinstance(modules, list)
def test_discover_missing_branch_raises_error(self, temp_registry):
"""discover_modules raises BranchNotFoundError for unknown branch."""
with pytest.raises(BranchNotFoundError):
discover_modules("@phantom")
def test_discover_no_entry_no_modules(self, branch_without_entry):
"""discover_modules returns empty list when no entry and no modules match help."""
cp = _make_completed_process(stdout=b"nothing useful\n", returncode=0)
# branch_without_entry has no entry point, so subprocess is never called for
# --help (discovery skips it), but it does have a modules/ dir.
with patch("subprocess.run", return_value=cp):
modules = discover_modules("@noentry")
# Should find do_thing.py from the modules dir.
assert "do_thing" in modules
def test_discover_returns_list(self, branch_with_entry):
"""discover_modules always returns a list."""
cp = _make_completed_process(stdout=b"", returncode=0)
with patch("subprocess.run", return_value=cp):
result = discover_modules("@myagent")
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# discovery.get_help
# ---------------------------------------------------------------------------
class TestGetHelp:
"""Tests for branch help text retrieval (get_help returns HelpResult)."""
def test_get_help_branch_level(self, branch_with_entry):
"""get_help returns HelpResult with text when command is None."""
help_text = b"Usage: myagent.py [command]\n\nCommands:\n status\n"
cp = _make_completed_process(stdout=help_text, returncode=0)
with patch("subprocess.run", return_value=cp):
result = get_help("@myagent")
assert "Usage" in result.text
assert result.branch == "myagent"
assert result.command is None
def test_get_help_command_level(self, branch_with_entry):
"""get_help passes the command name when command is specified."""
help_text = b"Usage: myagent.py status [options]\n"
cp = _make_completed_process(stdout=help_text, returncode=0)
with patch("subprocess.run", return_value=cp) as mock_run:
result = get_help("@myagent", command="status")
call_args = mock_run.call_args[0][0]
assert "status" in call_args
assert "--help" in call_args
assert "Usage" in result.text
assert result.command == "status"
def test_get_help_falls_back_to_stderr(self, branch_with_entry):
"""get_help uses stderr text when stdout is empty."""
cp = _make_completed_process(stdout=b"", stderr=b"help via stderr\n", returncode=0)
with patch("subprocess.run", return_value=cp):
result = get_help("@myagent")
assert "help via stderr" in result.text
def test_get_help_missing_branch_raises_error(self, temp_registry):
"""get_help raises BranchNotFoundError for unknown branch."""
with pytest.raises(BranchNotFoundError):
get_help("@phantom")
def test_get_help_missing_entry_point_raises_error(self, branch_without_entry):
"""get_help raises CommandExecutionError when entry point absent."""
with pytest.raises(CommandExecutionError, match="Entry point not found"):
get_help("@noentry")
def test_get_help_timeout_raises_error(self, branch_with_entry):
"""get_help raises CommandExecutionError on timeout."""
with patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="python3", timeout=10),
):
with pytest.raises(CommandExecutionError, match="timed out"):
get_help("@myagent")
def test_get_help_os_error_raises_error(self, branch_with_entry):
"""get_help raises CommandExecutionError on OS error."""
with patch("subprocess.run", side_effect=OSError("no such file")):
with pytest.raises(CommandExecutionError, match="OS error"):
get_help("@myagent")
def test_get_help_without_at_prefix(self, branch_with_entry):
"""get_help accepts branch name without @ prefix."""
cp = _make_completed_process(stdout=b"help text\n", returncode=0)
with patch("subprocess.run", return_value=cp):
result = get_help("myagent")
assert "help text" in result.text
def test_get_help_commands_found_populated(self, branch_with_entry):
"""get_help populates commands_found from parsed help text."""
help_text = b"Usage: myagent.py\n\nCommands:\n status Show status\n deploy Deploy\n"
cp = _make_completed_process(stdout=help_text, returncode=0)
with patch("subprocess.run", return_value=cp):
result = get_help("@myagent")
assert "status" in result.commands_found
assert "deploy" in result.commands_found
# ---------------------------------------------------------------------------
# Executor safety: no shell injection
# ---------------------------------------------------------------------------
class TestExecutorSafety:
"""Verify the executor never enables shell features."""
def test_shell_is_false(self, tmp_path):
"""subprocess.run is always called with shell=False."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
execute_command("python3", ["script.py", "arg with spaces"], cwd=str(tmp_path))
_, kwargs = mock_run.call_args
assert kwargs.get("shell") is False
def test_args_passed_as_list(self, tmp_path):
"""Command is passed as a list, never as a shell string."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
execute_command("python3", ["a", "b", "c"], cwd=str(tmp_path))
positional_cmd = mock_run.call_args[0][0]
assert isinstance(positional_cmd, list)
def test_shell_metacharacters_not_interpreted(self, tmp_path):
"""Shell metacharacters in args are passed verbatim, not interpreted."""
cp = _make_completed_process()
with patch("subprocess.run", return_value=cp) as mock_run:
execute_command(
"python3",
["script.py", "; rm -rf /", "$(evil)"],
cwd=str(tmp_path),
)
positional_cmd = mock_run.call_args[0][0]
# Metacharacter strings survive intact as list elements.
assert "; rm -rf /" in positional_cmd
assert "$(evil)" in positional_cmd
# ---------------------------------------------------------------------------
# Public API import smoke test
# ---------------------------------------------------------------------------
class TestPublicAPIImports:
"""Verify all Phase 2 symbols are importable from the top-level package."""
def test_route_command_importable(self):
from aipass.routing import route_command
assert callable(route_command)
def test_discover_modules_importable(self):
from aipass.routing import discover_modules
assert callable(discover_modules)
def test_get_help_importable(self):
from aipass.routing import get_help
assert callable(get_help)
def test_command_result_importable(self):
from aipass.routing import CommandResult
assert CommandResult is not None
def test_version_updated(self):
from aipass.routing import __version__
assert __version__ == "1.0.0"