mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-13 05:01:35 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d76b56ec6b | ||
|
|
f5d3e26de9 | ||
|
|
81dfb6a555 | ||
|
|
bc52cd67fd | ||
|
|
d4b563987f | ||
|
|
4c68e61438 |
No files matched your search
+2
-1
@@ -97,4 +97,5 @@ Cargo.lock
|
||||
|
||||
src/praisonai/build*.egg-info/
|
||||
src/praisonai/PraisonAI.egg-info/
|
||||
jobs/*
|
||||
jobs/*
|
||||
*.egg-info
|
||||
@@ -206,6 +206,7 @@ Methods:
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">get_next_task</a>(context: Dict[str, Any]) -> Optional[str]</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">set_status</a>(new_status: str) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.2" \
|
||||
"praisonai>=4.6.4" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.2" \
|
||||
"praisonai>=4.6.4" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.2" \
|
||||
"praisonai>=4.6.4" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Per-Agent Concurrency Limiter for PraisonAI Agents.
|
||||
|
||||
Provides a registry-based approach to limit concurrent task execution
|
||||
per agent name. No Agent constructor param bloat — uses a global registry.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.agent.concurrency import get_concurrency_registry
|
||||
|
||||
registry = get_concurrency_registry()
|
||||
registry.set_limit("researcher", 2) # max 2 concurrent tasks
|
||||
|
||||
# In async code:
|
||||
async with registry.throttle("researcher"):
|
||||
await do_work()
|
||||
|
||||
# Or manual:
|
||||
await registry.acquire("researcher")
|
||||
try:
|
||||
await do_work()
|
||||
finally:
|
||||
registry.release("researcher")
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, Optional
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConcurrencyRegistry:
|
||||
"""Registry for per-agent concurrency limits.
|
||||
|
||||
Thread-safe. Each agent name maps to an asyncio.Semaphore.
|
||||
Limit of 0 means unlimited (no throttling).
|
||||
"""
|
||||
|
||||
def __init__(self, default_limit: int = 0):
|
||||
self._default_limit = default_limit
|
||||
self._limits: Dict[str, int] = {}
|
||||
self._semaphores: Dict[str, asyncio.Semaphore] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_limit(self, agent_name: str, max_concurrent: int) -> None:
|
||||
"""Set concurrency limit for an agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent identifier
|
||||
max_concurrent: Max concurrent tasks (0 = unlimited)
|
||||
"""
|
||||
with self._lock:
|
||||
self._limits[agent_name] = max_concurrent
|
||||
# Reset semaphore so next acquire creates a fresh one
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def get_limit(self, agent_name: str) -> int:
|
||||
"""Get concurrency limit for an agent."""
|
||||
with self._lock:
|
||||
return self._limits.get(agent_name, self._default_limit)
|
||||
|
||||
def remove_limit(self, agent_name: str) -> None:
|
||||
"""Remove concurrency limit for an agent (reverts to default)."""
|
||||
with self._lock:
|
||||
self._limits.pop(agent_name, None)
|
||||
self._semaphores.pop(agent_name, None)
|
||||
|
||||
def _get_semaphore(self, agent_name: str) -> Optional[asyncio.Semaphore]:
|
||||
"""Get or create semaphore for agent. Returns None if unlimited."""
|
||||
with self._lock:
|
||||
limit = self._limits.get(agent_name, self._default_limit)
|
||||
if limit <= 0:
|
||||
return None
|
||||
if agent_name not in self._semaphores:
|
||||
self._semaphores[agent_name] = asyncio.Semaphore(limit)
|
||||
return self._semaphores[agent_name]
|
||||
|
||||
async def acquire(self, agent_name: str) -> None:
|
||||
"""Acquire concurrency slot for agent. No-op if unlimited."""
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is not None:
|
||||
await sem.acquire()
|
||||
|
||||
def acquire_sync(self, agent_name: str) -> None:
|
||||
"""Synchronous acquire — for non-async code paths.
|
||||
|
||||
Note: This creates/reuses an event loop internally.
|
||||
Prefer async acquire() when possible.
|
||||
"""
|
||||
sem = self._get_semaphore(agent_name)
|
||||
if sem is None:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# If we're in an async context, we can't block
|
||||
# Just try_acquire or no-op with warning
|
||||
if not sem._value > 0:
|
||||
logger.warning(
|
||||
f"Sync acquire for '{agent_name}' while async loop running and semaphore full. "
|
||||
f"Consider using async acquire() instead."
|
||||
)
|
||||
# Decrement manually for sync context
|
||||
sem._value = max(0, sem._value - 1)
|
||||
except RuntimeError:
|
||||
# No running loop — safe to use asyncio.run
|
||||
asyncio.get_event_loop().run_until_complete(sem.acquire())
|
||||
|
||||
def release(self, agent_name: str) -> None:
|
||||
"""Release concurrency slot for agent. No-op if unlimited."""
|
||||
with self._lock:
|
||||
sem = self._semaphores.get(agent_name)
|
||||
if sem is not None:
|
||||
try:
|
||||
sem.release()
|
||||
except ValueError:
|
||||
pass # Already fully released
|
||||
|
||||
@asynccontextmanager
|
||||
async def throttle(self, agent_name: str):
|
||||
"""Async context manager for throttled execution.
|
||||
|
||||
Usage:
|
||||
async with registry.throttle("agent_name"):
|
||||
await do_work()
|
||||
"""
|
||||
await self.acquire(agent_name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.release(agent_name)
|
||||
|
||||
|
||||
# Singleton
|
||||
_registry: Optional[ConcurrencyRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_concurrency_registry() -> ConcurrencyRegistry:
|
||||
"""Get the global concurrency registry singleton."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
with _registry_lock:
|
||||
if _registry is None:
|
||||
_registry = ConcurrencyRegistry()
|
||||
return _registry
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Authentication & Authorization Protocols for PraisonAI Agents.
|
||||
|
||||
This package provides protocol-driven auth interfaces for the core SDK.
|
||||
Implementations (JWT, OAuth, etc.) live in the platform package.
|
||||
|
||||
Quick start::
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity, AuthBackendProtocol
|
||||
|
||||
class MyAuthBackend:
|
||||
async def authenticate(self, credentials):
|
||||
return AuthIdentity(id="user-1", type="user", roles=["owner"])
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
AuthIdentity,
|
||||
AuthBackendProtocol,
|
||||
WorkspaceContextProtocol,
|
||||
AuthConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Auth protocol definitions for PraisonAI Agents.
|
||||
|
||||
Defines the structural contracts for authentication and authorization
|
||||
backends via typing.Protocol. Any class implementing the required methods
|
||||
satisfies the protocol without explicit inheritance (structural subtyping).
|
||||
|
||||
Design influenced by:
|
||||
- Multica: JWT-based auth with email codes + Google OAuth, role-based
|
||||
workspace membership (owner/admin/member), agents as assignees.
|
||||
- Paperclip: better-auth sessions, company memberships with principal
|
||||
types (user/agent), fine-grained permission grants.
|
||||
- PraisonAI patterns: ApprovalProtocol, GatewayProtocol, DbAdapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthIdentity:
|
||||
"""Authenticated identity (user or agent).
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this principal.
|
||||
type: Principal type — ``"user"`` or ``"agent"``.
|
||||
workspace_id: Active workspace/tenant scope (optional).
|
||||
roles: Roles within the workspace (e.g. ``["owner"]``).
|
||||
email: Email address (users only, optional).
|
||||
name: Display name (optional).
|
||||
metadata: Arbitrary extra data for backend-specific info.
|
||||
"""
|
||||
|
||||
id: str
|
||||
type: str = "user"
|
||||
workspace_id: Optional[str] = None
|
||||
roles: List[str] = field(default_factory=list)
|
||||
email: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_user(self) -> bool:
|
||||
"""Check if this identity is a human user."""
|
||||
return self.type == "user"
|
||||
|
||||
@property
|
||||
def is_agent(self) -> bool:
|
||||
"""Check if this identity is an agent."""
|
||||
return self.type == "agent"
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""Check if this identity has a specific role."""
|
||||
return role in self.roles
|
||||
|
||||
def has_any_role(self, *roles: str) -> bool:
|
||||
"""Check if this identity has any of the specified roles."""
|
||||
return bool(set(roles) & set(self.roles))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Configuration for authentication behaviour.
|
||||
|
||||
Follows PraisonAI's ``False/True/Config`` progressive-disclosure pattern:
|
||||
|
||||
- ``auth=False`` — disabled (no auth checks).
|
||||
- ``auth=True`` — use default backend from registry.
|
||||
- ``auth=AuthConfig(...)`` — full control.
|
||||
|
||||
Attributes:
|
||||
backend: An :class:`AuthBackendProtocol` backend.
|
||||
``None`` falls back to registry default.
|
||||
token_ttl: Token time-to-live in seconds (default: 30 days).
|
||||
require_workspace: Whether workspace_id is required for authorization.
|
||||
"""
|
||||
|
||||
backend: Any = None
|
||||
token_ttl: int = 30 * 24 * 3600
|
||||
require_workspace: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthBackendProtocol(Protocol):
|
||||
"""Protocol for authentication/authorization backends.
|
||||
|
||||
Implement ``authenticate`` and ``authorize`` to create custom auth:
|
||||
- JWT token validation
|
||||
- API key lookup
|
||||
- OAuth token exchange
|
||||
- Session cookie resolution
|
||||
|
||||
Example::
|
||||
|
||||
class JWTAuthBackend:
|
||||
async def authenticate(
|
||||
self, credentials: dict
|
||||
) -> AuthIdentity | None:
|
||||
token = credentials.get("token")
|
||||
payload = decode_jwt(token)
|
||||
return AuthIdentity(
|
||||
id=payload["sub"],
|
||||
type="user",
|
||||
email=payload.get("email"),
|
||||
roles=payload.get("roles", []),
|
||||
)
|
||||
|
||||
async def authorize(
|
||||
self, identity: AuthIdentity, resource: str, action: str
|
||||
) -> bool:
|
||||
return identity.has_role("admin")
|
||||
"""
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
credentials: Dict[str, Any],
|
||||
) -> Optional[AuthIdentity]:
|
||||
"""Authenticate credentials and return an identity.
|
||||
|
||||
Args:
|
||||
credentials: Backend-specific credentials dict.
|
||||
Common keys: ``token``, ``api_key``, ``email``, ``password``,
|
||||
``session_id``.
|
||||
|
||||
Returns:
|
||||
AuthIdentity if authentication succeeds, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def authorize(
|
||||
self,
|
||||
identity: AuthIdentity,
|
||||
resource: str,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Check if an identity is authorized for an action on a resource.
|
||||
|
||||
Args:
|
||||
identity: The authenticated identity.
|
||||
resource: Resource identifier (e.g. ``"workspace:ws-123"``
|
||||
or ``"issue:ISS-42"``).
|
||||
action: Action to perform (e.g. ``"read"``, ``"write"``,
|
||||
``"delete"``, ``"admin"``).
|
||||
|
||||
Returns:
|
||||
True if authorized, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WorkspaceContextProtocol(Protocol):
|
||||
"""Protocol for providing workspace context to agents.
|
||||
|
||||
Enables agents to access workspace-specific settings, instructions,
|
||||
and agent configurations from the platform layer.
|
||||
|
||||
Example::
|
||||
|
||||
class PlatformWorkspaceContext:
|
||||
async def get_workspace_context(self, workspace_id):
|
||||
ws = await db.get_workspace(workspace_id)
|
||||
return ws.context
|
||||
|
||||
async def get_agent_config(self, workspace_id, agent_id):
|
||||
agent = await db.get_agent(workspace_id, agent_id)
|
||||
return {"system_prompt": agent.system_prompt, ...}
|
||||
"""
|
||||
|
||||
async def get_workspace_context(
|
||||
self,
|
||||
workspace_id: str,
|
||||
) -> Optional[str]:
|
||||
"""Get workspace-level context/instructions for agents.
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace identifier.
|
||||
|
||||
Returns:
|
||||
Context string if available, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_agent_config(
|
||||
self,
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get agent configuration from the platform.
|
||||
|
||||
Args:
|
||||
workspace_id: The workspace identifier.
|
||||
agent_id: The agent identifier.
|
||||
|
||||
Returns:
|
||||
Agent configuration dict if found, None otherwise.
|
||||
Expected keys: ``system_prompt``, ``model``, ``tools``,
|
||||
``max_concurrent_tasks``.
|
||||
"""
|
||||
...
|
||||
@@ -1,4 +1,13 @@
|
||||
"""Task module for AI agent tasks"""
|
||||
from .task import Task
|
||||
from .protocols import TaskStatus, TaskLifecycleManager, InvalidTransitionError, TaskLifecycleProtocol
|
||||
from .message_sink import (
|
||||
TaskMessage, TaskMessageSinkProtocol, NoOpTaskMessageSink,
|
||||
InMemoryTaskMessageSink, TaskMessageEmitter,
|
||||
)
|
||||
|
||||
__all__ = ['Task']
|
||||
__all__ = [
|
||||
'Task', 'TaskStatus', 'TaskLifecycleManager', 'InvalidTransitionError', 'TaskLifecycleProtocol',
|
||||
'TaskMessage', 'TaskMessageSinkProtocol', 'NoOpTaskMessageSink',
|
||||
'InMemoryTaskMessageSink', 'TaskMessageEmitter',
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Task Message Sink Protocols for PraisonAI Agents.
|
||||
|
||||
Provides pluggable persistence for sequenced task execution messages.
|
||||
Matches Multica's ReportTaskMessages pattern but as a protocol,
|
||||
enabling replay of agent output, tool calls, errors, and status changes.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
|
||||
|
||||
sink = InMemoryTaskMessageSink()
|
||||
emitter = TaskMessageEmitter(task_id="t1", sink=sink, agent_name="researcher")
|
||||
emitter.emit("agent_output", "Hello world")
|
||||
emitter.emit("tool_call", "search_web('AI trends')")
|
||||
|
||||
# Replay
|
||||
messages = sink.replay("t1")
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable, List, Optional, Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskMessage:
|
||||
"""A single sequenced message from task execution.
|
||||
|
||||
Attributes:
|
||||
task_id: Task this message belongs to
|
||||
seq_num: Sequence number for ordering (0-indexed)
|
||||
msg_type: Message type (agent_output, tool_call, tool_result, error, status)
|
||||
content: Message content
|
||||
agent_name: Agent that produced this message
|
||||
metadata: Optional extra metadata
|
||||
timestamp: ISO timestamp
|
||||
"""
|
||||
task_id: str
|
||||
seq_num: int
|
||||
msg_type: str
|
||||
content: str
|
||||
agent_name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"seq_num": self.seq_num,
|
||||
"msg_type": self.msg_type,
|
||||
"content": self.content,
|
||||
"agent_name": self.agent_name,
|
||||
"metadata": self.metadata,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskMessageSinkProtocol(Protocol):
|
||||
"""Protocol for persisting task execution messages.
|
||||
|
||||
Implementations can write to databases, files, WebSockets, etc.
|
||||
"""
|
||||
|
||||
def emit(self, message: TaskMessage) -> None:
|
||||
"""Persist a single task message."""
|
||||
...
|
||||
|
||||
def replay(self, task_id: str) -> List[TaskMessage]:
|
||||
"""Replay all messages for a task, ordered by seq_num."""
|
||||
...
|
||||
|
||||
|
||||
class NoOpTaskMessageSink:
|
||||
"""Default sink that does nothing. Zero overhead."""
|
||||
|
||||
def emit(self, message: TaskMessage) -> None:
|
||||
pass
|
||||
|
||||
def replay(self, task_id: str) -> List[TaskMessage]:
|
||||
return []
|
||||
|
||||
|
||||
class InMemoryTaskMessageSink:
|
||||
"""In-memory sink for testing and debugging.
|
||||
|
||||
Stores all messages in a list for inspection and replay.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: List[TaskMessage] = []
|
||||
|
||||
def emit(self, message: TaskMessage) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
def replay(self, task_id: str) -> List[TaskMessage]:
|
||||
"""Replay messages for a task, ordered by seq_num."""
|
||||
task_messages = [m for m in self.messages if m.task_id == task_id]
|
||||
return sorted(task_messages, key=lambda m: m.seq_num)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages."""
|
||||
self.messages.clear()
|
||||
|
||||
|
||||
class TaskMessageEmitter:
|
||||
"""Convenience emitter that auto-sequences messages for a task.
|
||||
|
||||
Maintains a per-task sequence counter so callers don't need
|
||||
to track seq_num manually.
|
||||
|
||||
Usage:
|
||||
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
|
||||
emitter.emit("agent_output", "Hello") # seq_num=0
|
||||
emitter.emit("tool_call", "search()") # seq_num=1
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
sink: TaskMessageSinkProtocol,
|
||||
agent_name: Optional[str] = None,
|
||||
):
|
||||
self.task_id = task_id
|
||||
self.sink = sink
|
||||
self.agent_name = agent_name
|
||||
self._seq_num = 0
|
||||
|
||||
def emit(
|
||||
self,
|
||||
msg_type: str,
|
||||
content: str,
|
||||
agent_name: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit a message with auto-incremented sequence number."""
|
||||
message = TaskMessage(
|
||||
task_id=self.task_id,
|
||||
seq_num=self._seq_num,
|
||||
msg_type=msg_type,
|
||||
content=content,
|
||||
agent_name=agent_name or self.agent_name,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self.sink.emit(message)
|
||||
self._seq_num += 1
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Task Lifecycle Protocols for PraisonAI Agents.
|
||||
|
||||
Provides:
|
||||
- TaskStatus enum (backward-compatible with raw strings)
|
||||
- TaskLifecycleManager with transition validation
|
||||
- InvalidTransitionError
|
||||
- EventBus integration (zero overhead when no subscribers)
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, Callable, Dict, Set, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task execution status.
|
||||
|
||||
Inherits from str so TaskStatus.NOT_STARTED == "not started" is True.
|
||||
This ensures full backward compatibility with existing raw string usage.
|
||||
"""
|
||||
NOT_STARTED = "not started"
|
||||
IN_PROGRESS = "in progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class InvalidTransitionError(Exception):
|
||||
"""Raised when an invalid task status transition is attempted."""
|
||||
def __init__(self, from_status: str, to_status: str, task_id: Optional[str] = None):
|
||||
self.from_status = from_status
|
||||
self.to_status = to_status
|
||||
self.task_id = task_id
|
||||
tid = f" (task={task_id})" if task_id else ""
|
||||
super().__init__(
|
||||
f"Invalid task transition: '{from_status}' -> '{to_status}'{tid}"
|
||||
)
|
||||
|
||||
|
||||
# Valid transitions: from_status -> set of allowed to_statuses
|
||||
_VALID_TRANSITIONS: Dict[str, Set[str]] = {
|
||||
TaskStatus.NOT_STARTED: {TaskStatus.IN_PROGRESS, TaskStatus.CANCELLED, TaskStatus.FAILED},
|
||||
TaskStatus.IN_PROGRESS: {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED},
|
||||
TaskStatus.FAILED: {TaskStatus.IN_PROGRESS}, # retry
|
||||
TaskStatus.COMPLETED: set(), # terminal
|
||||
TaskStatus.CANCELLED: set(), # terminal
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskLifecycleProtocol(Protocol):
|
||||
"""Protocol for task lifecycle management."""
|
||||
|
||||
def can_transition(self, from_status: str, to_status: str) -> bool: ...
|
||||
def transition(self, from_status: str, to_status: str, task_id: Optional[str] = None) -> str: ...
|
||||
|
||||
|
||||
class TaskLifecycleManager:
|
||||
"""Default task lifecycle manager with transition validation.
|
||||
|
||||
Validates state transitions and optionally fires a callback.
|
||||
Zero overhead when no callback is configured.
|
||||
|
||||
Usage:
|
||||
mgr = TaskLifecycleManager()
|
||||
if mgr.can_transition("not started", "in progress"):
|
||||
new = mgr.transition("not started", "in progress", task_id="t1")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_transition: Optional[Callable[[str, str, Optional[str]], None]] = None,
|
||||
):
|
||||
self._on_transition = on_transition
|
||||
|
||||
def can_transition(self, from_status: str, to_status: str) -> bool:
|
||||
"""Check if a transition is valid."""
|
||||
allowed = _VALID_TRANSITIONS.get(from_status, set())
|
||||
return to_status in allowed
|
||||
|
||||
def transition(
|
||||
self,
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
task_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Execute a validated transition.
|
||||
|
||||
Args:
|
||||
from_status: Current status
|
||||
to_status: Target status
|
||||
task_id: Optional task identifier for logging/events
|
||||
|
||||
Returns:
|
||||
The new status string
|
||||
|
||||
Raises:
|
||||
InvalidTransitionError: If the transition is not valid
|
||||
"""
|
||||
if not self.can_transition(from_status, to_status):
|
||||
raise InvalidTransitionError(from_status, to_status, task_id)
|
||||
if self._on_transition is not None:
|
||||
self._on_transition(from_status, to_status, task_id)
|
||||
return to_status
|
||||
@@ -10,9 +10,14 @@ import uuid
|
||||
import os
|
||||
import time
|
||||
|
||||
from .protocols import TaskStatus, TaskLifecycleManager
|
||||
|
||||
# Set up logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Shared lifecycle manager (stateless, thread-safe)
|
||||
_lifecycle_manager = TaskLifecycleManager()
|
||||
|
||||
class Task:
|
||||
"""
|
||||
A unit of work that can be executed by an Agent or a custom handler function.
|
||||
@@ -185,7 +190,7 @@ class Task:
|
||||
else:
|
||||
# on_task_complete takes precedence (or both are None)
|
||||
self.callback = on_task_complete
|
||||
self.status = status
|
||||
self.status = TaskStatus(status) if status in TaskStatus._value2member_map_ else status
|
||||
self.result = result
|
||||
self.create_directory = create_directory
|
||||
self.images = images if images else []
|
||||
@@ -463,6 +468,29 @@ class Task:
|
||||
else:
|
||||
raise ValueError("Guardrail must be either a callable or a string description")
|
||||
|
||||
def set_status(self, new_status: str) -> None:
|
||||
"""Set task status with transition validation.
|
||||
|
||||
Validates the transition is legal. If not, logs a warning but
|
||||
still applies the change for backward compatibility.
|
||||
|
||||
Args:
|
||||
new_status: Target status (string or TaskStatus enum)
|
||||
"""
|
||||
# Normalize to string for comparison
|
||||
new_str = str(new_status)
|
||||
old_str = str(self.status)
|
||||
if not _lifecycle_manager.can_transition(old_str, new_str):
|
||||
logger.warning(
|
||||
f"Task {self.id}: Invalid status transition '{old_str}' -> '{new_str}'. "
|
||||
f"Allowing for backward compatibility."
|
||||
)
|
||||
# Always set for backward compat
|
||||
if new_str in TaskStatus._value2member_map_:
|
||||
self.status = TaskStatus(new_str)
|
||||
else:
|
||||
self.status = new_str
|
||||
|
||||
def __str__(self):
|
||||
return f"Task(name='{self.name if self.name else 'None'}', description='{self.description}', agent='{self.agent.name if self.agent else 'None'}', status='{self.status}')"
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Token Usage Sink Protocols for PraisonAI Agents.
|
||||
|
||||
Provides pluggable persistence for per-task token usage data.
|
||||
Matches Multica's task_usage table pattern but as a protocol,
|
||||
so users can plug in any backend (DB, file, API, etc.).
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenCollector
|
||||
|
||||
sink = InMemoryTokenUsageSink()
|
||||
collector = TokenCollector()
|
||||
collector.set_sink(sink)
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable, List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TokenUsageSinkProtocol(Protocol):
|
||||
"""Protocol for persisting token usage data.
|
||||
|
||||
Implementations can write to databases, files, APIs, etc.
|
||||
The NoOp implementation is used by default for zero overhead.
|
||||
"""
|
||||
|
||||
def persist(
|
||||
self,
|
||||
task_id: str,
|
||||
agent_name: str,
|
||||
model: str,
|
||||
metrics: Any,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Persist token usage for a task execution.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
agent_name: Agent that consumed tokens
|
||||
model: LLM model name
|
||||
metrics: TokenMetrics instance
|
||||
metadata: Optional extra metadata
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class NoOpTokenUsageSink:
|
||||
"""Default sink that does nothing. Zero overhead."""
|
||||
|
||||
def persist(
|
||||
self,
|
||||
task_id: str,
|
||||
agent_name: str,
|
||||
model: str,
|
||||
metrics: Any,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class InMemoryTokenUsageSink:
|
||||
"""In-memory sink for testing and debugging.
|
||||
|
||||
Stores all usage records in a list for inspection.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.records: List[Dict[str, Any]] = []
|
||||
|
||||
def persist(
|
||||
self,
|
||||
task_id: str,
|
||||
agent_name: str,
|
||||
model: str,
|
||||
metrics: Any,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
record = {
|
||||
"task_id": task_id,
|
||||
"agent_name": agent_name,
|
||||
"model": model,
|
||||
"input_tokens": getattr(metrics, "input_tokens", 0),
|
||||
"output_tokens": getattr(metrics, "output_tokens", 0),
|
||||
"cached_tokens": getattr(metrics, "cached_tokens", 0),
|
||||
"total_tokens": getattr(metrics, "total_tokens", 0),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self.records.append(record)
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all records for a specific task."""
|
||||
return [r for r in self.records if r["task_id"] == task_id]
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
"""Get all records for a specific agent."""
|
||||
return [r for r in self.records if r["agent_name"] == agent_name]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all records."""
|
||||
self.records.clear()
|
||||
@@ -113,6 +113,16 @@ class TokenCollector:
|
||||
self._session_metrics = SessionTokenMetrics()
|
||||
self._recent_interactions: List[Dict] = []
|
||||
self._max_recent = 100
|
||||
self._sink = None # Optional TokenUsageSinkProtocol
|
||||
|
||||
def set_sink(self, sink) -> None:
|
||||
"""Set a token usage sink for persistence.
|
||||
|
||||
Args:
|
||||
sink: Any object implementing TokenUsageSinkProtocol
|
||||
(must have a persist() method)
|
||||
"""
|
||||
self._sink = sink
|
||||
|
||||
def track_tokens(
|
||||
self,
|
||||
@@ -140,6 +150,20 @@ class TokenCollector:
|
||||
# Limit recent interactions
|
||||
if len(self._recent_interactions) > self._max_recent:
|
||||
self._recent_interactions.pop(0)
|
||||
|
||||
# Call sink outside the lock to avoid holding it during I/O
|
||||
if self._sink is not None:
|
||||
try:
|
||||
task_id = (metadata or {}).get("task_id", "")
|
||||
self._sink.persist(
|
||||
task_id=task_id,
|
||||
agent_name=agent or "",
|
||||
model=model,
|
||||
metrics=metrics,
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass # Sink errors must not break token tracking
|
||||
|
||||
def get_session_summary(self) -> Dict:
|
||||
"""Get summary of token usage for the session."""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.2"
|
||||
version = "1.6.4"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for Per-Agent Concurrency Limiter (Gap 2).
|
||||
|
||||
Validates:
|
||||
- ConcurrencyRegistry singleton
|
||||
- Per-agent semaphore limits
|
||||
- Default limit applies to unregistered agents
|
||||
- Async acquire/release
|
||||
- Thread safety
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
|
||||
class TestConcurrencyRegistry:
|
||||
"""Test ConcurrencyRegistry."""
|
||||
|
||||
def test_singleton(self):
|
||||
from praisonaiagents.agent.concurrency import get_concurrency_registry
|
||||
r1 = get_concurrency_registry()
|
||||
r2 = get_concurrency_registry()
|
||||
assert r1 is r2
|
||||
|
||||
def test_set_limit(self):
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("researcher", 2)
|
||||
assert reg.get_limit("researcher") == 2
|
||||
|
||||
def test_default_limit(self):
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
# Unregistered agent gets default (no limit = 0 means unlimited)
|
||||
assert reg.get_limit("unknown_agent") == 0
|
||||
|
||||
def test_set_default_limit(self):
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry(default_limit=5)
|
||||
assert reg.get_limit("any_agent") == 5
|
||||
|
||||
def test_remove_limit(self):
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("agent1", 3)
|
||||
reg.remove_limit("agent1")
|
||||
assert reg.get_limit("agent1") == 0 # back to default
|
||||
|
||||
|
||||
class TestConcurrencyLimiting:
|
||||
"""Test actual concurrency limiting via async acquire/release."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_release(self):
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("agent_a", 1)
|
||||
# Should acquire immediately
|
||||
await reg.acquire("agent_a")
|
||||
# Release
|
||||
reg.release("agent_a")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrency_limit_enforced(self):
|
||||
"""Only N tasks can run concurrently per agent."""
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("agent_b", 2)
|
||||
|
||||
active = []
|
||||
max_active = [0]
|
||||
|
||||
async def task(name):
|
||||
await reg.acquire(name)
|
||||
active.append(1)
|
||||
max_active[0] = max(max_active[0], len(active))
|
||||
await asyncio.sleep(0.05)
|
||||
active.pop()
|
||||
reg.release(name)
|
||||
|
||||
# Run 5 tasks, only 2 should be active at a time
|
||||
await asyncio.gather(*(task("agent_b") for _ in range(5)))
|
||||
assert max_active[0] <= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlimited_when_no_limit(self):
|
||||
"""When limit=0 (unlimited), acquire is a no-op."""
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
# No limit set — should not block
|
||||
await reg.acquire("no_limit_agent")
|
||||
reg.release("no_limit_agent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager(self):
|
||||
"""Test async context manager usage."""
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("ctx_agent", 1)
|
||||
async with reg.throttle("ctx_agent"):
|
||||
pass # Should acquire and release cleanly
|
||||
|
||||
def test_sync_acquire_release(self):
|
||||
"""Test synchronous acquire/release for non-async code paths."""
|
||||
from praisonaiagents.agent.concurrency import ConcurrencyRegistry
|
||||
reg = ConcurrencyRegistry()
|
||||
reg.set_limit("sync_agent", 2)
|
||||
# Sync acquire should work
|
||||
reg.acquire_sync("sync_agent")
|
||||
reg.release("sync_agent")
|
||||
Whitespace-only changes.
@@ -0,0 +1,204 @@
|
||||
"""Tests for auth protocols in core SDK."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from praisonaiagents.auth import (
|
||||
AuthIdentity,
|
||||
AuthBackendProtocol,
|
||||
WorkspaceContextProtocol,
|
||||
AuthConfig,
|
||||
)
|
||||
from praisonaiagents.auth.protocols import AuthIdentity as DirectAuthIdentity
|
||||
|
||||
|
||||
# ── AuthIdentity tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthIdentity:
|
||||
def test_create_user_identity(self):
|
||||
identity = AuthIdentity(id="u-1", type="user", email="[email protected]")
|
||||
assert identity.id == "u-1"
|
||||
assert identity.type == "user"
|
||||
assert identity.is_user is True
|
||||
assert identity.is_agent is False
|
||||
assert identity.email == "[email protected]"
|
||||
|
||||
def test_create_agent_identity(self):
|
||||
identity = AuthIdentity(id="a-1", type="agent", name="CodeBot")
|
||||
assert identity.is_agent is True
|
||||
assert identity.is_user is False
|
||||
assert identity.name == "CodeBot"
|
||||
|
||||
def test_roles(self):
|
||||
identity = AuthIdentity(id="u-1", roles=["owner", "admin"])
|
||||
assert identity.has_role("owner") is True
|
||||
assert identity.has_role("member") is False
|
||||
assert identity.has_any_role("member", "admin") is True
|
||||
assert identity.has_any_role("guest") is False
|
||||
|
||||
def test_defaults(self):
|
||||
identity = AuthIdentity(id="u-1")
|
||||
assert identity.type == "user"
|
||||
assert identity.workspace_id is None
|
||||
assert identity.roles == []
|
||||
assert identity.email is None
|
||||
assert identity.name is None
|
||||
assert identity.metadata == {}
|
||||
|
||||
def test_workspace_scoped(self):
|
||||
identity = AuthIdentity(
|
||||
id="u-1", workspace_id="ws-42", roles=["member"]
|
||||
)
|
||||
assert identity.workspace_id == "ws-42"
|
||||
assert identity.has_role("member") is True
|
||||
|
||||
def test_metadata(self):
|
||||
identity = AuthIdentity(id="u-1", metadata={"plan": "pro"})
|
||||
assert identity.metadata["plan"] == "pro"
|
||||
|
||||
def test_import_from_package(self):
|
||||
assert DirectAuthIdentity is AuthIdentity
|
||||
|
||||
|
||||
# ── AuthConfig tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthConfig:
|
||||
def test_defaults(self):
|
||||
config = AuthConfig()
|
||||
assert config.backend is None
|
||||
assert config.token_ttl == 30 * 24 * 3600
|
||||
assert config.require_workspace is False
|
||||
|
||||
def test_custom_config(self):
|
||||
config = AuthConfig(token_ttl=3600, require_workspace=True)
|
||||
assert config.token_ttl == 3600
|
||||
assert config.require_workspace is True
|
||||
|
||||
|
||||
# ── AuthBackendProtocol tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MockAuthBackend:
|
||||
"""Mock backend that satisfies AuthBackendProtocol via structural subtyping."""
|
||||
|
||||
def __init__(self, user_db: Optional[Dict[str, AuthIdentity]] = None):
|
||||
self.user_db = user_db or {}
|
||||
|
||||
async def authenticate(
|
||||
self, credentials: Dict[str, Any]
|
||||
) -> Optional[AuthIdentity]:
|
||||
token = credentials.get("token", "")
|
||||
return self.user_db.get(token)
|
||||
|
||||
async def authorize(
|
||||
self, identity: AuthIdentity, resource: str, action: str
|
||||
) -> bool:
|
||||
if identity.has_role("owner"):
|
||||
return True
|
||||
if action == "read" and identity.has_role("member"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TestAuthBackendProtocol:
|
||||
def test_isinstance_check(self):
|
||||
backend = MockAuthBackend()
|
||||
assert isinstance(backend, AuthBackendProtocol)
|
||||
|
||||
def test_authenticate_success(self):
|
||||
identity = AuthIdentity(id="u-1", type="user", roles=["owner"])
|
||||
backend = MockAuthBackend(user_db={"valid-token": identity})
|
||||
result = asyncio.run(backend.authenticate({"token": "valid-token"}))
|
||||
assert result is not None
|
||||
assert result.id == "u-1"
|
||||
|
||||
def test_authenticate_failure(self):
|
||||
backend = MockAuthBackend()
|
||||
result = asyncio.run(backend.authenticate({"token": "bad-token"}))
|
||||
assert result is None
|
||||
|
||||
def test_authorize_owner(self):
|
||||
identity = AuthIdentity(id="u-1", roles=["owner"])
|
||||
backend = MockAuthBackend()
|
||||
assert asyncio.run(backend.authorize(identity, "issue:1", "delete")) is True
|
||||
|
||||
def test_authorize_member_read(self):
|
||||
identity = AuthIdentity(id="u-2", roles=["member"])
|
||||
backend = MockAuthBackend()
|
||||
assert asyncio.run(backend.authorize(identity, "issue:1", "read")) is True
|
||||
assert asyncio.run(backend.authorize(identity, "issue:1", "delete")) is False
|
||||
|
||||
def test_authorize_no_roles(self):
|
||||
identity = AuthIdentity(id="u-3", roles=[])
|
||||
backend = MockAuthBackend()
|
||||
assert asyncio.run(backend.authorize(identity, "issue:1", "read")) is False
|
||||
|
||||
|
||||
# ── WorkspaceContextProtocol tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class MockWorkspaceContext:
|
||||
"""Mock workspace context that satisfies WorkspaceContextProtocol."""
|
||||
|
||||
def __init__(self):
|
||||
self.workspaces = {
|
||||
"ws-1": "You are an AI assistant for the Acme project.",
|
||||
}
|
||||
self.agents = {
|
||||
("ws-1", "agent-1"): {
|
||||
"system_prompt": "You help with code reviews.",
|
||||
"model": "gpt-4o-mini",
|
||||
"tools": ["code_review"],
|
||||
},
|
||||
}
|
||||
|
||||
async def get_workspace_context(self, workspace_id: str) -> Optional[str]:
|
||||
return self.workspaces.get(workspace_id)
|
||||
|
||||
async def get_agent_config(
|
||||
self, workspace_id: str, agent_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return self.agents.get((workspace_id, agent_id))
|
||||
|
||||
|
||||
class TestWorkspaceContextProtocol:
|
||||
def test_isinstance_check(self):
|
||||
ctx = MockWorkspaceContext()
|
||||
assert isinstance(ctx, WorkspaceContextProtocol)
|
||||
|
||||
def test_get_workspace_context(self):
|
||||
ctx = MockWorkspaceContext()
|
||||
result = asyncio.run(ctx.get_workspace_context("ws-1"))
|
||||
assert "Acme" in result
|
||||
|
||||
def test_get_workspace_context_missing(self):
|
||||
ctx = MockWorkspaceContext()
|
||||
result = asyncio.run(ctx.get_workspace_context("ws-999"))
|
||||
assert result is None
|
||||
|
||||
def test_get_agent_config(self):
|
||||
ctx = MockWorkspaceContext()
|
||||
result = asyncio.run(ctx.get_agent_config("ws-1", "agent-1"))
|
||||
assert result["model"] == "gpt-4o-mini"
|
||||
|
||||
def test_get_agent_config_missing(self):
|
||||
ctx = MockWorkspaceContext()
|
||||
result = asyncio.run(ctx.get_agent_config("ws-1", "agent-999"))
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── Non-conforming class should fail isinstance ──────────────────────────────
|
||||
|
||||
|
||||
class NotAnAuthBackend:
|
||||
def some_method(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestProtocolRejection:
|
||||
def test_non_conforming_class_fails(self):
|
||||
obj = NotAnAuthBackend()
|
||||
assert not isinstance(obj, AuthBackendProtocol)
|
||||
assert not isinstance(obj, WorkspaceContextProtocol)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for Task Lifecycle State Machine (Gap 1).
|
||||
|
||||
Validates:
|
||||
- TaskStatus enum values and string compatibility
|
||||
- Valid state transitions
|
||||
- Invalid state transitions raise errors
|
||||
- EventBus integration on transitions
|
||||
- Backward compatibility with raw string status
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTaskStatus:
|
||||
"""Test TaskStatus enum."""
|
||||
|
||||
def test_enum_values(self):
|
||||
from praisonaiagents.task.protocols import TaskStatus
|
||||
assert TaskStatus.NOT_STARTED == "not started"
|
||||
assert TaskStatus.IN_PROGRESS == "in progress"
|
||||
assert TaskStatus.COMPLETED == "completed"
|
||||
assert TaskStatus.FAILED == "failed"
|
||||
assert TaskStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_string_equality(self):
|
||||
"""TaskStatus must compare equal to raw strings for backward compat."""
|
||||
from praisonaiagents.task.protocols import TaskStatus
|
||||
assert TaskStatus.NOT_STARTED == "not started"
|
||||
assert "not started" == TaskStatus.NOT_STARTED
|
||||
assert TaskStatus.COMPLETED == "completed"
|
||||
|
||||
def test_str_representation(self):
|
||||
from praisonaiagents.task.protocols import TaskStatus
|
||||
assert str(TaskStatus.NOT_STARTED) == "not started"
|
||||
assert str(TaskStatus.IN_PROGRESS) == "in progress"
|
||||
|
||||
|
||||
class TestTaskLifecycleManager:
|
||||
"""Test TaskLifecycleManager transition validation."""
|
||||
|
||||
def test_valid_transitions(self):
|
||||
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
|
||||
mgr = TaskLifecycleManager()
|
||||
# not started -> in progress
|
||||
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS)
|
||||
# not started -> cancelled
|
||||
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.CANCELLED)
|
||||
# in progress -> completed
|
||||
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.COMPLETED)
|
||||
# in progress -> failed
|
||||
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.FAILED)
|
||||
# in progress -> cancelled
|
||||
assert mgr.can_transition(TaskStatus.IN_PROGRESS, TaskStatus.CANCELLED)
|
||||
# failed -> in progress (retry)
|
||||
assert mgr.can_transition(TaskStatus.FAILED, TaskStatus.IN_PROGRESS)
|
||||
# not started -> failed (import error etc)
|
||||
assert mgr.can_transition(TaskStatus.NOT_STARTED, TaskStatus.FAILED)
|
||||
|
||||
def test_invalid_transitions(self):
|
||||
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
|
||||
mgr = TaskLifecycleManager()
|
||||
# completed -> in progress (can't resume completed)
|
||||
assert not mgr.can_transition(TaskStatus.COMPLETED, TaskStatus.IN_PROGRESS)
|
||||
# cancelled -> in progress
|
||||
assert not mgr.can_transition(TaskStatus.CANCELLED, TaskStatus.IN_PROGRESS)
|
||||
# completed -> failed
|
||||
assert not mgr.can_transition(TaskStatus.COMPLETED, TaskStatus.FAILED)
|
||||
|
||||
def test_transition_executes(self):
|
||||
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
|
||||
mgr = TaskLifecycleManager()
|
||||
new_status = mgr.transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS)
|
||||
assert new_status == TaskStatus.IN_PROGRESS
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus, InvalidTransitionError
|
||||
mgr = TaskLifecycleManager()
|
||||
with pytest.raises(InvalidTransitionError):
|
||||
mgr.transition(TaskStatus.COMPLETED, TaskStatus.IN_PROGRESS)
|
||||
|
||||
def test_transition_callback(self):
|
||||
"""Callback fires on valid transition."""
|
||||
from praisonaiagents.task.protocols import TaskLifecycleManager, TaskStatus
|
||||
events = []
|
||||
def on_transition(old, new, task_id):
|
||||
events.append((old, new, task_id))
|
||||
mgr = TaskLifecycleManager(on_transition=on_transition)
|
||||
mgr.transition(TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS, task_id="t1")
|
||||
assert len(events) == 1
|
||||
assert events[0] == (TaskStatus.NOT_STARTED, TaskStatus.IN_PROGRESS, "t1")
|
||||
|
||||
|
||||
class TestTaskStatusIntegration:
|
||||
"""Test that Task class uses lifecycle manager."""
|
||||
|
||||
def test_task_initial_status(self):
|
||||
from praisonaiagents.task.task import Task
|
||||
from praisonaiagents.task.protocols import TaskStatus
|
||||
t = Task(description="Test task")
|
||||
assert t.status == TaskStatus.NOT_STARTED
|
||||
assert t.status == "not started" # backward compat
|
||||
|
||||
def test_task_set_status_valid(self):
|
||||
from praisonaiagents.task.task import Task
|
||||
from praisonaiagents.task.protocols import TaskStatus
|
||||
t = Task(description="Test task")
|
||||
t.set_status(TaskStatus.IN_PROGRESS)
|
||||
assert t.status == TaskStatus.IN_PROGRESS
|
||||
|
||||
def test_task_set_status_with_string(self):
|
||||
"""Backward compat: can set status with raw strings."""
|
||||
from praisonaiagents.task.task import Task
|
||||
t = Task(description="Test task")
|
||||
t.set_status("in progress")
|
||||
assert t.status == "in progress"
|
||||
|
||||
def test_task_invalid_transition_still_sets(self):
|
||||
"""Invalid transitions log warning but don't crash (backward compat)."""
|
||||
from praisonaiagents.task.task import Task
|
||||
t = Task(description="Test task")
|
||||
t.set_status("completed") # not started -> completed is VALID via in_progress
|
||||
# Force to completed state
|
||||
t.status = "completed"
|
||||
# Now try completed -> in progress (invalid)
|
||||
# Should still set for backward compat (logs warning internally)
|
||||
t.set_status("in progress")
|
||||
assert t.status == "in progress"
|
||||
|
||||
def test_task_direct_assignment_still_works(self):
|
||||
"""Direct task.status = 'x' still works for backward compat."""
|
||||
from praisonaiagents.task.task import Task
|
||||
t = Task(description="Test task")
|
||||
t.status = "in progress"
|
||||
assert t.status == "in progress"
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for TaskMessageSinkProtocol (Gap 4).
|
||||
|
||||
Validates:
|
||||
- Protocol structural subtyping
|
||||
- NoOpTaskMessageSink (default — zero overhead)
|
||||
- InMemoryTaskMessageSink for testing/debugging
|
||||
- Sequenced messages with auto-incrementing seq_num
|
||||
- Replay by task_id
|
||||
- Message types: agent_output, tool_call, tool_result, error, status
|
||||
- Registry pattern for sink management
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTaskMessageSinkProtocol:
|
||||
"""Test the protocol definition."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol
|
||||
assert TaskMessageSinkProtocol is not None
|
||||
|
||||
def test_noop_satisfies_protocol(self):
|
||||
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol, NoOpTaskMessageSink
|
||||
sink = NoOpTaskMessageSink()
|
||||
assert isinstance(sink, TaskMessageSinkProtocol)
|
||||
|
||||
def test_inmemory_satisfies_protocol(self):
|
||||
from praisonaiagents.task.message_sink import TaskMessageSinkProtocol, InMemoryTaskMessageSink
|
||||
sink = InMemoryTaskMessageSink()
|
||||
assert isinstance(sink, TaskMessageSinkProtocol)
|
||||
|
||||
|
||||
class TestTaskMessage:
|
||||
"""Test TaskMessage dataclass."""
|
||||
|
||||
def test_message_creation(self):
|
||||
from praisonaiagents.task.message_sink import TaskMessage
|
||||
msg = TaskMessage(
|
||||
task_id="t1",
|
||||
seq_num=1,
|
||||
msg_type="agent_output",
|
||||
content="Hello world",
|
||||
agent_name="researcher",
|
||||
)
|
||||
assert msg.task_id == "t1"
|
||||
assert msg.seq_num == 1
|
||||
assert msg.msg_type == "agent_output"
|
||||
assert msg.content == "Hello world"
|
||||
assert msg.agent_name == "researcher"
|
||||
assert isinstance(msg.timestamp, str)
|
||||
|
||||
def test_message_to_dict(self):
|
||||
from praisonaiagents.task.message_sink import TaskMessage
|
||||
msg = TaskMessage(
|
||||
task_id="t1",
|
||||
seq_num=0,
|
||||
msg_type="tool_call",
|
||||
content="search_web('AI')",
|
||||
agent_name="researcher",
|
||||
)
|
||||
d = msg.to_dict()
|
||||
assert d["task_id"] == "t1"
|
||||
assert d["msg_type"] == "tool_call"
|
||||
assert "timestamp" in d
|
||||
|
||||
|
||||
class TestNoOpTaskMessageSink:
|
||||
"""Test NoOp sink does nothing."""
|
||||
|
||||
def test_emit_is_noop(self):
|
||||
from praisonaiagents.task.message_sink import NoOpTaskMessageSink, TaskMessage
|
||||
sink = NoOpTaskMessageSink()
|
||||
msg = TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="hi")
|
||||
sink.emit(msg) # Should not raise
|
||||
|
||||
def test_replay_returns_empty(self):
|
||||
from praisonaiagents.task.message_sink import NoOpTaskMessageSink
|
||||
sink = NoOpTaskMessageSink()
|
||||
assert sink.replay("t1") == []
|
||||
|
||||
|
||||
class TestInMemoryTaskMessageSink:
|
||||
"""Test InMemory sink stores and replays messages."""
|
||||
|
||||
def test_emit_stores_message(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
|
||||
sink = InMemoryTaskMessageSink()
|
||||
msg = TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="Hello")
|
||||
sink.emit(msg)
|
||||
assert len(sink.messages) == 1
|
||||
|
||||
def test_replay_by_task_id(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
|
||||
sink = InMemoryTaskMessageSink()
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
|
||||
sink.emit(TaskMessage(task_id="t2", seq_num=0, msg_type="agent_output", content="b"))
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=1, msg_type="tool_call", content="c"))
|
||||
result = sink.replay("t1")
|
||||
assert len(result) == 2
|
||||
assert result[0].seq_num == 0
|
||||
assert result[1].seq_num == 1
|
||||
|
||||
def test_replay_ordered_by_seq_num(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
|
||||
sink = InMemoryTaskMessageSink()
|
||||
# Insert out of order
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=2, msg_type="error", content="err"))
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=1, msg_type="tool_call", content="b"))
|
||||
result = sink.replay("t1")
|
||||
assert [m.seq_num for m in result] == [0, 1, 2]
|
||||
|
||||
def test_clear(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
|
||||
sink = InMemoryTaskMessageSink()
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=0, msg_type="agent_output", content="a"))
|
||||
sink.clear()
|
||||
assert len(sink.messages) == 0
|
||||
|
||||
def test_message_types(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessage
|
||||
sink = InMemoryTaskMessageSink()
|
||||
types = ["agent_output", "tool_call", "tool_result", "error", "status"]
|
||||
for i, t in enumerate(types):
|
||||
sink.emit(TaskMessage(task_id="t1", seq_num=i, msg_type=t, content=f"msg_{t}"))
|
||||
result = sink.replay("t1")
|
||||
assert len(result) == 5
|
||||
assert [m.msg_type for m in result] == types
|
||||
|
||||
|
||||
class TestTaskMessageEmitter:
|
||||
"""Test the convenience emitter that auto-sequences messages."""
|
||||
|
||||
def test_emitter_auto_sequences(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
|
||||
sink = InMemoryTaskMessageSink()
|
||||
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
|
||||
emitter.emit("agent_output", "Hello")
|
||||
emitter.emit("tool_call", "search_web('AI')")
|
||||
emitter.emit("agent_output", "Done")
|
||||
result = sink.replay("t1")
|
||||
assert [m.seq_num for m in result] == [0, 1, 2]
|
||||
|
||||
def test_emitter_with_agent_name(self):
|
||||
from praisonaiagents.task.message_sink import InMemoryTaskMessageSink, TaskMessageEmitter
|
||||
sink = InMemoryTaskMessageSink()
|
||||
emitter = TaskMessageEmitter(task_id="t1", sink=sink, agent_name="researcher")
|
||||
emitter.emit("agent_output", "result")
|
||||
assert sink.messages[0].agent_name == "researcher"
|
||||
|
||||
def test_emitter_with_noop_sink(self):
|
||||
"""Emitter works with NoOp sink (zero overhead)."""
|
||||
from praisonaiagents.task.message_sink import NoOpTaskMessageSink, TaskMessageEmitter
|
||||
sink = NoOpTaskMessageSink()
|
||||
emitter = TaskMessageEmitter(task_id="t1", sink=sink)
|
||||
emitter.emit("agent_output", "Hello") # Should not raise
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for TokenUsageSinkProtocol (Gap 3).
|
||||
|
||||
Validates:
|
||||
- Protocol structural subtyping
|
||||
- NoOpTokenUsageSink (default — zero overhead)
|
||||
- InMemoryTokenUsageSink for testing/debugging
|
||||
- TokenCollector integration with sink
|
||||
- Registry pattern for sink management
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTokenUsageSinkProtocol:
|
||||
"""Test the protocol definition."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol
|
||||
assert TokenUsageSinkProtocol is not None
|
||||
|
||||
def test_noop_satisfies_protocol(self):
|
||||
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol, NoOpTokenUsageSink
|
||||
sink = NoOpTokenUsageSink()
|
||||
assert isinstance(sink, TokenUsageSinkProtocol)
|
||||
|
||||
def test_inmemory_satisfies_protocol(self):
|
||||
from praisonaiagents.telemetry.protocols import TokenUsageSinkProtocol, InMemoryTokenUsageSink
|
||||
sink = InMemoryTokenUsageSink()
|
||||
assert isinstance(sink, TokenUsageSinkProtocol)
|
||||
|
||||
|
||||
class TestNoOpTokenUsageSink:
|
||||
"""Test NoOp sink does nothing."""
|
||||
|
||||
def test_persist_is_noop(self):
|
||||
from praisonaiagents.telemetry.protocols import NoOpTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenMetrics
|
||||
sink = NoOpTokenUsageSink()
|
||||
# Should not raise
|
||||
sink.persist(
|
||||
task_id="t1",
|
||||
agent_name="researcher",
|
||||
model="gpt-4o",
|
||||
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
|
||||
)
|
||||
|
||||
|
||||
class TestInMemoryTokenUsageSink:
|
||||
"""Test InMemory sink stores records."""
|
||||
|
||||
def test_persist_stores_record(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenMetrics
|
||||
sink = InMemoryTokenUsageSink()
|
||||
sink.persist(
|
||||
task_id="t1",
|
||||
agent_name="researcher",
|
||||
model="gpt-4o",
|
||||
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
|
||||
)
|
||||
assert len(sink.records) == 1
|
||||
assert sink.records[0]["task_id"] == "t1"
|
||||
assert sink.records[0]["model"] == "gpt-4o"
|
||||
|
||||
def test_get_by_task(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenMetrics
|
||||
sink = InMemoryTokenUsageSink()
|
||||
m = TokenMetrics(input_tokens=10, output_tokens=5)
|
||||
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=m)
|
||||
sink.persist(task_id="t2", agent_name="b", model="m1", metrics=m)
|
||||
sink.persist(task_id="t1", agent_name="a", model="m2", metrics=m)
|
||||
result = sink.get_by_task("t1")
|
||||
assert len(result) == 2
|
||||
|
||||
def test_get_by_agent(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenMetrics
|
||||
sink = InMemoryTokenUsageSink()
|
||||
m = TokenMetrics(input_tokens=10, output_tokens=5)
|
||||
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=m)
|
||||
sink.persist(task_id="t2", agent_name="b", model="m1", metrics=m)
|
||||
result = sink.get_by_agent("a")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_clear(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
from praisonaiagents.telemetry.token_collector import TokenMetrics
|
||||
sink = InMemoryTokenUsageSink()
|
||||
sink.persist(task_id="t1", agent_name="a", model="m1", metrics=TokenMetrics())
|
||||
sink.clear()
|
||||
assert len(sink.records) == 0
|
||||
|
||||
|
||||
class TestTokenCollectorSinkIntegration:
|
||||
"""Test that TokenCollector calls the sink on track_tokens."""
|
||||
|
||||
def test_collector_with_sink(self):
|
||||
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
sink = InMemoryTokenUsageSink()
|
||||
collector = TokenCollector()
|
||||
collector.set_sink(sink)
|
||||
collector.track_tokens(
|
||||
model="gpt-4o",
|
||||
agent="researcher",
|
||||
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
|
||||
metadata={"task_id": "t1"},
|
||||
)
|
||||
assert len(sink.records) == 1
|
||||
assert sink.records[0]["task_id"] == "t1"
|
||||
|
||||
def test_collector_without_sink_no_error(self):
|
||||
"""Default collector with no sink should work fine."""
|
||||
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
|
||||
collector = TokenCollector()
|
||||
# Should not raise even without a sink
|
||||
collector.track_tokens(
|
||||
model="gpt-4o",
|
||||
agent="researcher",
|
||||
metrics=TokenMetrics(input_tokens=100, output_tokens=50),
|
||||
)
|
||||
|
||||
def test_collector_sink_receives_task_id_from_metadata(self):
|
||||
from praisonaiagents.telemetry.token_collector import TokenCollector, TokenMetrics
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink
|
||||
sink = InMemoryTokenUsageSink()
|
||||
collector = TokenCollector()
|
||||
collector.set_sink(sink)
|
||||
collector.track_tokens(
|
||||
model="gpt-4o",
|
||||
agent="researcher",
|
||||
metrics=TokenMetrics(input_tokens=50, output_tokens=25),
|
||||
metadata={"task_id": "task-abc"},
|
||||
)
|
||||
assert sink.records[0]["task_id"] == "task-abc"
|
||||
assert sink.records[0]["agent_name"] == "researcher"
|
||||
Generated
+1
-1
@@ -2951,7 +2951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.2"
|
||||
version = "1.6.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
PraisonAI Platform — workspace, auth, issues, projects.
|
||||
|
||||
A separate package providing multi-tenancy, authentication, issue tracking,
|
||||
and project management on top of the praisonaiagents core SDK.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""FastAPI REST API for the platform."""
|
||||
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..db.base import init_db
|
||||
from .routes.activity import router as activity_router
|
||||
from .routes.agents import router as agents_router
|
||||
from .routes.auth import router as auth_router
|
||||
from .routes.dependencies import router as dependencies_router
|
||||
from .routes.issues import router as issues_router
|
||||
from .routes.labels import router as labels_router
|
||||
from .routes.projects import router as projects_router
|
||||
from .routes.workspaces import router as workspaces_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""App lifespan: init DB on startup."""
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(
|
||||
title="PraisonAI Platform",
|
||||
version="0.1.0",
|
||||
description="Workspace, auth, issues, and projects for PraisonAI agents",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.include_router(auth_router, prefix="/api/v1")
|
||||
app.include_router(workspaces_router, prefix="/api/v1")
|
||||
app.include_router(projects_router, prefix="/api/v1")
|
||||
app.include_router(issues_router, prefix="/api/v1")
|
||||
app.include_router(agents_router, prefix="/api/v1")
|
||||
app.include_router(labels_router, prefix="/api/v1")
|
||||
app.include_router(dependencies_router, prefix="/api/v1")
|
||||
app.include_router(activity_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FastAPI dependency injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..db.base import get_session
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an async DB session per request."""
|
||||
factory = get_session()
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> AuthIdentity:
|
||||
"""Extract and verify the current user from Authorization header."""
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header",
|
||||
)
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer <token>",
|
||||
)
|
||||
auth_svc = AuthService(session)
|
||||
identity = await auth_svc.authenticate({"token": token})
|
||||
if identity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
)
|
||||
return identity
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Activity log routes — GAP-9."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import ActivityLogResponse
|
||||
from ...services.activity_service import ActivityService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}", tags=["activity"])
|
||||
|
||||
|
||||
@router.get("/activity", response_model=List[ActivityLogResponse])
|
||||
async def list_workspace_activity(
|
||||
workspace_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ActivityService(session)
|
||||
logs = await svc.list_for_workspace(workspace_id, limit=limit, offset=offset)
|
||||
return [ActivityLogResponse.model_validate(log) for log in logs]
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}/activity", response_model=List[ActivityLogResponse])
|
||||
async def list_issue_activity(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ActivityService(session)
|
||||
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
|
||||
return [ActivityLogResponse.model_validate(log) for log in logs]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Agent routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import AgentCreate, AgentResponse, AgentUpdate
|
||||
from ...services.agent_service import AgentService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/agents", tags=["agents"])
|
||||
|
||||
|
||||
@router.post("/", response_model=AgentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_agent(
|
||||
workspace_id: str,
|
||||
body: AgentCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(
|
||||
workspace_id=workspace_id,
|
||||
name=body.name,
|
||||
owner_id=user.id,
|
||||
runtime_mode=body.runtime_mode,
|
||||
runtime_config=body.runtime_config,
|
||||
instructions=body.instructions,
|
||||
max_concurrent_tasks=body.max_concurrent_tasks,
|
||||
)
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AgentResponse])
|
||||
async def list_agents(
|
||||
workspace_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agents = await svc.list_for_workspace(workspace_id, status=status_filter, limit=limit, offset=offset)
|
||||
return [AgentResponse.model_validate(a) for a in agents]
|
||||
|
||||
|
||||
@router.get("/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.get(agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.patch("/{agent_id}", response_model=AgentResponse)
|
||||
async def update_agent(
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
body: AgentUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.update(
|
||||
agent_id,
|
||||
name=body.name,
|
||||
status=body.status,
|
||||
instructions=body.instructions,
|
||||
runtime_mode=body.runtime_mode,
|
||||
runtime_config=body.runtime_config,
|
||||
max_concurrent_tasks=body.max_concurrent_tasks,
|
||||
)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.delete("/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_agent(
|
||||
workspace_id: str,
|
||||
agent_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
deleted = await svc.delete(agent_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Auth routes — register, login, me."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
||||
from ...services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest, session: AsyncSession = Depends(get_db)):
|
||||
auth_svc = AuthService(session)
|
||||
try:
|
||||
user, token = await auth_svc.register(body.email, body.password, body.name)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
return TokenResponse(
|
||||
token=token,
|
||||
user=UserResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, session: AsyncSession = Depends(get_db)):
|
||||
auth_svc = AuthService(session)
|
||||
result = await auth_svc.login(body.email, body.password)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
user, token = result
|
||||
return TokenResponse(
|
||||
token=token,
|
||||
user=UserResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def me(current_user: AuthIdentity = Depends(get_current_user)):
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
name=current_user.name or "",
|
||||
email=current_user.email or "",
|
||||
created_at=None, # type: ignore[arg-type]
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Issue dependency routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
from ...services.dependency_service import DependencyService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
|
||||
tags=["dependencies"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DependencyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_dependency(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
body: DependencyCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
|
||||
return DependencyResponse.model_validate(dep)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DependencyResponse])
|
||||
async def list_dependencies(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DependencyService(session)
|
||||
deps = await svc.list_for_issue(issue_id)
|
||||
return [DependencyResponse.model_validate(d) for d in deps]
|
||||
|
||||
|
||||
@router.delete("/{dep_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_dependency(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
dep_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DependencyService(session)
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Issue and Comment routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import (
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
IssueCreate,
|
||||
IssueResponse,
|
||||
IssueUpdate,
|
||||
)
|
||||
from ...services.activity_service import ActivityService
|
||||
from ...services.comment_service import CommentService
|
||||
from ...services.issue_service import IssueService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/issues", tags=["issues"])
|
||||
|
||||
|
||||
@router.post("/", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_issue(
|
||||
workspace_id: str,
|
||||
body: IssueCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
creator_id=user.id,
|
||||
creator_type="user" if user.is_user else "agent",
|
||||
project_id=body.project_id,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
assignee_type=body.assignee_type,
|
||||
assignee_id=body.assignee_id,
|
||||
parent_issue_id=body.parent_issue_id,
|
||||
acceptance_criteria=body.acceptance_criteria,
|
||||
)
|
||||
act_svc = ActivityService(session)
|
||||
await act_svc.log(
|
||||
workspace_id, "issue.created",
|
||||
actor_type="user" if user.is_user else "agent",
|
||||
actor_id=user.id, issue_id=issue.id,
|
||||
details={"title": issue.title, "identifier": issue.identifier},
|
||||
)
|
||||
return IssueResponse.model_validate(issue)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[IssueResponse])
|
||||
async def list_issues(
|
||||
workspace_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
project_id: Optional[str] = None,
|
||||
assignee_id: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issues = await svc.list_for_workspace(
|
||||
workspace_id,
|
||||
status=status_filter,
|
||||
project_id=project_id,
|
||||
assignee_id=assignee_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [IssueResponse.model_validate(i) for i in issues]
|
||||
|
||||
|
||||
@router.get("/{issue_id}", response_model=IssueResponse)
|
||||
async def get_issue(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.get(issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
return IssueResponse.model_validate(issue)
|
||||
|
||||
|
||||
@router.patch("/{issue_id}", response_model=IssueResponse)
|
||||
async def update_issue(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
body: IssueUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.update(
|
||||
issue_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
assignee_type=body.assignee_type,
|
||||
assignee_id=body.assignee_id,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
act_svc = ActivityService(session)
|
||||
await act_svc.log(
|
||||
workspace_id, "issue.updated",
|
||||
actor_type="user" if user.is_user else "agent",
|
||||
actor_id=user.id, issue_id=issue.id,
|
||||
details={"fields": body.model_dump(exclude_none=True)},
|
||||
)
|
||||
return IssueResponse.model_validate(issue)
|
||||
|
||||
|
||||
@router.delete("/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_issue(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
deleted = await svc.delete(issue_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
|
||||
# ── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{issue_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_comment(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
body: CommentCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(
|
||||
issue_id=issue_id,
|
||||
author_id=user.id,
|
||||
content=body.content,
|
||||
author_type="member" if user.is_user else "agent",
|
||||
parent_id=body.parent_id,
|
||||
)
|
||||
return CommentResponse.model_validate(comment)
|
||||
|
||||
|
||||
@router.get("/{issue_id}/comments", response_model=List[CommentResponse])
|
||||
async def list_comments(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CommentService(session)
|
||||
comments = await svc.list_for_issue(issue_id)
|
||||
return [CommentResponse.model_validate(c) for c in comments]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Label routes — workspace labels and issue-label linking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
|
||||
from ...services.label_service import LabelService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}", tags=["labels"])
|
||||
|
||||
|
||||
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_label(
|
||||
workspace_id: str,
|
||||
body: LabelCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.create(workspace_id, body.name, body.color)
|
||||
return LabelResponse.model_validate(label)
|
||||
|
||||
|
||||
@router.get("/labels", response_model=List[LabelResponse])
|
||||
async def list_labels(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
labels = await svc.list_for_workspace(workspace_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
|
||||
|
||||
@router.patch("/labels/{label_id}", response_model=LabelResponse)
|
||||
async def update_label(
|
||||
workspace_id: str,
|
||||
label_id: str,
|
||||
body: LabelUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.update(label_id, body.name, body.color)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
return LabelResponse.model_validate(label)
|
||||
|
||||
|
||||
@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_label(
|
||||
workspace_id: str,
|
||||
label_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
deleted = await svc.delete(label_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
|
||||
|
||||
# ── Issue-Label linking ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/issues/{issue_id}/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def add_label_to_issue(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
label_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
await svc.add_to_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@router.delete("/issues/{issue_id}/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_label_from_issue(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
label_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
await svc.remove_from_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}/labels", response_model=List[LabelResponse])
|
||||
async def list_issue_labels(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
labels = await svc.list_for_issue(issue_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Project routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
|
||||
from ...services.project_service import ProjectService
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{workspace_id}/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
workspace_id: str,
|
||||
body: ProjectCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.create(
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
icon=body.icon,
|
||||
lead_type=body.lead_type,
|
||||
lead_id=body.lead_id,
|
||||
)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ProjectResponse])
|
||||
async def list_projects(
|
||||
workspace_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
projects = await svc.list_for_workspace(workspace_id, limit=limit, offset=offset)
|
||||
return [ProjectResponse.model_validate(p) for p in projects]
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
body: ProjectUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.update(
|
||||
project_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
lead_type=body.lead_type,
|
||||
lead_id=body.lead_id,
|
||||
)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_project(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
deleted = await svc.delete(project_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
|
||||
@router.get("/{project_id}/stats")
|
||||
async def project_stats(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
return await svc.get_stats(project_id)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Workspace routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db
|
||||
from ..schemas import (
|
||||
MemberAdd,
|
||||
MemberResponse,
|
||||
MemberUpdate,
|
||||
WorkspaceCreate,
|
||||
WorkspaceResponse,
|
||||
WorkspaceUpdate,
|
||||
)
|
||||
from ...services.member_service import MemberService
|
||||
from ...services.workspace_service import WorkspaceService
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
body: WorkspaceCreate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create(body.name, user.id, body.slug, body.description)
|
||||
return WorkspaceResponse.model_validate(ws)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[WorkspaceResponse])
|
||||
async def list_workspaces(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
workspaces = await ws_svc.list_for_user(user.id, limit=limit, offset=offset)
|
||||
return [WorkspaceResponse.model_validate(ws) for ws in workspaces]
|
||||
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def get_workspace(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.get(workspace_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return WorkspaceResponse.model_validate(ws)
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def update_workspace(
|
||||
workspace_id: str,
|
||||
body: WorkspaceUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.update(workspace_id, body.name, body.description, body.settings)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return WorkspaceResponse.model_validate(ws)
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
deleted = await ws_svc.delete(workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
|
||||
# ── Members ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
body: MemberAdd,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
member = await member_svc.add(workspace_id, body.user_id, body.role)
|
||||
return MemberResponse.model_validate(member)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members", response_model=List[MemberResponse])
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
members = await member_svc.list_members(workspace_id)
|
||||
return [MemberResponse.model_validate(m) for m in members]
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)
|
||||
async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
body: MemberUpdate,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
member = await member_svc.update_role(workspace_id, user_id, body.role)
|
||||
if member is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
return MemberResponse.model_validate(member)
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
removed = await member_svc.remove(workspace_id, user_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Pydantic schemas for API request/response."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Fix forward reference
|
||||
TokenResponse.model_rebuild()
|
||||
|
||||
|
||||
# ── Workspace ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WorkspaceCreate(BaseModel):
|
||||
name: str
|
||||
slug: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
settings: Dict[str, Any] = {}
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Member ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
user_id: str
|
||||
role: str = "member"
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Project ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
lead_type: Optional[str] = None
|
||||
lead_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
lead_type: Optional[str] = None
|
||||
lead_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
status: str
|
||||
lead_type: Optional[str] = None
|
||||
lead_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Issue ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class IssueCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
status: str = "backlog"
|
||||
priority: str = "none"
|
||||
assignee_type: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
parent_issue_id: Optional[str] = None
|
||||
acceptance_criteria: Optional[List[str]] = None
|
||||
|
||||
|
||||
class IssueUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assignee_type: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
class IssueResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: Optional[str] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str
|
||||
priority: str
|
||||
assignee_type: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
creator_type: str
|
||||
creator_id: str
|
||||
number: Optional[int] = None
|
||||
identifier: Optional[str] = None
|
||||
parent_issue_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Comment ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
issue_id: str
|
||||
author_type: str
|
||||
author_id: str
|
||||
parent_id: Optional[str] = None
|
||||
content: str
|
||||
type: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Agent ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
name: str
|
||||
runtime_mode: str = "local"
|
||||
runtime_config: Optional[Dict[str, Any]] = None
|
||||
instructions: Optional[str] = None
|
||||
max_concurrent_tasks: int = 1
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
instructions: Optional[str] = None
|
||||
runtime_mode: Optional[str] = None
|
||||
runtime_config: Optional[Dict[str, Any]] = None
|
||||
max_concurrent_tasks: Optional[int] = None
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
name: str
|
||||
avatar_url: Optional[str] = None
|
||||
runtime_mode: str
|
||||
instructions: Optional[str] = None
|
||||
status: str
|
||||
max_concurrent_tasks: int
|
||||
owner_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── Label ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LabelCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#6B7280"
|
||||
|
||||
|
||||
class LabelUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class LabelResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
name: str
|
||||
color: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── IssueDependency ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DependencyCreate(BaseModel):
|
||||
depends_on_issue_id: str
|
||||
type: str = "blocks"
|
||||
|
||||
|
||||
class DependencyResponse(BaseModel):
|
||||
id: str
|
||||
issue_id: str
|
||||
depends_on_issue_id: str
|
||||
type: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ── ActivityLog ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActivityLogResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
issue_id: Optional[str] = None
|
||||
actor_type: Optional[str] = None
|
||||
actor_id: Optional[str] = None
|
||||
action: str
|
||||
details: Dict[str, Any] = {}
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Platform client SDK for agent ↔ platform integration."""
|
||||
|
||||
from .platform_client import PlatformClient
|
||||
|
||||
__all__ = ["PlatformClient"]
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
PlatformClient — httpx-based SDK for agents to interact with the platform API.
|
||||
|
||||
Usage::
|
||||
|
||||
client = PlatformClient("http://localhost:8000", token="jwt-token")
|
||||
workspaces = await client.list_workspaces()
|
||||
issue = await client.create_issue(
|
||||
workspace_id="ws-1",
|
||||
title="Fix bug",
|
||||
description="The thing is broken",
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class PlatformClient:
|
||||
"""Async HTTP client for the PraisonAI Platform API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8000",
|
||||
token: Optional[str] = None,
|
||||
):
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
return headers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}/api/v1{path}"
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def register(self, email: str, password: str, name: Optional[str] = None) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url("/auth/register"),
|
||||
json={"email": email, "password": password, "name": name},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._token = data["token"]
|
||||
return data
|
||||
|
||||
async def login(self, email: str, password: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url("/auth/login"),
|
||||
json={"email": email, "password": password},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._token = data["token"]
|
||||
return data
|
||||
|
||||
# ── Workspaces ───────────────────────────────────────────────────────
|
||||
|
||||
async def create_workspace(self, name: str, slug: Optional[str] = None) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url("/workspaces/"),
|
||||
json={"name": name, "slug": slug},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_workspaces(self) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(self._url("/workspaces/"), headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_workspace(self, workspace_id: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Members ──────────────────────────────────────────────────────────
|
||||
|
||||
async def add_member(self, workspace_id: str, user_id: str, role: str = "member") -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url(f"/workspaces/{workspace_id}/members"),
|
||||
json={"user_id": user_id, "role": role},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_members(self, workspace_id: str) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/members"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────
|
||||
|
||||
async def create_project(
|
||||
self, workspace_id: str, title: str, description: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url(f"/workspaces/{workspace_id}/projects/"),
|
||||
json={"title": title, "description": description},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_projects(self, workspace_id: str) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/projects/"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Issues ───────────────────────────────────────────────────────────
|
||||
|
||||
async def create_issue(
|
||||
self,
|
||||
workspace_id: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
priority: str = "none",
|
||||
assignee_type: Optional[str] = None,
|
||||
assignee_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {"title": title, "priority": priority}
|
||||
if description:
|
||||
body["description"] = description
|
||||
if project_id:
|
||||
body["project_id"] = project_id
|
||||
if assignee_type:
|
||||
body["assignee_type"] = assignee_type
|
||||
if assignee_id:
|
||||
body["assignee_id"] = assignee_id
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/"),
|
||||
json=body,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
workspace_id: str,
|
||||
status: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
params: Dict[str, str] = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if project_id:
|
||||
params["project_id"] = project_id
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/"),
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_issue(self, workspace_id: str, issue_id: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def update_issue(
|
||||
self, workspace_id: str, issue_id: str, **fields: Any
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.patch(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}"),
|
||||
json=fields,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Comments ─────────────────────────────────────────────────────────
|
||||
|
||||
async def add_comment(
|
||||
self, workspace_id: str, issue_id: str, content: str
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}/comments"),
|
||||
json={"content": content},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_comments(
|
||||
self, workspace_id: str, issue_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/issues/{issue_id}/comments"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Agents ────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_agent(
|
||||
self,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
runtime_mode: str = "local",
|
||||
instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {"name": name, "runtime_mode": runtime_mode}
|
||||
if instructions:
|
||||
body["instructions"] = instructions
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.post(
|
||||
self._url(f"/workspaces/{workspace_id}/agents/"),
|
||||
json=body,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_agents(self, workspace_id: str) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/agents/"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_agent(self, workspace_id: str, agent_id: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.get(
|
||||
self._url(f"/workspaces/{workspace_id}/agents/{agent_id}"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def update_agent(
|
||||
self, workspace_id: str, agent_id: str, **fields: Any
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient() as c:
|
||||
resp = await c.patch(
|
||||
self._url(f"/workspaces/{workspace_id}/agents/{agent_id}"),
|
||||
json=fields,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Platform services — auth, workspace, member, project, issue, agent, label, dependency, activity."""
|
||||
|
||||
from .auth_service import AuthService
|
||||
from .workspace_service import WorkspaceService
|
||||
from .member_service import MemberService
|
||||
from .project_service import ProjectService
|
||||
from .issue_service import IssueService
|
||||
from .comment_service import CommentService
|
||||
from .activity_service import ActivityService
|
||||
from .agent_service import AgentService
|
||||
from .label_service import LabelService
|
||||
from .dependency_service import DependencyService
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
"WorkspaceService",
|
||||
"MemberService",
|
||||
"ProjectService",
|
||||
"IssueService",
|
||||
"CommentService",
|
||||
"ActivityService",
|
||||
"AgentService",
|
||||
"LabelService",
|
||||
"DependencyService",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Activity log service — audit trail for workspace actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import ActivityLog
|
||||
|
||||
|
||||
class ActivityService:
|
||||
"""Activity log operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def log(
|
||||
self,
|
||||
workspace_id: str,
|
||||
action: str,
|
||||
actor_type: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
issue_id: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> ActivityLog:
|
||||
"""Record an activity."""
|
||||
entry = ActivityLog(
|
||||
workspace_id=workspace_id,
|
||||
action=action,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
issue_id=issue_id,
|
||||
details=details or {},
|
||||
)
|
||||
self._session.add(entry)
|
||||
await self._session.flush()
|
||||
return entry
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[ActivityLog]:
|
||||
"""List recent activity for a workspace."""
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.workspace_id == workspace_id)
|
||||
.order_by(ActivityLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_for_issue(
|
||||
self,
|
||||
issue_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[ActivityLog]:
|
||||
"""List activity for a specific issue."""
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.issue_id == issue_id)
|
||||
.order_by(ActivityLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Agent service — CRUD for workspace agents.
|
||||
|
||||
Influenced by Multica's agent table (001_init + 021_instructions)
|
||||
and Paperclip's agents schema (adapter, status, runtime config).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Agent
|
||||
|
||||
VALID_STATUSES = {"idle", "working", "blocked", "error", "offline"}
|
||||
VALID_RUNTIME_MODES = {"local", "cloud"}
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Agent CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
owner_id: Optional[str] = None,
|
||||
runtime_mode: str = "local",
|
||||
runtime_config: Optional[dict] = None,
|
||||
instructions: Optional[str] = None,
|
||||
status: str = "offline",
|
||||
max_concurrent_tasks: int = 1,
|
||||
) -> Agent:
|
||||
"""Create an agent in a workspace."""
|
||||
agent = Agent(
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
owner_id=owner_id,
|
||||
runtime_mode=runtime_mode,
|
||||
runtime_config=runtime_config or {},
|
||||
instructions=instructions,
|
||||
status=status,
|
||||
max_concurrent_tasks=max_concurrent_tasks,
|
||||
)
|
||||
self._session.add(agent)
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def get(self, agent_id: str) -> Optional[Agent]:
|
||||
"""Get agent by ID."""
|
||||
return await self._session.get(Agent, agent_id)
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Agent]:
|
||||
"""List agents in a workspace with optional filters."""
|
||||
stmt = select(Agent).where(Agent.workspace_id == workspace_id)
|
||||
if status:
|
||||
stmt = stmt.where(Agent.status == status)
|
||||
stmt = stmt.order_by(Agent.created_at).limit(limit).offset(offset)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
agent_id: str,
|
||||
name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
runtime_mode: Optional[str] = None,
|
||||
runtime_config: Optional[dict] = None,
|
||||
max_concurrent_tasks: Optional[int] = None,
|
||||
) -> Optional[Agent]:
|
||||
"""Update agent fields."""
|
||||
agent = await self.get(agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
if name is not None:
|
||||
agent.name = name
|
||||
if status is not None:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"Invalid status: {status}")
|
||||
agent.status = status
|
||||
if instructions is not None:
|
||||
agent.instructions = instructions
|
||||
if runtime_mode is not None:
|
||||
if runtime_mode not in VALID_RUNTIME_MODES:
|
||||
raise ValueError(f"Invalid runtime_mode: {runtime_mode}")
|
||||
agent.runtime_mode = runtime_mode
|
||||
if runtime_config is not None:
|
||||
agent.runtime_config = runtime_config
|
||||
if max_concurrent_tasks is not None:
|
||||
agent.max_concurrent_tasks = max_concurrent_tasks
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def delete(self, agent_id: str) -> bool:
|
||||
"""Delete an agent."""
|
||||
agent = await self.get(agent_id)
|
||||
if agent is None:
|
||||
return False
|
||||
await self._session.delete(agent)
|
||||
await self._session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Auth service — JWT-based authentication.
|
||||
|
||||
Implements AuthBackendProtocol from praisonaiagents.auth.
|
||||
Influenced by Multica's issueJWT + findOrCreateUser pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..db.models import Member, User
|
||||
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
_DEFAULT_SECRET = "dev-secret-change-me"
|
||||
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
|
||||
|
||||
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
|
||||
raise RuntimeError(
|
||||
"PLATFORM_JWT_SECRET must be set to a strong random value in production. "
|
||||
"Set PLATFORM_ENV=dev to suppress this check during development."
|
||||
)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""JWT authentication service implementing AuthBackendProtocol."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
# ── AuthBackendProtocol methods ──────────────────────────────────────
|
||||
|
||||
async def authenticate(
|
||||
self, credentials: Dict[str, Any]
|
||||
) -> Optional[AuthIdentity]:
|
||||
"""Authenticate via JWT token or email+password."""
|
||||
token = credentials.get("token")
|
||||
if token:
|
||||
return self._verify_token(token)
|
||||
|
||||
email = credentials.get("email")
|
||||
password = credentials.get("password")
|
||||
if email and password:
|
||||
return await self._authenticate_password(email, password)
|
||||
|
||||
return None
|
||||
|
||||
async def authorize(
|
||||
self, identity: AuthIdentity, resource: str, action: str
|
||||
) -> bool:
|
||||
"""Check workspace-scoped RBAC authorization."""
|
||||
if not identity.workspace_id:
|
||||
return action == "read"
|
||||
|
||||
stmt = select(Member).where(
|
||||
Member.workspace_id == identity.workspace_id,
|
||||
Member.user_id == identity.id,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
member = result.scalar_one_or_none()
|
||||
if member is None:
|
||||
return False
|
||||
|
||||
if member.role == "owner":
|
||||
return True
|
||||
if member.role == "admin":
|
||||
return action != "admin"
|
||||
if member.role == "member":
|
||||
return action in ("read", "write")
|
||||
return False
|
||||
|
||||
# ── Registration & Login ─────────────────────────────────────────────
|
||||
|
||||
async def register(
|
||||
self, email: str, password: str, name: Optional[str] = None
|
||||
) -> tuple[User, str]:
|
||||
"""Register a new user and return (user, jwt_token)."""
|
||||
user = User(
|
||||
email=email,
|
||||
name=name or email.split("@")[0],
|
||||
password_hash=_pwd_context.hash(password),
|
||||
)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
token = self._issue_token(user)
|
||||
return user, token
|
||||
|
||||
async def login(self, email: str, password: str) -> Optional[tuple[User, str]]:
|
||||
"""Login with email+password, return (user, jwt_token) or None."""
|
||||
stmt = select(User).where(User.email == email)
|
||||
result = await self._session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.password_hash:
|
||||
return None
|
||||
if not _pwd_context.verify(password, user.password_hash):
|
||||
return None
|
||||
token = self._issue_token(user)
|
||||
return user, token
|
||||
|
||||
# ── Token management ─────────────────────────────────────────────────
|
||||
|
||||
def _issue_token(self, user: User) -> str:
|
||||
"""Issue a JWT for a user."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=JWT_TTL_SECONDS),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
def _verify_token(self, token: str) -> Optional[AuthIdentity]:
|
||||
"""Decode and verify a JWT, returning AuthIdentity or None."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return AuthIdentity(
|
||||
id=payload["sub"],
|
||||
type="user",
|
||||
email=payload.get("email"),
|
||||
name=payload.get("name"),
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
async def _authenticate_password(
|
||||
self, email: str, password: str
|
||||
) -> Optional[AuthIdentity]:
|
||||
"""Authenticate via email+password, return AuthIdentity."""
|
||||
result = await self.login(email, password)
|
||||
if result is None:
|
||||
return None
|
||||
user, _ = result
|
||||
return AuthIdentity(
|
||||
id=user.id,
|
||||
type="user",
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Comment service — CRUD for issue comments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Comment
|
||||
|
||||
|
||||
class CommentService:
|
||||
"""Comment CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
issue_id: str,
|
||||
author_id: str,
|
||||
content: str,
|
||||
author_type: str = "member",
|
||||
comment_type: str = "comment",
|
||||
parent_id: Optional[str] = None,
|
||||
) -> Comment:
|
||||
"""Add a comment to an issue."""
|
||||
comment = Comment(
|
||||
issue_id=issue_id,
|
||||
author_type=author_type,
|
||||
author_id=author_id,
|
||||
parent_id=parent_id,
|
||||
content=content,
|
||||
type=comment_type,
|
||||
)
|
||||
self._session.add(comment)
|
||||
await self._session.flush()
|
||||
return comment
|
||||
|
||||
async def get(self, comment_id: str) -> Optional[Comment]:
|
||||
"""Get comment by ID."""
|
||||
return await self._session.get(Comment, comment_id)
|
||||
|
||||
async def list_for_issue(self, issue_id: str) -> list[Comment]:
|
||||
"""List all comments on an issue."""
|
||||
stmt = (
|
||||
select(Comment)
|
||||
.where(Comment.issue_id == issue_id)
|
||||
.order_by(Comment.created_at)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(self, comment_id: str, content: str) -> Optional[Comment]:
|
||||
"""Update comment content."""
|
||||
comment = await self.get(comment_id)
|
||||
if comment is None:
|
||||
return None
|
||||
comment.content = content
|
||||
await self._session.flush()
|
||||
return comment
|
||||
|
||||
async def delete(self, comment_id: str) -> bool:
|
||||
"""Delete a comment."""
|
||||
comment = await self.get(comment_id)
|
||||
if comment is None:
|
||||
return False
|
||||
await self._session.delete(comment)
|
||||
await self._session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Issue dependency service — manage issue relationships.
|
||||
|
||||
Influenced by Multica's issue_dependency (blocks/blocked_by/related)
|
||||
and Paperclip's issue_relations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import IssueDependency
|
||||
|
||||
VALID_TYPES = {"blocks", "blocked_by", "related"}
|
||||
|
||||
|
||||
class DependencyService:
|
||||
"""Issue dependency CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
issue_id: str,
|
||||
depends_on_issue_id: str,
|
||||
dep_type: str = "blocks",
|
||||
) -> IssueDependency:
|
||||
"""Create a dependency between two issues."""
|
||||
if dep_type not in VALID_TYPES:
|
||||
raise ValueError(f"Invalid type: {dep_type}. Must be one of {VALID_TYPES}")
|
||||
dep = IssueDependency(
|
||||
issue_id=issue_id,
|
||||
depends_on_issue_id=depends_on_issue_id,
|
||||
type=dep_type,
|
||||
)
|
||||
self._session.add(dep)
|
||||
await self._session.flush()
|
||||
return dep
|
||||
|
||||
async def get(self, dep_id: str) -> Optional[IssueDependency]:
|
||||
"""Get dependency by ID."""
|
||||
return await self._session.get(IssueDependency, dep_id)
|
||||
|
||||
async def list_for_issue(self, issue_id: str) -> list[IssueDependency]:
|
||||
"""List all dependencies for an issue (both directions)."""
|
||||
stmt = select(IssueDependency).where(
|
||||
(IssueDependency.issue_id == issue_id)
|
||||
| (IssueDependency.depends_on_issue_id == issue_id)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete(self, dep_id: str) -> bool:
|
||||
"""Delete a dependency."""
|
||||
dep = await self.get(dep_id)
|
||||
if dep is None:
|
||||
return False
|
||||
await self._session.delete(dep)
|
||||
await self._session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Issue service — CRUD, status workflow, assignment, sub-issues.
|
||||
|
||||
Influenced by Multica's issue handler with status state machine,
|
||||
agent assignment (assignee_type: member|agent), and sub-issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Issue, Workspace
|
||||
|
||||
VALID_STATUSES = {"backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"}
|
||||
VALID_PRIORITIES = {"urgent", "high", "medium", "low", "none"}
|
||||
VALID_ASSIGNEE_TYPES = {"member", "agent"}
|
||||
|
||||
|
||||
class IssueService:
|
||||
"""Issue management with status workflow."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
workspace_id: str,
|
||||
title: str,
|
||||
creator_id: str,
|
||||
creator_type: str = "member",
|
||||
project_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: str = "backlog",
|
||||
priority: str = "none",
|
||||
assignee_type: Optional[str] = None,
|
||||
assignee_id: Optional[str] = None,
|
||||
parent_issue_id: Optional[str] = None,
|
||||
acceptance_criteria: Optional[list] = None,
|
||||
) -> Issue:
|
||||
"""Create an issue with auto-assigned number and identifier."""
|
||||
ws = await self._session.get(Workspace, workspace_id)
|
||||
number = None
|
||||
identifier = None
|
||||
if ws is not None:
|
||||
ws.issue_counter = (ws.issue_counter or 0) + 1
|
||||
number = ws.issue_counter
|
||||
identifier = f"{ws.issue_prefix}-{number}"
|
||||
|
||||
issue = Issue(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
assignee_type=assignee_type,
|
||||
assignee_id=assignee_id,
|
||||
creator_type=creator_type,
|
||||
creator_id=creator_id,
|
||||
parent_issue_id=parent_issue_id,
|
||||
acceptance_criteria=acceptance_criteria or [],
|
||||
number=number,
|
||||
identifier=identifier,
|
||||
)
|
||||
self._session.add(issue)
|
||||
await self._session.flush()
|
||||
return issue
|
||||
|
||||
async def get(self, issue_id: str) -> Optional[Issue]:
|
||||
"""Get issue by ID."""
|
||||
return await self._session.get(Issue, issue_id)
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
status: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
assignee_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Issue]:
|
||||
"""List issues with optional filters."""
|
||||
stmt = select(Issue).where(Issue.workspace_id == workspace_id)
|
||||
if status:
|
||||
stmt = stmt.where(Issue.status == status)
|
||||
if project_id:
|
||||
stmt = stmt.where(Issue.project_id == project_id)
|
||||
if assignee_id:
|
||||
stmt = stmt.where(Issue.assignee_id == assignee_id)
|
||||
stmt = stmt.order_by(Issue.position, Issue.created_at).limit(limit).offset(offset)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
issue_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
assignee_type: Optional[str] = None,
|
||||
assignee_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Optional[Issue]:
|
||||
"""Update issue fields."""
|
||||
issue = await self.get(issue_id)
|
||||
if issue is None:
|
||||
return None
|
||||
if title is not None:
|
||||
issue.title = title
|
||||
if description is not None:
|
||||
issue.description = description
|
||||
if status is not None:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"Invalid status: {status}")
|
||||
issue.status = status
|
||||
if priority is not None:
|
||||
if priority not in VALID_PRIORITIES:
|
||||
raise ValueError(f"Invalid priority: {priority}")
|
||||
issue.priority = priority
|
||||
if assignee_type is not None:
|
||||
issue.assignee_type = assignee_type
|
||||
if assignee_id is not None:
|
||||
issue.assignee_id = assignee_id
|
||||
if project_id is not None:
|
||||
issue.project_id = project_id
|
||||
await self._session.flush()
|
||||
return issue
|
||||
|
||||
async def assign(
|
||||
self,
|
||||
issue_id: str,
|
||||
assignee_type: str,
|
||||
assignee_id: str,
|
||||
) -> Optional[Issue]:
|
||||
"""Assign an issue to a member or agent."""
|
||||
if assignee_type not in VALID_ASSIGNEE_TYPES:
|
||||
raise ValueError(f"Invalid assignee_type: {assignee_type}")
|
||||
return await self.update(
|
||||
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
|
||||
)
|
||||
|
||||
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
|
||||
"""Transition an issue to a new status."""
|
||||
return await self.update(issue_id, status=new_status)
|
||||
|
||||
async def delete(self, issue_id: str) -> bool:
|
||||
"""Delete an issue."""
|
||||
issue = await self.get(issue_id)
|
||||
if issue is None:
|
||||
return False
|
||||
await self._session.delete(issue)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
async def list_sub_issues(self, parent_id: str) -> list[Issue]:
|
||||
"""List sub-issues of a parent issue."""
|
||||
stmt = select(Issue).where(Issue.parent_issue_id == parent_id)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Label service — CRUD for workspace issue labels and issue-label links.
|
||||
|
||||
Influenced by Multica's issue_label + issue_to_label schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import IssueLabel, IssueLabelLink
|
||||
|
||||
|
||||
class LabelService:
|
||||
"""Issue label CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
color: str = "#6B7280",
|
||||
) -> IssueLabel:
|
||||
"""Create a label in a workspace."""
|
||||
label = IssueLabel(workspace_id=workspace_id, name=name, color=color)
|
||||
self._session.add(label)
|
||||
await self._session.flush()
|
||||
return label
|
||||
|
||||
async def get(self, label_id: str) -> Optional[IssueLabel]:
|
||||
"""Get label by ID."""
|
||||
return await self._session.get(IssueLabel, label_id)
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[IssueLabel]:
|
||||
"""List all labels in a workspace."""
|
||||
stmt = select(IssueLabel).where(IssueLabel.workspace_id == workspace_id)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
label_id: str,
|
||||
name: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> Optional[IssueLabel]:
|
||||
"""Update a label."""
|
||||
label = await self.get(label_id)
|
||||
if label is None:
|
||||
return None
|
||||
if name is not None:
|
||||
label.name = name
|
||||
if color is not None:
|
||||
label.color = color
|
||||
await self._session.flush()
|
||||
return label
|
||||
|
||||
async def delete(self, label_id: str) -> bool:
|
||||
"""Delete a label."""
|
||||
label = await self.get(label_id)
|
||||
if label is None:
|
||||
return False
|
||||
await self._session.delete(label)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
# ── Issue-Label linking ────────────────────────────────────────────────
|
||||
|
||||
async def add_to_issue(self, issue_id: str, label_id: str) -> None:
|
||||
"""Attach a label to an issue."""
|
||||
link = IssueLabelLink(issue_id=issue_id, label_id=label_id)
|
||||
self._session.add(link)
|
||||
await self._session.flush()
|
||||
|
||||
async def remove_from_issue(self, issue_id: str, label_id: str) -> None:
|
||||
"""Remove a label from an issue."""
|
||||
stmt = delete(IssueLabelLink).where(
|
||||
IssueLabelLink.issue_id == issue_id,
|
||||
IssueLabelLink.label_id == label_id,
|
||||
)
|
||||
await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
|
||||
async def list_for_issue(self, issue_id: str) -> list[IssueLabel]:
|
||||
"""List all labels on an issue."""
|
||||
stmt = (
|
||||
select(IssueLabel)
|
||||
.join(IssueLabelLink, IssueLabelLink.label_id == IssueLabel.id)
|
||||
.where(IssueLabelLink.issue_id == issue_id)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Member/RBAC service.
|
||||
|
||||
Influenced by Multica's member handler (owner/admin/member roles)
|
||||
and Paperclip's access service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Member
|
||||
|
||||
VALID_ROLES = {"owner", "admin", "member"}
|
||||
|
||||
|
||||
class MemberService:
|
||||
"""Member management with role-based access control."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
role: str = "member",
|
||||
) -> Member:
|
||||
"""Add a user to a workspace."""
|
||||
if role not in VALID_ROLES:
|
||||
raise ValueError(f"Invalid role: {role}. Must be one of {VALID_ROLES}")
|
||||
member = Member(workspace_id=workspace_id, user_id=user_id, role=role)
|
||||
self._session.add(member)
|
||||
await self._session.flush()
|
||||
return member
|
||||
|
||||
async def get(self, workspace_id: str, user_id: str) -> Optional[Member]:
|
||||
"""Get a member by workspace + user."""
|
||||
stmt = select(Member).where(
|
||||
Member.workspace_id == workspace_id,
|
||||
Member.user_id == user_id,
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_members(self, workspace_id: str) -> list[Member]:
|
||||
"""List all members of a workspace."""
|
||||
stmt = select(Member).where(Member.workspace_id == workspace_id)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_role(
|
||||
self,
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
new_role: str,
|
||||
) -> Optional[Member]:
|
||||
"""Update a member's role."""
|
||||
if new_role not in VALID_ROLES:
|
||||
raise ValueError(f"Invalid role: {new_role}. Must be one of {VALID_ROLES}")
|
||||
member = await self.get(workspace_id, user_id)
|
||||
if member is None:
|
||||
return None
|
||||
member.role = new_role
|
||||
await self._session.flush()
|
||||
return member
|
||||
|
||||
async def remove(self, workspace_id: str, user_id: str) -> bool:
|
||||
"""Remove a member from a workspace."""
|
||||
member = await self.get(workspace_id, user_id)
|
||||
if member is None:
|
||||
return False
|
||||
await self._session.delete(member)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
async def has_role(
|
||||
self,
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
required_role: str,
|
||||
) -> bool:
|
||||
"""Check if a user has at least the required role.
|
||||
|
||||
Role hierarchy: owner > admin > member.
|
||||
"""
|
||||
member = await self.get(workspace_id, user_id)
|
||||
if member is None:
|
||||
return False
|
||||
role_levels = {"owner": 3, "admin": 2, "member": 1}
|
||||
user_level = role_levels.get(member.role, 0)
|
||||
required_level = role_levels.get(required_role, 0)
|
||||
return user_level >= required_level
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Project service — CRUD with lead and issue linking.
|
||||
|
||||
Influenced by Multica's 034_projects.up.sql and project handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Issue, Project
|
||||
|
||||
|
||||
class ProjectService:
|
||||
"""Project CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
workspace_id: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
lead_type: Optional[str] = None,
|
||||
lead_id: Optional[str] = None,
|
||||
) -> Project:
|
||||
"""Create a project."""
|
||||
project = Project(
|
||||
workspace_id=workspace_id,
|
||||
title=title,
|
||||
description=description,
|
||||
icon=icon,
|
||||
lead_type=lead_type,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
self._session.add(project)
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def get(self, project_id: str) -> Optional[Project]:
|
||||
"""Get project by ID."""
|
||||
return await self._session.get(Project, project_id)
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Project]:
|
||||
"""List all projects in a workspace."""
|
||||
stmt = select(Project).where(Project.workspace_id == workspace_id).limit(limit).offset(offset)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
project_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
lead_type: Optional[str] = None,
|
||||
lead_id: Optional[str] = None,
|
||||
) -> Optional[Project]:
|
||||
"""Update project fields."""
|
||||
project = await self.get(project_id)
|
||||
if project is None:
|
||||
return None
|
||||
if title is not None:
|
||||
project.title = title
|
||||
if description is not None:
|
||||
project.description = description
|
||||
if status is not None:
|
||||
project.status = status
|
||||
if lead_type is not None:
|
||||
project.lead_type = lead_type
|
||||
if lead_id is not None:
|
||||
project.lead_id = lead_id
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def delete(self, project_id: str) -> bool:
|
||||
"""Delete a project."""
|
||||
project = await self.get(project_id)
|
||||
if project is None:
|
||||
return False
|
||||
await self._session.delete(project)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
async def get_stats(self, project_id: str) -> dict:
|
||||
"""Get issue statistics for a project."""
|
||||
stmt = (
|
||||
select(Issue.status, func.count(Issue.id))
|
||||
.where(Issue.project_id == project_id)
|
||||
.group_by(Issue.status)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
stats = {row[0]: row[1] for row in result.all()}
|
||||
total = sum(stats.values())
|
||||
return {"total": total, "by_status": stats}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Workspace service — CRUD with slug validation.
|
||||
|
||||
Influenced by Multica's workspace handler (CreateWorkspace, slug pattern)
|
||||
and Paperclip's company service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Member, Workspace
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Convert name to a URL-safe slug."""
|
||||
slug = name.lower().strip()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
slug = slug.strip("-")
|
||||
return slug or "workspace"
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
"""Workspace CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self._session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
name: str,
|
||||
owner_id: str,
|
||||
slug: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Workspace:
|
||||
"""Create a workspace and add the creator as owner."""
|
||||
final_slug = slug or _slugify(name)
|
||||
final_slug = await self._ensure_unique_slug(final_slug)
|
||||
|
||||
ws = Workspace(name=name, slug=final_slug, description=description)
|
||||
self._session.add(ws)
|
||||
await self._session.flush()
|
||||
|
||||
member = Member(workspace_id=ws.id, user_id=owner_id, role="owner")
|
||||
self._session.add(member)
|
||||
await self._session.flush()
|
||||
return ws
|
||||
|
||||
async def get(self, workspace_id: str) -> Optional[Workspace]:
|
||||
"""Get workspace by ID."""
|
||||
return await self._session.get(Workspace, workspace_id)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Workspace]:
|
||||
"""Get workspace by slug."""
|
||||
stmt = select(Workspace).where(Workspace.slug == slug)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Workspace]:
|
||||
"""List workspaces a user is a member of."""
|
||||
stmt = (
|
||||
select(Workspace)
|
||||
.join(Member, Member.workspace_id == Workspace.id)
|
||||
.where(Member.user_id == user_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
workspace_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
settings: Optional[dict] = None,
|
||||
) -> Optional[Workspace]:
|
||||
"""Update workspace fields."""
|
||||
ws = await self.get(workspace_id)
|
||||
if ws is None:
|
||||
return None
|
||||
if name is not None:
|
||||
ws.name = name
|
||||
if description is not None:
|
||||
ws.description = description
|
||||
if settings is not None:
|
||||
ws.settings = settings
|
||||
await self._session.flush()
|
||||
return ws
|
||||
|
||||
async def delete(self, workspace_id: str) -> bool:
|
||||
"""Delete a workspace."""
|
||||
ws = await self.get(workspace_id)
|
||||
if ws is None:
|
||||
return False
|
||||
await self._session.delete(ws)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
async def _ensure_unique_slug(self, slug: str) -> str:
|
||||
"""Append a suffix if the slug is already taken."""
|
||||
base = slug
|
||||
counter = 1
|
||||
while True:
|
||||
stmt = select(Workspace.id).where(Workspace.slug == slug)
|
||||
result = await self._session.execute(stmt)
|
||||
if result.scalar_one_or_none() is None:
|
||||
return slug
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
@@ -0,0 +1,45 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.0"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name="Mervin Praison" }
|
||||
]
|
||||
dependencies = [
|
||||
"praisonaiagents>=1.6.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"alembic>=1.14.0",
|
||||
"PyJWT>=2.8.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn>=0.34.0",
|
||||
"httpx>=0.27.0",
|
||||
"pydantic>=2.10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = [
|
||||
"asyncpg>=0.30.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
]
|
||||
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["praisonai_platform*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
timeout = 30
|
||||
Whitespace-only changes.
@@ -0,0 +1,41 @@
|
||||
"""Shared fixtures for platform tests."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from praisonai_platform.db.base import Base
|
||||
|
||||
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine():
|
||||
"""Create a fresh in-memory SQLite engine per test."""
|
||||
eng = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session(engine):
|
||||
"""Create a session per test."""
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as sess:
|
||||
yield sess
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Integration tests: Full API flow via httpx.AsyncClient + TestClient.
|
||||
|
||||
Tests the complete flow: register → login → create workspace → add member →
|
||||
create project → create issue → assign agent → comment.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
|
||||
|
||||
from praisonai_platform.api.app import create_app
|
||||
from praisonai_platform.db.base import Base, reset_engine
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app():
|
||||
"""Create a fresh app with in-memory DB for each test."""
|
||||
await reset_engine()
|
||||
# Patch the engine for testing
|
||||
from praisonai_platform.db import base as base_mod
|
||||
eng = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
base_mod._engine = eng
|
||||
base_mod._session_factory = None
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
application = create_app()
|
||||
yield application
|
||||
await eng.dispose()
|
||||
base_mod._engine = None
|
||||
base_mod._session_factory = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app):
|
||||
"""httpx async client for the app."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(self, client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
class TestFullFlow:
|
||||
"""End-to-end: register → workspace → project → issue → comment."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_flow(self, client):
|
||||
# 1. Register
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "[email protected]",
|
||||
"password": "testpass",
|
||||
"name": "Flow User",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
token = data["token"]
|
||||
user_id = data["user"]["id"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 2. Login
|
||||
resp = await client.post("/api/v1/auth/login", json={
|
||||
"email": "[email protected]",
|
||||
"password": "testpass",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["token"]
|
||||
|
||||
# 3. Create workspace
|
||||
resp = await client.post("/api/v1/workspaces/", json={
|
||||
"name": "Acme Corp",
|
||||
"slug": "acme",
|
||||
}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
ws = resp.json()
|
||||
ws_id = ws["id"]
|
||||
assert ws["slug"] == "acme"
|
||||
|
||||
# 4. List workspaces
|
||||
resp = await client.get("/api/v1/workspaces/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
# 5. Get workspace
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Acme Corp"
|
||||
|
||||
# 6. List members (owner should be auto-added)
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}/members", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
members = resp.json()
|
||||
assert len(members) >= 1
|
||||
assert any(m["role"] == "owner" for m in members)
|
||||
|
||||
# 7. Create project
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/projects/",
|
||||
json={"title": "Platform MVP", "description": "Build the platform"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
project = resp.json()
|
||||
project_id = project["id"]
|
||||
assert project["title"] == "Platform MVP"
|
||||
|
||||
# 8. List projects
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}/projects/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# 9. Create issue
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={
|
||||
"title": "Implement auth",
|
||||
"description": "JWT + bcrypt",
|
||||
"project_id": project_id,
|
||||
"priority": "high",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
issue = resp.json()
|
||||
issue_id = issue["id"]
|
||||
assert issue["priority"] == "high"
|
||||
assert issue["creator_id"] == user_id
|
||||
|
||||
# 10. Update issue (assign to agent)
|
||||
resp = await client.patch(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}",
|
||||
json={"assignee_type": "agent", "assignee_id": "agent-42", "status": "in_progress"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
updated = resp.json()
|
||||
assert updated["assignee_type"] == "agent"
|
||||
assert updated["assignee_id"] == "agent-42"
|
||||
assert updated["status"] == "in_progress"
|
||||
|
||||
# 11. Add comment
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
|
||||
json={"content": "Starting work on this now."},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
comment = resp.json()
|
||||
assert comment["content"] == "Starting work on this now."
|
||||
|
||||
# 12. List comments
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
comments = resp.json()
|
||||
assert len(comments) == 1
|
||||
|
||||
# 13. Project stats
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/projects/{project_id}/stats",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
assert stats["total"] == 1
|
||||
assert stats["by_status"]["in_progress"] == 1
|
||||
|
||||
|
||||
class TestAuthErrors:
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_auth(self, client):
|
||||
resp = await client.get("/api/v1/workspaces/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_token(self, client):
|
||||
resp = await client.get(
|
||||
"/api/v1/workspaces/",
|
||||
headers={"Authorization": "Bearer bad-token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_password(self, client):
|
||||
await client.post("/api/v1/auth/register", json={
|
||||
"email": "[email protected]",
|
||||
"password": "correct",
|
||||
})
|
||||
resp = await client.post("/api/v1/auth/login", json={
|
||||
"email": "[email protected]",
|
||||
"password": "incorrect",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestNotFound:
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_not_found(self, client):
|
||||
# Register to get a token
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "[email protected]",
|
||||
"password": "pass",
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.get("/api/v1/workspaces/nonexistent", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Integration tests for new gap API routes:
|
||||
- Agent CRUD routes
|
||||
- Label routes + issue linking
|
||||
- Dependency routes
|
||||
- Activity log routes
|
||||
- Issue numbering in API
|
||||
- Threaded comments in API
|
||||
- Pagination query params
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
os.environ["PLATFORM_JWT_SECRET"] = "test-secret-for-testing-only"
|
||||
|
||||
from praisonai_platform.api.app import create_app
|
||||
from praisonai_platform.db.base import Base, reset_engine
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app():
|
||||
await reset_engine()
|
||||
from praisonai_platform.db import base as base_mod
|
||||
eng = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
base_mod._engine = eng
|
||||
base_mod._session_factory = None
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
application = create_app()
|
||||
yield application
|
||||
await eng.dispose()
|
||||
base_mod._engine = None
|
||||
base_mod._session_factory = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _auth(client) -> tuple:
|
||||
"""Register and return (token, user_id, headers, workspace_id)."""
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "[email protected]", "password": "testpass", "name": "GapUser",
|
||||
})
|
||||
data = resp.json()
|
||||
token = data["token"]
|
||||
user_id = data["user"]["id"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
resp = await client.post("/api/v1/workspaces/", json={
|
||||
"name": "GapWS", "slug": "gapws",
|
||||
}, headers=headers)
|
||||
ws_id = resp.json()["id"]
|
||||
return token, user_id, headers, ws_id
|
||||
|
||||
|
||||
class TestAgentRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_crud(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
# Create
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/",
|
||||
json={"name": "TestBot", "instructions": "Help users"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
agent = resp.json()
|
||||
agent_id = agent["id"]
|
||||
assert agent["name"] == "TestBot"
|
||||
assert agent["instructions"] == "Help users"
|
||||
assert agent["status"] == "offline"
|
||||
|
||||
# List
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}/agents/", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Get
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}/agents/{agent_id}", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "TestBot"
|
||||
|
||||
# Update
|
||||
resp = await client.patch(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/{agent_id}",
|
||||
json={"name": "UpdatedBot", "status": "idle"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "UpdatedBot"
|
||||
assert resp.json()["status"] == "idle"
|
||||
|
||||
# Delete
|
||||
resp = await client.delete(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/{agent_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_not_found(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/nonexistent",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestLabelRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_crud_and_linking(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
# Create label
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/labels",
|
||||
json={"name": "bug", "color": "#FF0000"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
label = resp.json()
|
||||
label_id = label["id"]
|
||||
assert label["name"] == "bug"
|
||||
|
||||
# List labels
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_id}/labels", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Create issue
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": "Labeled bug"},
|
||||
headers=headers,
|
||||
)
|
||||
issue_id = resp.json()["id"]
|
||||
|
||||
# Add label to issue
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels/{label_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# List issue labels
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Remove label from issue
|
||||
resp = await client.delete(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/labels/{label_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Delete label
|
||||
resp = await client.delete(
|
||||
f"/api/v1/workspaces/{ws_id}/labels/{label_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
class TestDependencyRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_crud(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
# Create two issues
|
||||
resp1 = await client.post(f"/api/v1/workspaces/{ws_id}/issues/", json={"title": "Blocker"}, headers=headers)
|
||||
resp2 = await client.post(f"/api/v1/workspaces/{ws_id}/issues/", json={"title": "Blocked"}, headers=headers)
|
||||
i1_id = resp1.json()["id"]
|
||||
i2_id = resp2.json()["id"]
|
||||
|
||||
# Create dependency
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/",
|
||||
json={"depends_on_issue_id": i2_id, "type": "blocks"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
dep = resp.json()
|
||||
dep_id = dep["id"]
|
||||
assert dep["type"] == "blocks"
|
||||
|
||||
# List dependencies
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Delete dependency
|
||||
resp = await client.delete(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{i1_id}/dependencies/{dep_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
class TestActivityRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_routes(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
# Create an issue (triggers activity logging)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": "Activity test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
issue_id = resp.json()["id"]
|
||||
|
||||
# List workspace activity
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/activity",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
activities = resp.json()
|
||||
assert len(activities) >= 1
|
||||
assert any(a["action"] == "issue.created" for a in activities)
|
||||
|
||||
# List issue activity
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/activity",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
|
||||
class TestIssueNumberingAPI:
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_has_number_and_identifier(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": "Numbered Issue 1"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
issue1 = resp.json()
|
||||
assert issue1["number"] == 1
|
||||
assert issue1["identifier"] == "ISS-1"
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": "Numbered Issue 2"},
|
||||
headers=headers,
|
||||
)
|
||||
issue2 = resp.json()
|
||||
assert issue2["number"] == 2
|
||||
assert issue2["identifier"] == "ISS-2"
|
||||
|
||||
|
||||
class TestThreadedCommentsAPI:
|
||||
@pytest.mark.asyncio
|
||||
async def test_threaded_comment(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
# Create issue
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": "Thread test"},
|
||||
headers=headers,
|
||||
)
|
||||
issue_id = resp.json()["id"]
|
||||
|
||||
# Top-level comment
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
|
||||
json={"content": "Parent comment"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
parent = resp.json()
|
||||
assert parent["parent_id"] is None
|
||||
|
||||
# Reply
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/{issue_id}/comments",
|
||||
json={"content": "Reply", "parent_id": parent["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
reply = resp.json()
|
||||
assert reply["parent_id"] == parent["id"]
|
||||
|
||||
|
||||
class TestPaginationAPI:
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_list_pagination(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
for i in range(5):
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/",
|
||||
json={"title": f"Issue {i}"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/?limit=2&offset=0",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/issues/?limit=2&offset=2",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_list_pagination(self, client):
|
||||
_, _, headers, ws_id = await _auth(client)
|
||||
|
||||
for i in range(4):
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/",
|
||||
json={"name": f"Agent {i}"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/workspaces/{ws_id}/agents/?limit=2&offset=0",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Tests for all new gap implementations:
|
||||
GAP-1: Agent CRUD service
|
||||
GAP-2: Label service + issue linking
|
||||
GAP-3: Dependency service
|
||||
GAP-4: Human-readable issue IDs
|
||||
GAP-5: Threaded comments (parent_id)
|
||||
GAP-6: Pagination
|
||||
GAP-10: Agent instructions field
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_platform.services.auth_service import AuthService
|
||||
from praisonai_platform.services.workspace_service import WorkspaceService
|
||||
from praisonai_platform.services.issue_service import IssueService
|
||||
from praisonai_platform.services.agent_service import AgentService
|
||||
from praisonai_platform.services.label_service import LabelService
|
||||
from praisonai_platform.services.dependency_service import DependencyService
|
||||
from praisonai_platform.services.comment_service import CommentService
|
||||
from praisonai_platform.services.activity_service import ActivityService
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _create_user_and_workspace(session, email="[email protected]"):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register(email, "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("TestWS", user.id)
|
||||
return user, ws
|
||||
|
||||
|
||||
# ── GAP-1: Agent CRUD ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "CodeBot", owner_id=user.id)
|
||||
assert agent.name == "CodeBot"
|
||||
assert agent.status == "offline"
|
||||
assert agent.workspace_id == ws.id
|
||||
assert agent.owner_id == user.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_agent_with_instructions(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "InstrBot", instructions="You are a helpful bot.")
|
||||
assert agent.instructions == "You are a helpful bot."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
await svc.create(ws.id, "Agent1")
|
||||
await svc.create(ws.id, "Agent2")
|
||||
agents = await svc.list_for_workspace(ws.id)
|
||||
assert len(agents) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_filter_status(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
await svc.create(ws.id, "Active", status="idle")
|
||||
await svc.create(ws.id, "Off", status="offline")
|
||||
idle = await svc.list_for_workspace(ws.id, status="idle")
|
||||
assert len(idle) == 1
|
||||
assert idle[0].name == "Active"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_agent(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "OldName")
|
||||
updated = await svc.update(agent.id, name="NewName", status="idle")
|
||||
assert updated.name == "NewName"
|
||||
assert updated.status == "idle"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_agent_invalid_status(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "Bot")
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
await svc.update(agent.id, status="flying")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "ToDelete")
|
||||
assert await svc.delete(agent.id) is True
|
||||
assert await svc.get(agent.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent(self, session):
|
||||
svc = AgentService(session)
|
||||
assert await svc.delete("nonexistent") is False
|
||||
|
||||
|
||||
# ── GAP-2: Label CRUD + Issue linking ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestLabelService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_label(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = LabelService(session)
|
||||
label = await svc.create(ws.id, "bug", "#FF0000")
|
||||
assert label.name == "bug"
|
||||
assert label.color == "#FF0000"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_labels(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = LabelService(session)
|
||||
await svc.create(ws.id, "bug")
|
||||
await svc.create(ws.id, "feature")
|
||||
labels = await svc.list_for_workspace(ws.id)
|
||||
assert len(labels) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_label(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = LabelService(session)
|
||||
label = await svc.create(ws.id, "old")
|
||||
updated = await svc.update(label.id, name="new", color="#00FF00")
|
||||
assert updated.name == "new"
|
||||
assert updated.color == "#00FF00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_label(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = LabelService(session)
|
||||
label = await svc.create(ws.id, "gone")
|
||||
assert await svc.delete(label.id) is True
|
||||
assert await svc.get(label.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_remove_label_to_issue(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
label_svc = LabelService(session)
|
||||
issue_svc = IssueService(session)
|
||||
label = await label_svc.create(ws.id, "bug")
|
||||
issue = await issue_svc.create(ws.id, "Labeled Issue", user.id)
|
||||
await label_svc.add_to_issue(issue.id, label.id)
|
||||
labels = await label_svc.list_for_issue(issue.id)
|
||||
assert len(labels) == 1
|
||||
assert labels[0].id == label.id
|
||||
await label_svc.remove_from_issue(issue.id, label.id)
|
||||
labels2 = await label_svc.list_for_issue(issue.id)
|
||||
assert len(labels2) == 0
|
||||
|
||||
|
||||
# ── GAP-3: Dependency service ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDependencyService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dependency(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
issue_svc = IssueService(session)
|
||||
i1 = await issue_svc.create(ws.id, "Blocker", user.id)
|
||||
i2 = await issue_svc.create(ws.id, "Blocked", user.id)
|
||||
dep_svc = DependencyService(session)
|
||||
dep = await dep_svc.create(i1.id, i2.id, "blocks")
|
||||
assert dep.issue_id == i1.id
|
||||
assert dep.depends_on_issue_id == i2.id
|
||||
assert dep.type == "blocks"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_dep_type(self, session):
|
||||
dep_svc = DependencyService(session)
|
||||
with pytest.raises(ValueError, match="Invalid type"):
|
||||
await dep_svc.create("a", "b", "depends_on")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dependencies(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
issue_svc = IssueService(session)
|
||||
i1 = await issue_svc.create(ws.id, "A", user.id)
|
||||
i2 = await issue_svc.create(ws.id, "B", user.id)
|
||||
dep_svc = DependencyService(session)
|
||||
await dep_svc.create(i1.id, i2.id, "blocks")
|
||||
deps_from_i1 = await dep_svc.list_for_issue(i1.id)
|
||||
deps_from_i2 = await dep_svc.list_for_issue(i2.id)
|
||||
assert len(deps_from_i1) == 1
|
||||
assert len(deps_from_i2) == 1 # Same dep shows from both sides
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dependency(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
issue_svc = IssueService(session)
|
||||
i1 = await issue_svc.create(ws.id, "X", user.id)
|
||||
i2 = await issue_svc.create(ws.id, "Y", user.id)
|
||||
dep_svc = DependencyService(session)
|
||||
dep = await dep_svc.create(i1.id, i2.id, "related")
|
||||
assert await dep_svc.delete(dep.id) is True
|
||||
assert await dep_svc.get(dep.id) is None
|
||||
|
||||
|
||||
# ── GAP-4: Human-readable issue IDs ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestIssueNumbering:
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_auto_number(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = IssueService(session)
|
||||
i1 = await svc.create(ws.id, "First", user.id)
|
||||
i2 = await svc.create(ws.id, "Second", user.id)
|
||||
assert i1.number == 1
|
||||
assert i1.identifier == "ISS-1"
|
||||
assert i2.number == 2
|
||||
assert i2.identifier == "ISS-2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_issue_prefix(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("PrefixWS", user.id)
|
||||
await ws_svc.update(ws.id, settings={"custom": True})
|
||||
# Directly update prefix for testing
|
||||
from praisonai_platform.db.models import Workspace
|
||||
ws_obj = await session.get(Workspace, ws.id)
|
||||
ws_obj.issue_prefix = "PRJ"
|
||||
await session.flush()
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "Custom Prefix", user.id)
|
||||
assert issue.identifier == "PRJ-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counter_persists_across_creates(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = IssueService(session)
|
||||
for i in range(5):
|
||||
issue = await svc.create(ws.id, f"Issue-{i}", user.id)
|
||||
assert issue.number == 5
|
||||
assert issue.identifier == "ISS-5"
|
||||
|
||||
|
||||
# ── GAP-5: Threaded comments ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestThreadedComments:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_to_comment(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "ThreadIssue", user.id)
|
||||
svc = CommentService(session)
|
||||
parent = await svc.create(issue.id, user.id, "Parent comment")
|
||||
reply = await svc.create(issue.id, user.id, "Reply", parent_id=parent.id)
|
||||
assert reply.parent_id == parent.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_level_comment_no_parent(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "TopIssue", user.id)
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(issue.id, user.id, "Top level")
|
||||
assert comment.parent_id is None
|
||||
|
||||
|
||||
# ── GAP-6: Pagination ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_pagination(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = IssueService(session)
|
||||
for i in range(10):
|
||||
await svc.create(ws.id, f"Issue {i}", user.id)
|
||||
page1 = await svc.list_for_workspace(ws.id, limit=3, offset=0)
|
||||
page2 = await svc.list_for_workspace(ws.id, limit=3, offset=3)
|
||||
assert len(page1) == 3
|
||||
assert len(page2) == 3
|
||||
assert page1[0].id != page2[0].id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_pagination(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
for i in range(5):
|
||||
await svc.create(ws.id, f"Agent-{i}")
|
||||
page = await svc.list_for_workspace(ws.id, limit=2, offset=0)
|
||||
assert len(page) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_pagination(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = ActivityService(session)
|
||||
for i in range(5):
|
||||
await svc.log(ws.id, f"action.{i}", "member", user.id)
|
||||
page = await svc.list_for_workspace(ws.id, limit=2, offset=0)
|
||||
assert len(page) == 2
|
||||
|
||||
|
||||
# ── GAP-10: Agent instructions in model ──────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentInstructions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_persist(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "InstrBot", instructions="Do X then Y")
|
||||
fetched = await svc.get(agent.id)
|
||||
assert fetched.instructions == "Do X then Y"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_instructions(self, session):
|
||||
user, ws = await _create_user_and_workspace(session, "[email protected]")
|
||||
svc = AgentService(session)
|
||||
agent = await svc.create(ws.id, "UpdInstr")
|
||||
updated = await svc.update(agent.id, instructions="New instructions")
|
||||
assert updated.instructions == "New instructions"
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Unit tests for all platform services."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_platform.services.auth_service import AuthService
|
||||
from praisonai_platform.services.workspace_service import WorkspaceService
|
||||
from praisonai_platform.services.member_service import MemberService
|
||||
from praisonai_platform.services.project_service import ProjectService
|
||||
from praisonai_platform.services.issue_service import IssueService
|
||||
from praisonai_platform.services.comment_service import CommentService
|
||||
from praisonai_platform.services.activity_service import ActivityService
|
||||
|
||||
|
||||
# ── Auth Service ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register(self, session):
|
||||
svc = AuthService(session)
|
||||
user, token = await svc.register("[email protected]", "password123", "Alice")
|
||||
assert user.email == "[email protected]"
|
||||
assert user.name == "Alice"
|
||||
assert user.password_hash is not None
|
||||
assert token is not None
|
||||
assert len(token) > 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_default_name(self, session):
|
||||
svc = AuthService(session)
|
||||
user, _ = await svc.register("[email protected]", "pass")
|
||||
assert user.name == "bob"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(self, session):
|
||||
svc = AuthService(session)
|
||||
await svc.register("[email protected]", "mypass", "Carol")
|
||||
result = await svc.login("[email protected]", "mypass")
|
||||
assert result is not None
|
||||
user, token = result
|
||||
assert user.email == "[email protected]"
|
||||
assert token is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(self, session):
|
||||
svc = AuthService(session)
|
||||
await svc.register("[email protected]", "correct")
|
||||
result = await svc.login("[email protected]", "wrong")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(self, session):
|
||||
svc = AuthService(session)
|
||||
result = await svc.login("[email protected]", "pass")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_with_token(self, session):
|
||||
svc = AuthService(session)
|
||||
_, token = await svc.register("[email protected]", "pass", "Eve")
|
||||
identity = await svc.authenticate({"token": token})
|
||||
assert identity is not None
|
||||
assert identity.email == "[email protected]"
|
||||
assert identity.name == "Eve"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_bad_token(self, session):
|
||||
svc = AuthService(session)
|
||||
identity = await svc.authenticate({"token": "invalid.jwt.token"})
|
||||
assert identity is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_with_credentials(self, session):
|
||||
svc = AuthService(session)
|
||||
await svc.register("[email protected]", "pass123", "Frank")
|
||||
identity = await svc.authenticate({"email": "[email protected]", "password": "pass123"})
|
||||
assert identity is not None
|
||||
assert identity.email == "[email protected]"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize(self, session):
|
||||
svc = AuthService(session)
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
identity = AuthIdentity(id="u-1", workspace_id="ws-1", roles=["owner"])
|
||||
# Without workspace member record, auth will check DB — returns False
|
||||
result = await svc.authorize(identity, "issue:1", "delete")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── Workspace Service ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWorkspaceService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("My Workspace", user.id)
|
||||
assert ws.name == "My Workspace"
|
||||
assert ws.slug == "my-workspace"
|
||||
assert ws.id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_custom_slug(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("Test", user.id, slug="custom-slug")
|
||||
assert ws.slug == "custom-slug"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_duplicate_slug(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws1 = await svc.create("Same", user.id, slug="same")
|
||||
ws2 = await svc.create("Same", user.id, slug="same")
|
||||
assert ws1.slug == "same"
|
||||
assert ws2.slug == "same-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("GetMe", user.id)
|
||||
fetched = await svc.get(ws.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "GetMe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("BySlug", user.id)
|
||||
fetched = await svc.get_by_slug(ws.slug)
|
||||
assert fetched is not None
|
||||
assert fetched.id == ws.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_user(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
await svc.create("WS1", user.id)
|
||||
await svc.create("WS2", user.id)
|
||||
workspaces = await svc.list_for_user(user.id)
|
||||
assert len(workspaces) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_workspace(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("Original", user.id)
|
||||
updated = await svc.update(ws.id, name="Updated")
|
||||
assert updated.name == "Updated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workspace(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
svc = WorkspaceService(session)
|
||||
ws = await svc.create("ToDelete", user.id)
|
||||
assert await svc.delete(ws.id) is True
|
||||
assert await svc.get(ws.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent(self, session):
|
||||
svc = WorkspaceService(session)
|
||||
assert await svc.delete("nonexistent-id") is False
|
||||
|
||||
|
||||
# ── Member Service ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMemberService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_member(self, session):
|
||||
auth = AuthService(session)
|
||||
user1, _ = await auth.register("[email protected]", "pass")
|
||||
user2, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("MemberWS", user1.id)
|
||||
svc = MemberService(session)
|
||||
member = await svc.add(ws.id, user2.id, "member")
|
||||
assert member.role == "member"
|
||||
assert member.user_id == user2.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_role(self, session):
|
||||
svc = MemberService(session)
|
||||
with pytest.raises(ValueError, match="Invalid role"):
|
||||
await svc.add("ws-1", "u-1", "superadmin")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_members(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ListMembersWS", user.id)
|
||||
svc = MemberService(session)
|
||||
members = await svc.list_members(ws.id)
|
||||
assert len(members) >= 1 # owner auto-added
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_role(self, session):
|
||||
auth = AuthService(session)
|
||||
user1, _ = await auth.register("[email protected]", "pass")
|
||||
user2, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("RoleWS", user1.id)
|
||||
svc = MemberService(session)
|
||||
await svc.add(ws.id, user2.id, "member")
|
||||
updated = await svc.update_role(ws.id, user2.id, "admin")
|
||||
assert updated.role == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_member(self, session):
|
||||
auth = AuthService(session)
|
||||
user1, _ = await auth.register("[email protected]", "pass")
|
||||
user2, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("RemWS", user1.id)
|
||||
svc = MemberService(session)
|
||||
await svc.add(ws.id, user2.id, "member")
|
||||
assert await svc.remove(ws.id, user2.id) is True
|
||||
assert await svc.get(ws.id, user2.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_role(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("HasRoleWS", user.id)
|
||||
svc = MemberService(session)
|
||||
# Owner should have at least "member" access
|
||||
assert await svc.has_role(ws.id, user.id, "member") is True
|
||||
assert await svc.has_role(ws.id, user.id, "admin") is True
|
||||
assert await svc.has_role(ws.id, user.id, "owner") is True
|
||||
assert await svc.has_role(ws.id, "nonexistent", "member") is False
|
||||
|
||||
|
||||
# ── Project Service ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProjectService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ProjectWS", user.id)
|
||||
svc = ProjectService(session)
|
||||
project = await svc.create(ws.id, "My Project", description="A project")
|
||||
assert project.title == "My Project"
|
||||
assert project.status == "planned"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ProjListWS", user.id)
|
||||
svc = ProjectService(session)
|
||||
await svc.create(ws.id, "P1")
|
||||
await svc.create(ws.id, "P2")
|
||||
projects = await svc.list_for_workspace(ws.id)
|
||||
assert len(projects) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ProjUpdWS", user.id)
|
||||
svc = ProjectService(session)
|
||||
project = await svc.create(ws.id, "Original")
|
||||
updated = await svc.update(project.id, title="Updated", status="in_progress")
|
||||
assert updated.title == "Updated"
|
||||
assert updated.status == "in_progress"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ProjDelWS", user.id)
|
||||
svc = ProjectService(session)
|
||||
project = await svc.create(ws.id, "ToDelete")
|
||||
assert await svc.delete(project.id) is True
|
||||
assert await svc.get(project.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ProjStatsWS", user.id)
|
||||
proj_svc = ProjectService(session)
|
||||
project = await proj_svc.create(ws.id, "StatsProj")
|
||||
issue_svc = IssueService(session)
|
||||
await issue_svc.create(ws.id, "Issue1", user.id, project_id=project.id, status="todo")
|
||||
await issue_svc.create(ws.id, "Issue2", user.id, project_id=project.id, status="todo")
|
||||
await issue_svc.create(ws.id, "Issue3", user.id, project_id=project.id, status="done")
|
||||
stats = await proj_svc.get_stats(project.id)
|
||||
assert stats["total"] == 3
|
||||
assert stats["by_status"]["todo"] == 2
|
||||
assert stats["by_status"]["done"] == 1
|
||||
|
||||
|
||||
# ── Issue Service ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIssueService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("IssueWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "Bug report", user.id)
|
||||
assert issue.title == "Bug report"
|
||||
assert issue.status == "backlog"
|
||||
assert issue.priority == "none"
|
||||
assert issue.creator_id == user.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_with_agent_creator(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("AgentIssueWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "Agent Task", "agent-1", creator_type="agent")
|
||||
assert issue.creator_type == "agent"
|
||||
assert issue.creator_id == "agent-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_issues_with_filters(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("FilterWS", user.id)
|
||||
svc = IssueService(session)
|
||||
await svc.create(ws.id, "Todo1", user.id, status="todo")
|
||||
await svc.create(ws.id, "Done1", user.id, status="done")
|
||||
todo_issues = await svc.list_for_workspace(ws.id, status="todo")
|
||||
assert len(todo_issues) == 1
|
||||
assert todo_issues[0].title == "Todo1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_agent(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("AssignWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "To Assign", user.id)
|
||||
assigned = await svc.assign(issue.id, "agent", "agent-42")
|
||||
assert assigned.assignee_type == "agent"
|
||||
assert assigned.assignee_id == "agent-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_assignee_type(self, session):
|
||||
svc = IssueService(session)
|
||||
with pytest.raises(ValueError, match="Invalid assignee_type"):
|
||||
await svc.assign("issue-1", "robot", "r-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("TransWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "Transition", user.id)
|
||||
assert issue.status == "backlog"
|
||||
updated = await svc.transition(issue.id, "in_progress")
|
||||
assert updated.status == "in_progress"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_status(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("BadStatWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "BadStat", user.id)
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
await svc.update(issue.id, status="invalid_status")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_issues(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("SubWS", user.id)
|
||||
svc = IssueService(session)
|
||||
parent = await svc.create(ws.id, "Parent Issue", user.id)
|
||||
child = await svc.create(ws.id, "Child Issue", user.id, parent_issue_id=parent.id)
|
||||
assert child.parent_issue_id == parent.id
|
||||
sub_issues = await svc.list_sub_issues(parent.id)
|
||||
assert len(sub_issues) == 1
|
||||
assert sub_issues[0].id == child.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_issue(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("DelIssueWS", user.id)
|
||||
svc = IssueService(session)
|
||||
issue = await svc.create(ws.id, "ToDelete", user.id)
|
||||
assert await svc.delete(issue.id) is True
|
||||
assert await svc.get(issue.id) is None
|
||||
|
||||
|
||||
# ── Comment Service ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCommentService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("CommentWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "Commentable", user.id)
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(issue.id, user.id, "This is a comment")
|
||||
assert comment.content == "This is a comment"
|
||||
assert comment.author_type == "member"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_comment(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("AgentCommentWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "AgentComment", user.id)
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(issue.id, "agent-1", "Agent progress update", "agent", "progress_update")
|
||||
assert comment.author_type == "agent"
|
||||
assert comment.type == "progress_update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_comments(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ListCommentWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "WithComments", user.id)
|
||||
svc = CommentService(session)
|
||||
await svc.create(issue.id, user.id, "First")
|
||||
await svc.create(issue.id, user.id, "Second")
|
||||
comments = await svc.list_for_issue(issue.id)
|
||||
assert len(comments) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_comment(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("UpdCommentWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "UpdComment", user.id)
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(issue.id, user.id, "Original")
|
||||
updated = await svc.update(comment.id, "Updated content")
|
||||
assert updated.content == "Updated content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_comment(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("DelCommentWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "DelComment", user.id)
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(issue.id, user.id, "ToDelete")
|
||||
assert await svc.delete(comment.id) is True
|
||||
assert await svc.get(comment.id) is None
|
||||
|
||||
|
||||
# ── Activity Service ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestActivityService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_activity(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ActivityWS", user.id)
|
||||
svc = ActivityService(session)
|
||||
entry = await svc.log(ws.id, "issue.created", "member", user.id, details={"title": "Bug"})
|
||||
assert entry.action == "issue.created"
|
||||
assert entry.details["title"] == "Bug"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_workspace(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ActivityListWS", user.id)
|
||||
svc = ActivityService(session)
|
||||
await svc.log(ws.id, "workspace.created", "member", user.id)
|
||||
await svc.log(ws.id, "member.added", "member", user.id)
|
||||
activities = await svc.list_for_workspace(ws.id)
|
||||
assert len(activities) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_for_issue(self, session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_svc = WorkspaceService(session)
|
||||
ws = await ws_svc.create("ActivityIssueWS", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(ws.id, "Tracked", user.id)
|
||||
svc = ActivityService(session)
|
||||
await svc.log(ws.id, "issue.created", "member", user.id, issue_id=issue.id)
|
||||
activities = await svc.list_for_issue(issue.id)
|
||||
assert len(activities) == 1
|
||||
Generated
+2013
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-11",
|
||||
"lastUpdated": "2026-04-13",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 321,
|
||||
"pythonCoreFeatures": 328,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 42,
|
||||
"parityPercentage": 207.8
|
||||
"gapCount": 49,
|
||||
"parityPercentage": 203.4
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -20,6 +20,7 @@
|
||||
"AgentAppProtocol",
|
||||
"AgentFlow",
|
||||
"AgentManager",
|
||||
"AgentMessageEvent",
|
||||
"AgentOSConfig",
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
@@ -71,6 +72,7 @@
|
||||
"ContextTraceEmitter",
|
||||
"ContextTraceSink",
|
||||
"ContextTraceSinkProtocol",
|
||||
"CustomToolUseEvent",
|
||||
"DeepResearchAgent",
|
||||
"DeepResearchResponse",
|
||||
"DefaultsConfig",
|
||||
@@ -144,6 +146,7 @@
|
||||
"MULTI_AGENT_EXECUTION_PRESETS",
|
||||
"MULTI_AGENT_OUTPUT_PRESETS",
|
||||
"ManagedBackendProtocol",
|
||||
"ManagedEvent",
|
||||
"ManagerConfig",
|
||||
"Memory",
|
||||
"MemoryBackend",
|
||||
@@ -215,12 +218,15 @@
|
||||
"SecurityPolicy",
|
||||
"Session",
|
||||
"SessionConfig",
|
||||
"SessionErrorEvent",
|
||||
"SessionIdleEvent",
|
||||
"SkillLoader",
|
||||
"SkillManager",
|
||||
"SkillMetadata",
|
||||
"SkillProperties",
|
||||
"SkillsConfig",
|
||||
"StepResult",
|
||||
"StopReason",
|
||||
"StructuredFormatter",
|
||||
"Task",
|
||||
"TaskOutput",
|
||||
@@ -232,6 +238,7 @@
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
"ToolUseEvent",
|
||||
"ToolValidationError",
|
||||
"Tools",
|
||||
"TraceSink",
|
||||
@@ -2853,6 +2860,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agents"
|
||||
},
|
||||
{
|
||||
"feature": "AgentMessageEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentOSConfig",
|
||||
"python": true,
|
||||
@@ -3177,6 +3193,15 @@
|
||||
"status": "DONE",
|
||||
"category": "trace"
|
||||
},
|
||||
{
|
||||
"feature": "CustomToolUseEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "DefaultsConfig",
|
||||
"python": true,
|
||||
@@ -3256,7 +3281,7 @@
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "gateway"
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ExecutionConfig",
|
||||
@@ -3618,6 +3643,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ManagedEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "MemoryBackend",
|
||||
"python": true,
|
||||
@@ -3969,6 +4003,24 @@
|
||||
"status": "DONE",
|
||||
"category": "gateway"
|
||||
},
|
||||
{
|
||||
"feature": "SessionErrorEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "SessionIdleEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "SkillsConfig",
|
||||
"python": true,
|
||||
@@ -3978,6 +4030,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "StopReason",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "StructuredFormatter",
|
||||
"python": true,
|
||||
@@ -4032,6 +4093,15 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "ToolUseEvent",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "TraceSink",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 321 | **Rust Features:** 667 | **Parity:** 88.8%
|
||||
> **Python Features:** 328 | **Rust Features:** 667 | **Parity:** 86.9%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 321 |
|
||||
| Python Core Features | 328 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **36** |
|
||||
| **Actual Gap Count** | **43** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **88.8%** |
|
||||
| **Parity** | **86.9%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -694,6 +694,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
|
||||
## Missing Features
|
||||
|
||||
- ❌ `AgentMessageEvent`
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
- ❌ `BaseTool`
|
||||
@@ -701,6 +702,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `BotOSProtocol`
|
||||
- ❌ `BudgetExceededError`
|
||||
- ❌ `ConsoleBackend`
|
||||
- ❌ `CustomToolUseEvent`
|
||||
- ❌ `DoomLoopDetector`
|
||||
- ❌ `ErrorContextProtocol`
|
||||
- ❌ `EscalationPipeline`
|
||||
@@ -716,12 +718,17 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `LearnProtocol`
|
||||
- ❌ `LearnScope`
|
||||
- ❌ `ManagedBackendProtocol`
|
||||
- ❌ `ManagedEvent`
|
||||
- ❌ `NetworkError`
|
||||
- ❌ `ObservabilityEventType`
|
||||
- ❌ `ObservabilityHooks`
|
||||
- ❌ `PraisonAIError`
|
||||
- ❌ `SessionErrorEvent`
|
||||
- ❌ `SessionIdleEvent`
|
||||
- ❌ `StopReason`
|
||||
- ❌ `StructuredFormatter`
|
||||
- ❌ `ToolExecutionError`
|
||||
- ❌ `ToolUseEvent`
|
||||
- ❌ `ToolValidationError`
|
||||
- ❌ `configure_structured_logging`
|
||||
- ❌ `get_logger`
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-11",
|
||||
"lastUpdated": "2026-04-13",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 321,
|
||||
"pythonCoreFeatures": 328,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 29,
|
||||
"gapCount": 36,
|
||||
"priorityP0": 1,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 28
|
||||
"priorityP3": 35
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -24,6 +24,7 @@
|
||||
"AgentAppProtocol",
|
||||
"AgentFlow",
|
||||
"AgentManager",
|
||||
"AgentMessageEvent",
|
||||
"AgentOSConfig",
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
@@ -75,6 +76,7 @@
|
||||
"ContextTraceEmitter",
|
||||
"ContextTraceSink",
|
||||
"ContextTraceSinkProtocol",
|
||||
"CustomToolUseEvent",
|
||||
"DeepResearchAgent",
|
||||
"DeepResearchResponse",
|
||||
"DefaultsConfig",
|
||||
@@ -148,6 +150,7 @@
|
||||
"MULTI_AGENT_EXECUTION_PRESETS",
|
||||
"MULTI_AGENT_OUTPUT_PRESETS",
|
||||
"ManagedBackendProtocol",
|
||||
"ManagedEvent",
|
||||
"ManagerConfig",
|
||||
"Memory",
|
||||
"MemoryBackend",
|
||||
@@ -219,12 +222,15 @@
|
||||
"SecurityPolicy",
|
||||
"Session",
|
||||
"SessionConfig",
|
||||
"SessionErrorEvent",
|
||||
"SessionIdleEvent",
|
||||
"SkillLoader",
|
||||
"SkillManager",
|
||||
"SkillMetadata",
|
||||
"SkillProperties",
|
||||
"SkillsConfig",
|
||||
"StepResult",
|
||||
"StopReason",
|
||||
"StructuredFormatter",
|
||||
"Task",
|
||||
"TaskOutput",
|
||||
@@ -236,6 +242,7 @@
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
"ToolUseEvent",
|
||||
"ToolValidationError",
|
||||
"Tools",
|
||||
"TraceSink",
|
||||
@@ -519,7 +526,6 @@
|
||||
"track_workflow"
|
||||
],
|
||||
"gateway": [
|
||||
"EventType",
|
||||
"GatewayClientProtocol",
|
||||
"GatewayConfig",
|
||||
"GatewayEvent",
|
||||
@@ -564,14 +570,17 @@
|
||||
"Memory"
|
||||
],
|
||||
"other": [
|
||||
"AgentMessageEvent",
|
||||
"Agents",
|
||||
"AsyncLearnProtocol",
|
||||
"AutonomyConfig",
|
||||
"BudgetExceededError",
|
||||
"CustomToolUseEvent",
|
||||
"DoomLoopDetector",
|
||||
"ErrorContextProtocol",
|
||||
"EscalationPipeline",
|
||||
"EscalationStage",
|
||||
"EventType",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffError",
|
||||
@@ -583,12 +592,17 @@
|
||||
"LearnManagerProtocol",
|
||||
"LearnProtocol",
|
||||
"ManagedBackendProtocol",
|
||||
"ManagedEvent",
|
||||
"NetworkError",
|
||||
"ObservabilityEventType",
|
||||
"ObservabilityHooks",
|
||||
"PraisonAIError",
|
||||
"SessionErrorEvent",
|
||||
"SessionIdleEvent",
|
||||
"StopReason",
|
||||
"StructuredFormatter",
|
||||
"ToolExecutionError",
|
||||
"ToolUseEvent",
|
||||
"ValidationError",
|
||||
"config",
|
||||
"configure_structured_logging",
|
||||
@@ -3930,6 +3944,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agents"
|
||||
},
|
||||
{
|
||||
"feature": "AgentMessageEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentOSConfig",
|
||||
"python": true,
|
||||
@@ -4254,6 +4277,15 @@
|
||||
"status": "DONE",
|
||||
"category": "trace"
|
||||
},
|
||||
{
|
||||
"feature": "CustomToolUseEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "DefaultsConfig",
|
||||
"python": true,
|
||||
@@ -4333,7 +4365,7 @@
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "gateway"
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ExecutionConfig",
|
||||
@@ -4695,6 +4727,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ManagedEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "MemoryBackend",
|
||||
"python": true,
|
||||
@@ -5046,6 +5087,24 @@
|
||||
"status": "DONE",
|
||||
"category": "gateway"
|
||||
},
|
||||
{
|
||||
"feature": "SessionErrorEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "SessionIdleEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "SkillsConfig",
|
||||
"python": true,
|
||||
@@ -5055,6 +5114,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "StopReason",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "StructuredFormatter",
|
||||
"python": true,
|
||||
@@ -5109,6 +5177,15 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "ToolUseEvent",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "TraceSink",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-11
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-13
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 321 |
|
||||
| Python Core Features | 328 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **29** |
|
||||
| **Gap Count** | **36** |
|
||||
| P0 (Critical) | 1 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 28 |
|
||||
| P3 (Low) | 35 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -147,15 +147,17 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (179 done, 28 todo)
|
||||
### P3_Advanced (179 done, 35 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BotOSConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CustomToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
|
||||
@@ -170,12 +172,17 @@
|
||||
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ManagedBackendProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ManagedEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `NetworkError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PraisonAIError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `SessionErrorEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SessionIdleEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `StopReason` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
|
||||
@@ -454,10 +461,10 @@ from praisonaiagents import FlowDisplay, track_workflow
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>gateway</strong> (8 exports)</summary>
|
||||
<summary><strong>gateway</strong> (7 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import EventType, GatewayClientProtocol, GatewayConfig, GatewayEvent, GatewayMessage, GatewayProtocol, GatewaySessionProtocol, SessionConfig
|
||||
from praisonaiagents import GatewayClientProtocol, GatewayConfig, GatewayEvent, GatewayMessage, GatewayProtocol, GatewaySessionProtocol, SessionConfig
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -517,10 +524,10 @@ from praisonaiagents import Memory
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>other</strong> (34 exports)</summary>
|
||||
<summary><strong>other</strong> (42 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage, HandoffCycleError, HandoffDepthError...
|
||||
from praisonaiagents import AgentMessageEvent, Agents, AsyncLearnProtocol, AutonomyConfig, BudgetExceededError, CustomToolUseEvent, DoomLoopDetector, ErrorContextProtocol, EscalationPipeline, EscalationStage...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.2.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.4.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.4.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.2 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.4 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.6.2"
|
||||
__version__ = "4.6.4"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.2",
|
||||
"praisonaiagents>=1.6.4",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
Generated
+1
-1
@@ -5155,7 +5155,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.2"
|
||||
version = "1.6.4"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Reference in new issue
Block a user