diff --git a/.env.template b/.env.template index 3596eb4..742df15 100644 --- a/.env.template +++ b/.env.template @@ -1,15 +1,22 @@ # Common settings -PROJECT_NAME=App +PROJECT_NAME=Syndarix VERSION=1.0.0 # Database settings POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres -POSTGRES_DB=app +POSTGRES_DB=syndarix POSTGRES_HOST=db POSTGRES_PORT=5432 DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} +# Redis settings (cache, pub/sub, Celery broker) +REDIS_URL=redis://redis:6379/0 + +# Celery settings (optional - defaults to REDIS_URL if not set) +# CELERY_BROKER_URL=redis://redis:6379/0 +# CELERY_RESULT_BACKEND=redis://redis:6379/0 + # Backend settings BACKEND_PORT=8000 # CRITICAL: Generate a secure SECRET_KEY for production! diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 06d445c..3264771 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -333,6 +333,13 @@ jobs: # Run with explicit security rules only uv run ruff check app --select=S --ignore=S101,S104,S105,S106,S603,S607 + - name: Run pip-audit for dependency vulnerabilities + working-directory: backend + run: | + # pip-audit checks for known vulnerabilities in Python dependencies + uv run pip-audit --require-hashes --disable-pip -r <(uv pip compile pyproject.toml) || true + # Note: Using || true temporarily while setting up proper remediation + - name: Check for secrets in code run: | # Basic check for common secret patterns @@ -347,9 +354,107 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Run npm audit working-directory: frontend run: | npm audit --audit-level=high || true # Note: Using || true to not fail on moderate vulnerabilities # In production, consider stricter settings + + # =========================================================================== + # E2E TEST JOB - Run end-to-end tests with Playwright + # =========================================================================== + e2e-tests: + name: E2E Tests + runs-on: ubuntu-latest + needs: [lint, test] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.event_name == 'pull_request' + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: syndarix_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: ${{ env.UV_VERSION }} + + - name: Install backend dependencies + working-directory: backend + run: uv sync --extra dev --frozen + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Start backend server + working-directory: backend + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/syndarix_test + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY: test-secret-key-for-e2e-tests-only + ENVIRONMENT: test + IS_TEST: "True" + run: | + # Run migrations + uv run python -c "from app.database import create_tables; import asyncio; asyncio.run(create_tables())" || true + # Start backend in background + uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 & + # Wait for backend to be ready + sleep 10 + + - name: Run Playwright E2E tests + working-directory: frontend + env: + NEXT_PUBLIC_API_URL: http://localhost:8000 + run: | + npm run build + npm run test:e2e -- --project=chromium + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 diff --git a/backend/app/alembic/versions/0001_initial_models.py b/backend/app/alembic/versions/0001_initial_models.py index 86b7eda..d93341c 100644 --- a/backend/app/alembic/versions/0001_initial_models.py +++ b/backend/app/alembic/versions/0001_initial_models.py @@ -1,21 +1,21 @@ """initial models Revision ID: 0001 -Revises: +Revises: Create Date: 2025-11-27 09:08:09.464506 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = '0001' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index fe1f87a..ef89913 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -64,6 +64,12 @@ celery_app.conf.update( result_expires=86400, # Broker connection retry broker_connection_retry_on_startup=True, + # Retry configuration per ADR-003 (built-in retry with backoff) + task_autoretry_for=(Exception,), # Retry on all exceptions + task_retry_kwargs={"max_retries": 3, "countdown": 5}, # Initial 5s delay + task_retry_backoff=True, # Enable exponential backoff + task_retry_backoff_max=600, # Max 10 minutes between retries + task_retry_jitter=True, # Add jitter to prevent thundering herd # Beat schedule for periodic tasks beat_schedule={ # Cost aggregation every hour per ADR-012 diff --git a/backend/app/crud/syndarix/agent_instance.py b/backend/app/crud/syndarix/agent_instance.py index 716c7a3..911182c 100644 --- a/backend/app/crud/syndarix/agent_instance.py +++ b/backend/app/crud/syndarix/agent_instance.py @@ -31,6 +31,7 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan db_obj = AgentInstance( agent_type_id=obj_in.agent_type_id, project_id=obj_in.project_id, + name=obj_in.name, status=obj_in.status, current_task=obj_in.current_task, short_term_memory=obj_in.short_term_memory, diff --git a/backend/app/crud/syndarix/issue.py b/backend/app/crud/syndarix/issue.py index 2453cbf..248a25e 100644 --- a/backend/app/crud/syndarix/issue.py +++ b/backend/app/crud/syndarix/issue.py @@ -36,10 +36,10 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]): human_assignee=obj_in.human_assignee, sprint_id=obj_in.sprint_id, story_points=obj_in.story_points, - external_tracker=obj_in.external_tracker, - external_id=obj_in.external_id, - external_url=obj_in.external_url, - external_number=obj_in.external_number, + external_tracker_type=obj_in.external_tracker_type, + external_issue_id=obj_in.external_issue_id, + remote_url=obj_in.remote_url, + external_issue_number=obj_in.external_issue_number, sync_status=SyncStatus.SYNCED, ) db.add(db_obj) @@ -389,21 +389,21 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]): self, db: AsyncSession, *, - external_tracker: str, - external_id: str, + external_tracker_type: str, + external_issue_id: str, ) -> Issue | None: """Get an issue by its external tracker ID.""" try: result = await db.execute( select(Issue).where( - Issue.external_tracker == external_tracker, - Issue.external_id == external_id, + Issue.external_tracker_type == external_tracker_type, + Issue.external_issue_id == external_issue_id, ) ) return result.scalar_one_or_none() except Exception as e: logger.error( - f"Error getting issue by external ID {external_tracker}:{external_id}: {e!s}", + f"Error getting issue by external ID {external_tracker_type}:{external_issue_id}: {e!s}", exc_info=True, ) raise @@ -418,7 +418,7 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]): """Get issues that need to be synced with external tracker.""" try: query = select(Issue).where( - Issue.external_tracker.isnot(None), + Issue.external_tracker_type.isnot(None), Issue.sync_status.in_([SyncStatus.PENDING, SyncStatus.ERROR]), ) diff --git a/backend/app/crud/syndarix/sprint.py b/backend/app/crud/syndarix/sprint.py index e126225..a43c373 100644 --- a/backend/app/crud/syndarix/sprint.py +++ b/backend/app/crud/syndarix/sprint.py @@ -34,7 +34,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]): end_date=obj_in.end_date, status=obj_in.status, planned_points=obj_in.planned_points, - completed_points=obj_in.completed_points, + velocity=obj_in.velocity, ) db.add(db_obj) await db.commit() @@ -246,14 +246,14 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]): sprint.status = SprintStatus.COMPLETED - # Calculate completed points from closed issues + # Calculate velocity (completed points) from closed issues points_result = await db.execute( select(func.sum(Issue.story_points)).where( Issue.sprint_id == sprint_id, Issue.status == IssueStatus.CLOSED, ) ) - sprint.completed_points = points_result.scalar_one_or_none() or 0 + sprint.velocity = points_result.scalar_one_or_none() or 0 await db.commit() await db.refresh(sprint) @@ -317,16 +317,16 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]): velocity_data = [] for sprint in reversed(sprints): # Return in chronological order - velocity = None + velocity_ratio = None if sprint.planned_points and sprint.planned_points > 0: - velocity = (sprint.completed_points or 0) / sprint.planned_points + velocity_ratio = (sprint.velocity or 0) / sprint.planned_points velocity_data.append( { "sprint_number": sprint.number, "sprint_name": sprint.name, "planned_points": sprint.planned_points, - "completed_points": sprint.completed_points, - "velocity": velocity, + "velocity": sprint.velocity, + "velocity_ratio": velocity_ratio, } ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 406e976..7a1ea5d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,11 +18,6 @@ from .oauth_provider_token import OAuthConsent, OAuthProviderRefreshToken from .oauth_state import OAuthState from .organization import Organization -# Import models -from .user import User -from .user_organization import OrganizationRole, UserOrganization -from .user_session import UserSession - # Syndarix domain models from .syndarix import ( AgentInstance, @@ -32,8 +27,17 @@ from .syndarix import ( Sprint, ) +# Import models +from .user import User +from .user_organization import OrganizationRole, UserOrganization +from .user_session import UserSession + __all__ = [ + # Syndarix models + "AgentInstance", + "AgentType", "Base", + "Issue", "OAuthAccount", "OAuthAuthorizationCode", "OAuthClient", @@ -42,15 +46,11 @@ __all__ = [ "OAuthState", "Organization", "OrganizationRole", + "Project", + "Sprint", "TimestampMixin", "UUIDMixin", "User", "UserOrganization", "UserSession", - # Syndarix models - "AgentInstance", - "AgentType", - "Issue", - "Project", - "Sprint", ] diff --git a/backend/app/models/syndarix/__init__.py b/backend/app/models/syndarix/__init__.py index d447edf..a3481dd 100644 --- a/backend/app/models/syndarix/__init__.py +++ b/backend/app/models/syndarix/__init__.py @@ -15,8 +15,11 @@ from .agent_type import AgentType from .enums import ( AgentStatus, AutonomyLevel, + ClientMode, IssuePriority, IssueStatus, + IssueType, + ProjectComplexity, ProjectStatus, SprintStatus, SyncStatus, @@ -30,10 +33,13 @@ __all__ = [ "AgentStatus", "AgentType", "AutonomyLevel", + "ClientMode", "Issue", "IssuePriority", "IssueStatus", + "IssueType", "Project", + "ProjectComplexity", "ProjectStatus", "Sprint", "SprintStatus", diff --git a/backend/app/models/syndarix/agent_instance.py b/backend/app/models/syndarix/agent_instance.py index ad437d8..7db6d14 100644 --- a/backend/app/models/syndarix/agent_instance.py +++ b/backend/app/models/syndarix/agent_instance.py @@ -57,6 +57,9 @@ class AgentInstance(Base, UUIDMixin, TimestampMixin): index=True, ) + # Agent instance name (e.g., "Dave", "Eve") for personality + name = Column(String(100), nullable=False, index=True) + # Status tracking status: Column[AgentStatus] = Column( Enum(AgentStatus), @@ -103,6 +106,6 @@ class AgentInstance(Base, UUIDMixin, TimestampMixin): def __repr__(self) -> str: return ( - f"" ) diff --git a/backend/app/models/syndarix/enums.py b/backend/app/models/syndarix/enums.py index d0aa331..fa09e84 100644 --- a/backend/app/models/syndarix/enums.py +++ b/backend/app/models/syndarix/enums.py @@ -23,6 +23,34 @@ class AutonomyLevel(str, PyEnum): AUTONOMOUS = "autonomous" +class ProjectComplexity(str, PyEnum): + """ + Project complexity level for estimation and planning. + + SCRIPT: Simple automation or script-level work + SIMPLE: Straightforward feature or fix + MEDIUM: Standard complexity with some architectural considerations + COMPLEX: Large-scale feature requiring significant design work + """ + + SCRIPT = "script" + SIMPLE = "simple" + MEDIUM = "medium" + COMPLEX = "complex" + + +class ClientMode(str, PyEnum): + """ + How the client prefers to interact with agents. + + TECHNICAL: Client is technical and prefers detailed updates + AUTO: Agents automatically determine communication level + """ + + TECHNICAL = "technical" + AUTO = "auto" + + class ProjectStatus(str, PyEnum): """ Project lifecycle status. @@ -57,6 +85,22 @@ class AgentStatus(str, PyEnum): TERMINATED = "terminated" +class IssueType(str, PyEnum): + """ + Issue type for categorization and hierarchy. + + EPIC: Large feature or body of work containing stories + STORY: User-facing feature or requirement + TASK: Technical work item + BUG: Defect or issue to be fixed + """ + + EPIC = "epic" + STORY = "story" + TASK = "task" + BUG = "bug" + + class IssueStatus(str, PyEnum): """ Issue workflow status. @@ -113,11 +157,13 @@ class SprintStatus(str, PyEnum): PLANNED: Sprint has been created but not started ACTIVE: Sprint is currently in progress + IN_REVIEW: Sprint work is done, demo/review pending COMPLETED: Sprint has been finished successfully CANCELLED: Sprint was cancelled before completion """ PLANNED = "planned" ACTIVE = "active" + IN_REVIEW = "in_review" COMPLETED = "completed" CANCELLED = "cancelled" diff --git a/backend/app/models/syndarix/issue.py b/backend/app/models/syndarix/issue.py index 35fd220..738f9f1 100644 --- a/backend/app/models/syndarix/issue.py +++ b/backend/app/models/syndarix/issue.py @@ -6,7 +6,17 @@ An Issue represents a unit of work that can be assigned to agents or humans, with optional synchronization to external issue trackers (Gitea, GitHub, GitLab). """ -from sqlalchemy import Column, DateTime, Enum, ForeignKey, Index, Integer, String, Text +from sqlalchemy import ( + Column, + Date, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + Text, +) from sqlalchemy.dialects.postgresql import ( JSONB, UUID as PGUUID, @@ -15,7 +25,7 @@ from sqlalchemy.orm import relationship from app.models.base import Base, TimestampMixin, UUIDMixin -from .enums import IssuePriority, IssueStatus, SyncStatus +from .enums import IssuePriority, IssueStatus, IssueType, SyncStatus class Issue(Base, UUIDMixin, TimestampMixin): @@ -39,6 +49,29 @@ class Issue(Base, UUIDMixin, TimestampMixin): index=True, ) + # Parent issue for hierarchy (Epic -> Story -> Task) + parent_id = Column( + PGUUID(as_uuid=True), + ForeignKey("issues.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + + # Issue type (Epic, Story, Task, Bug) + type: Column[IssueType] = Column( + Enum(IssueType), + default=IssueType.TASK, + nullable=False, + index=True, + ) + + # Reporter (who created this issue - can be user or agent) + reporter_id = Column( + PGUUID(as_uuid=True), + nullable=True, # System-generated issues may have no reporter + index=True, + ) + # Issue content title = Column(String(500), nullable=False) body = Column(Text, nullable=False, default="") @@ -83,16 +116,19 @@ class Issue(Base, UUIDMixin, TimestampMixin): # Story points for estimation story_points = Column(Integer, nullable=True) + # Due date for the issue + due_date = Column(Date, nullable=True, index=True) + # External tracker integration - external_tracker = Column( + external_tracker_type = Column( String(50), nullable=True, index=True, ) # 'gitea', 'github', 'gitlab' - external_id = Column(String(255), nullable=True) # External system's ID - external_url = Column(String(1000), nullable=True) # Link to external issue - external_number = Column(Integer, nullable=True) # Issue number (e.g., #123) + external_issue_id = Column(String(255), nullable=True) # External system's ID + remote_url = Column(String(1000), nullable=True) # Link to external issue + external_issue_number = Column(Integer, nullable=True) # Issue number (e.g., #123) # Sync status with external tracker sync_status: Column[SyncStatus] = Column( @@ -116,14 +152,17 @@ class Issue(Base, UUIDMixin, TimestampMixin): foreign_keys=[assigned_agent_id], ) sprint = relationship("Sprint", back_populates="issues") + parent = relationship("Issue", remote_side="Issue.id", backref="children") __table_args__ = ( Index("ix_issues_project_status", "project_id", "status"), Index("ix_issues_project_priority", "project_id", "priority"), Index("ix_issues_project_sprint", "project_id", "sprint_id"), - Index("ix_issues_external_tracker_id", "external_tracker", "external_id"), + Index("ix_issues_external_tracker_id", "external_tracker_type", "external_issue_id"), Index("ix_issues_sync_status", "sync_status"), Index("ix_issues_project_agent", "project_id", "assigned_agent_id"), + Index("ix_issues_project_type", "project_id", "type"), + Index("ix_issues_project_status_priority", "project_id", "status", "priority"), ) def __repr__(self) -> str: diff --git a/backend/app/models/syndarix/project.py b/backend/app/models/syndarix/project.py index f848ee1..974d112 100644 --- a/backend/app/models/syndarix/project.py +++ b/backend/app/models/syndarix/project.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import relationship from app.models.base import Base, TimestampMixin, UUIDMixin -from .enums import AutonomyLevel, ProjectStatus +from .enums import AutonomyLevel, ClientMode, ProjectComplexity, ProjectStatus class Project(Base, UUIDMixin, TimestampMixin): @@ -48,6 +48,20 @@ class Project(Base, UUIDMixin, TimestampMixin): index=True, ) + complexity: Column[ProjectComplexity] = Column( + Enum(ProjectComplexity), + default=ProjectComplexity.MEDIUM, + nullable=False, + index=True, + ) + + client_mode: Column[ClientMode] = Column( + Enum(ClientMode), + default=ClientMode.AUTO, + nullable=False, + index=True, + ) + # JSON field for flexible project configuration # Can include: mcp_servers, webhook_urls, notification_settings, etc. settings = Column(JSONB, default=dict, nullable=False) @@ -82,6 +96,7 @@ class Project(Base, UUIDMixin, TimestampMixin): Index("ix_projects_slug_status", "slug", "status"), Index("ix_projects_owner_status", "owner_id", "status"), Index("ix_projects_autonomy_status", "autonomy_level", "status"), + Index("ix_projects_complexity_status", "complexity", "status"), ) def __repr__(self) -> str: diff --git a/backend/app/models/syndarix/sprint.py b/backend/app/models/syndarix/sprint.py index 7dcf073..bdf0794 100644 --- a/backend/app/models/syndarix/sprint.py +++ b/backend/app/models/syndarix/sprint.py @@ -55,7 +55,7 @@ class Sprint(Base, UUIDMixin, TimestampMixin): # Progress metrics planned_points = Column(Integer, nullable=True) # Sum of story points at start - completed_points = Column(Integer, nullable=True) # Sum of completed story points + velocity = Column(Integer, nullable=True) # Sum of completed story points # Relationships project = relationship("Project", back_populates="sprints") diff --git a/backend/app/schemas/syndarix/agent_instance.py b/backend/app/schemas/syndarix/agent_instance.py index 1d7fc3b..ebe66f5 100644 --- a/backend/app/schemas/syndarix/agent_instance.py +++ b/backend/app/schemas/syndarix/agent_instance.py @@ -30,6 +30,7 @@ class AgentInstanceCreate(BaseModel): agent_type_id: UUID project_id: UUID + name: str = Field(..., min_length=1, max_length=100) status: AgentStatus = AgentStatus.IDLE current_task: str | None = None short_term_memory: dict[str, Any] = Field(default_factory=dict) @@ -78,6 +79,7 @@ class AgentInstanceResponse(BaseModel): id: UUID agent_type_id: UUID project_id: UUID + name: str status: AgentStatus current_task: str | None = None short_term_memory: dict[str, Any] = Field(default_factory=dict) diff --git a/backend/app/schemas/syndarix/issue.py b/backend/app/schemas/syndarix/issue.py index 576f8cf..2e14ea7 100644 --- a/backend/app/schemas/syndarix/issue.py +++ b/backend/app/schemas/syndarix/issue.py @@ -46,10 +46,10 @@ class IssueCreate(IssueBase): sprint_id: UUID | None = None # External tracker fields (optional, for importing from external systems) - external_tracker: Literal["gitea", "github", "gitlab"] | None = None - external_id: str | None = Field(None, max_length=255) - external_url: str | None = Field(None, max_length=1000) - external_number: int | None = None + external_tracker_type: Literal["gitea", "github", "gitlab"] | None = None + external_issue_id: str | None = Field(None, max_length=255) + remote_url: str | None = Field(None, max_length=1000) + external_issue_number: int | None = None class IssueUpdate(BaseModel): @@ -121,10 +121,10 @@ class IssueInDB(IssueBase): assigned_agent_id: UUID | None = None human_assignee: str | None = None sprint_id: UUID | None = None - external_tracker: str | None = None - external_id: str | None = None - external_url: str | None = None - external_number: int | None = None + external_tracker_type: str | None = None + external_issue_id: str | None = None + remote_url: str | None = None + external_issue_number: int | None = None sync_status: SyncStatus = SyncStatus.SYNCED last_synced_at: datetime | None = None external_updated_at: datetime | None = None @@ -149,10 +149,10 @@ class IssueResponse(BaseModel): human_assignee: str | None = None sprint_id: UUID | None = None story_points: int | None = None - external_tracker: str | None = None - external_id: str | None = None - external_url: str | None = None - external_number: int | None = None + external_tracker_type: str | None = None + external_issue_id: str | None = None + remote_url: str | None = None + external_issue_number: int | None = None sync_status: SyncStatus = SyncStatus.SYNCED last_synced_at: datetime | None = None external_updated_at: datetime | None = None diff --git a/backend/app/schemas/syndarix/sprint.py b/backend/app/schemas/syndarix/sprint.py index 35f4799..01bfc61 100644 --- a/backend/app/schemas/syndarix/sprint.py +++ b/backend/app/schemas/syndarix/sprint.py @@ -21,7 +21,7 @@ class SprintBase(BaseModel): end_date: date status: SprintStatus = SprintStatus.PLANNED planned_points: int | None = Field(None, ge=0) - completed_points: int | None = Field(None, ge=0) + velocity: int | None = Field(None, ge=0) @field_validator("name") @classmethod @@ -54,7 +54,7 @@ class SprintUpdate(BaseModel): end_date: date | None = None status: SprintStatus | None = None planned_points: int | None = Field(None, ge=0) - completed_points: int | None = Field(None, ge=0) + velocity: int | None = Field(None, ge=0) @field_validator("name") @classmethod @@ -74,7 +74,7 @@ class SprintStart(BaseModel): class SprintComplete(BaseModel): """Schema for completing a sprint.""" - completed_points: int | None = Field(None, ge=0) + velocity: int | None = Field(None, ge=0) notes: str | None = None @@ -123,8 +123,8 @@ class SprintVelocity(BaseModel): sprint_number: int sprint_name: str planned_points: int | None - completed_points: int | None - velocity: float | None # completed/planned ratio + velocity: int | None # Sum of completed story points + velocity_ratio: float | None # velocity/planned ratio class SprintBurndown(BaseModel): diff --git a/backend/app/services/event_bus.py b/backend/app/services/event_bus.py index 65fdec6..27bff16 100644 --- a/backend/app/services/event_bus.py +++ b/backend/app/services/event_bus.py @@ -81,7 +81,7 @@ class EventBus: This class provides: - Event publishing to project/agent-specific channels - Subscription management for SSE endpoints - - Reconnection support via event IDs and sequence numbers + - Reconnection support via event IDs (Last-Event-ID) - Keepalive messages for connection health - Type-safe event creation with the Event schema @@ -108,7 +108,6 @@ class EventBus: self._redis_client: redis.Redis | None = None self._pubsub: redis.client.PubSub | None = None self._connected = False - self._sequence_counters: dict[str, int] = {} @property def redis_client(self) -> redis.Redis: @@ -239,12 +238,6 @@ class EventBus: """ return f"{self.USER_CHANNEL_PREFIX}:{user_id}" - def _get_next_sequence(self, channel: str) -> int: - """Get the next sequence number for a channel's events.""" - current = self._sequence_counters.get(channel, 0) - self._sequence_counters[channel] = current + 1 - return current + 1 - @staticmethod def create_event( event_type: EventType, diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 78f18c8..ba5b34b 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,6 +1,5 @@ #!/bin/bash set -e -echo "Starting Backend" # Ensure the project's virtualenv binaries are on PATH so commands like # 'uvicorn' work even when not prefixed by 'uv run'. This matches how uv @@ -9,14 +8,23 @@ if [ -d "/app/.venv/bin" ]; then export PATH="/app/.venv/bin:$PATH" fi -# Apply database migrations -# Avoid installing the project in editable mode (which tries to write egg-info) -# when running inside a bind-mounted volume with restricted permissions. -# See: https://github.com/astral-sh/uv (use --no-project to skip project build) -uv run --no-project alembic upgrade head +# Only the backend service should run migrations and init_db +# Celery workers should skip this to avoid race conditions +# Check if the first argument contains 'celery' - if so, skip migrations +if [[ "$1" == *"celery"* ]]; then + echo "Starting Celery worker (skipping migrations)" +else + echo "Starting Backend" -# Initialize database (creates first superuser if needed) -uv run --no-project python app/init_db.py + # Apply database migrations + # Avoid installing the project in editable mode (which tries to write egg-info) + # when running inside a bind-mounted volume with restricted permissions. + # See: https://github.com/astral-sh/uv (use --no-project to skip project build) + uv run --no-project alembic upgrade head + + # Initialize database (creates first superuser if needed) + uv run --no-project python app/init_db.py +fi # Execute the command passed to docker run exec "$@" \ No newline at end of file diff --git a/backend/migrate.py b/backend/migrate.py index b6b5dc5..da21a02 100755 --- a/backend/migrate.py +++ b/backend/migrate.py @@ -306,7 +306,7 @@ def show_next_rev_id(): """Show the next sequential revision ID.""" next_id = get_next_rev_id() print(f"Next revision ID: {next_id}") - print(f"\nUsage:") + print("\nUsage:") print(f" python migrate.py --local generate 'your_message' --rev-id {next_id}") print(f" python migrate.py --local auto 'your_message' --rev-id {next_id}") return next_id @@ -416,7 +416,7 @@ def main(): if args.command == 'auto' and offline: generate_migration(args.message, rev_id=args.rev_id, offline=True) print("\nOffline migration generated. Apply it later with:") - print(f" python migrate.py --local apply") + print(" python migrate.py --local apply") return # Setup database URL (must be done before importing settings elsewhere) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 05f9291..54a06d1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -252,6 +252,22 @@ ignore_missing_imports = true module = "authlib.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "celery.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "redis.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "sse_starlette.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "httpx.*" +ignore_missing_imports = true + # SQLAlchemy ORM models - Column descriptors cause type confusion [[tool.mypy.overrides]] module = "app.models.*" @@ -282,11 +298,38 @@ disable_error_code = ["arg-type"] module = "app.services.auth_service" disable_error_code = ["assignment", "arg-type"] +# OAuth services - SQLAlchemy Column issues and unused type:ignore from library evolution +[[tool.mypy.overrides]] +module = "app.services.oauth_provider_service" +disable_error_code = ["assignment", "arg-type", "attr-defined", "unused-ignore"] + +[[tool.mypy.overrides]] +module = "app.services.oauth_service" +disable_error_code = ["assignment", "arg-type", "attr-defined"] + # Test utils - Testing patterns [[tool.mypy.overrides]] module = "app.utils.auth_test_utils" disable_error_code = ["assignment", "arg-type"] +# Test dependencies - ignore missing stubs +[[tool.mypy.overrides]] +module = "pytest_asyncio.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "schemathesis.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "testcontainers.*" +ignore_missing_imports = true + +# Tests directory - relax type checking for test code +[[tool.mypy.overrides]] +module = "tests.*" +disable_error_code = ["arg-type", "union-attr", "return-value", "call-arg", "unused-ignore", "assignment", "var-annotated", "operator"] + # ============================================================================ # Pydantic mypy plugin configuration # ============================================================================ diff --git a/backend/tests/api/routes/test_events.py b/backend/tests/api/routes/test_events.py index 163e179..d2f8e37 100644 --- a/backend/tests/api/routes/test_events.py +++ b/backend/tests/api/routes/test_events.py @@ -374,33 +374,6 @@ class TestEventBusUnit: assert bus.get_agent_channel(agent_id) == f"agent:{agent_id}" assert bus.get_user_channel(user_id) == f"user:{user_id}" - @pytest.mark.asyncio - async def test_event_bus_sequence_counter(self): - """Test sequence counter increments.""" - bus = EventBus() - channel = "test-channel" - - seq1 = bus._get_next_sequence(channel) - seq2 = bus._get_next_sequence(channel) - seq3 = bus._get_next_sequence(channel) - - assert seq1 == 1 - assert seq2 == 2 - assert seq3 == 3 - - @pytest.mark.asyncio - async def test_event_bus_sequence_per_channel(self): - """Test sequence counter is per-channel.""" - bus = EventBus() - - seq1 = bus._get_next_sequence("channel-1") - seq2 = bus._get_next_sequence("channel-2") - seq3 = bus._get_next_sequence("channel-1") - - assert seq1 == 1 - assert seq2 == 1 # Different channel starts at 1 - assert seq3 == 2 - def test_event_bus_create_event(self): """Test EventBus.create_event factory method.""" project_id = uuid.uuid4() diff --git a/backend/tests/crud/syndarix/conftest.py b/backend/tests/crud/syndarix/conftest.py index 7863336..f3a87a7 100644 --- a/backend/tests/crud/syndarix/conftest.py +++ b/backend/tests/crud/syndarix/conftest.py @@ -22,15 +22,11 @@ from app.models.syndarix import ( ProjectStatus, Sprint, SprintStatus, - SyncStatus, ) from app.models.user import User from app.schemas.syndarix import ( - AgentInstanceCreate, AgentTypeCreate, - IssueCreate, ProjectCreate, - SprintCreate, ) @@ -77,7 +73,7 @@ def sprint_create_data(): "end_date": today + timedelta(days=14), "status": SprintStatus.PLANNED, "planned_points": 21, - "completed_points": 0, + "velocity": 0, } @@ -171,6 +167,7 @@ async def test_agent_instance_crud(async_test_db, test_project_crud, test_agent_ id=uuid.uuid4(), agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="TestAgent", status=AgentStatus.IDLE, current_task=None, short_term_memory={}, diff --git a/backend/tests/crud/syndarix/test_agent_instance_crud.py b/backend/tests/crud/syndarix/test_agent_instance_crud.py index 3d07151..24b216b 100644 --- a/backend/tests/crud/syndarix/test_agent_instance_crud.py +++ b/backend/tests/crud/syndarix/test_agent_instance_crud.py @@ -25,6 +25,7 @@ class TestAgentInstanceCreate: instance_data = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="TestBot", status=AgentStatus.IDLE, current_task=None, short_term_memory={"context": "initial"}, @@ -48,6 +49,7 @@ class TestAgentInstanceCreate: instance_data = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="MinimalBot", ) result = await agent_instance_crud.create(session, obj_in=instance_data) @@ -179,6 +181,7 @@ class TestAgentInstanceTerminate: instance_data = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="TerminateBot", status=AgentStatus.WORKING, ) created = await agent_instance_crud.create(session, obj_in=instance_data) @@ -236,6 +239,7 @@ class TestAgentInstanceMetrics: instance_data = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="MetricsBot", ) created = await agent_instance_crud.create(session, obj_in=instance_data) instance_id = created.id @@ -309,6 +313,7 @@ class TestAgentInstanceByProject: idle_instance = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="IdleBot", status=AgentStatus.IDLE, ) await agent_instance_crud.create(session, obj_in=idle_instance) @@ -316,12 +321,13 @@ class TestAgentInstanceByProject: working_instance = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name="WorkerBot", status=AgentStatus.WORKING, ) await agent_instance_crud.create(session, obj_in=working_instance) async with AsyncTestingSessionLocal() as session: - instances, total = await agent_instance_crud.get_by_project( + instances, _total = await agent_instance_crud.get_by_project( session, project_id=test_project_crud.id, status=AgentStatus.WORKING, @@ -362,6 +368,7 @@ class TestBulkTerminate: instance_data = AgentInstanceCreate( agent_type_id=test_agent_type_crud.id, project_id=test_project_crud.id, + name=f"BulkBot-{i}", status=AgentStatus.WORKING if i < 2 else AgentStatus.IDLE, ) await agent_instance_crud.create(session, obj_in=instance_data) diff --git a/backend/tests/crud/syndarix/test_agent_type_crud.py b/backend/tests/crud/syndarix/test_agent_type_crud.py index 602cb9b..2459561 100644 --- a/backend/tests/crud/syndarix/test_agent_type_crud.py +++ b/backend/tests/crud/syndarix/test_agent_type_crud.py @@ -280,7 +280,7 @@ class TestAgentTypeFilters: await agent_type_crud.create(session, obj_in=agent_type_data) async with AsyncTestingSessionLocal() as session: - page1, total = await agent_type_crud.get_multi_with_filters( + page1, _total = await agent_type_crud.get_multi_with_filters( session, skip=0, limit=2, diff --git a/backend/tests/crud/syndarix/test_issue_crud.py b/backend/tests/crud/syndarix/test_issue_crud.py index a327c82..e802434 100644 --- a/backend/tests/crud/syndarix/test_issue_crud.py +++ b/backend/tests/crud/syndarix/test_issue_crud.py @@ -50,16 +50,16 @@ class TestIssueCreate: issue_data = IssueCreate( project_id=test_project_crud.id, title="External Issue", - external_tracker="gitea", - external_id="gitea-123", - external_url="https://gitea.example.com/issues/123", - external_number=123, + external_tracker_type="gitea", + external_issue_id="gitea-123", + remote_url="https://gitea.example.com/issues/123", + external_issue_number=123, ) result = await issue_crud.create(session, obj_in=issue_data) - assert result.external_tracker == "gitea" - assert result.external_id == "gitea-123" - assert result.external_number == 123 + assert result.external_tracker_type == "gitea" + assert result.external_issue_id == "gitea-123" + assert result.external_issue_number == 123 @pytest.mark.asyncio async def test_create_issue_minimal(self, async_test_db, test_project_crud): @@ -433,8 +433,8 @@ class TestIssueSyncStatus: issue_data = IssueCreate( project_id=test_project_crud.id, title="Sync Status Issue", - external_tracker="gitea", - external_id="gitea-456", + external_tracker_type="gitea", + external_issue_id="gitea-456", ) created = await issue_crud.create(session, obj_in=issue_data) issue_id = created.id @@ -463,8 +463,8 @@ class TestIssueSyncStatus: issue_data = IssueCreate( project_id=test_project_crud.id, title="Pending Sync Issue", - external_tracker="gitea", - external_id="gitea-789", + external_tracker_type="gitea", + external_issue_id="gitea-789", ) created = await issue_crud.create(session, obj_in=issue_data) @@ -494,20 +494,20 @@ class TestIssueExternalTracker: issue_data = IssueCreate( project_id=test_project_crud.id, title="External ID Issue", - external_tracker="github", - external_id="github-unique-123", + external_tracker_type="github", + external_issue_id="github-unique-123", ) await issue_crud.create(session, obj_in=issue_data) async with AsyncTestingSessionLocal() as session: result = await issue_crud.get_by_external_id( session, - external_tracker="github", - external_id="github-unique-123", + external_tracker_type="github", + external_issue_id="github-unique-123", ) assert result is not None - assert result.external_id == "github-unique-123" + assert result.external_issue_id == "github-unique-123" @pytest.mark.asyncio async def test_get_by_external_id_not_found(self, async_test_db): @@ -517,8 +517,8 @@ class TestIssueExternalTracker: async with AsyncTestingSessionLocal() as session: result = await issue_crud.get_by_external_id( session, - external_tracker="gitea", - external_id="non-existent", + external_tracker_type="gitea", + external_issue_id="non-existent", ) assert result is None diff --git a/backend/tests/crud/syndarix/test_project_crud.py b/backend/tests/crud/syndarix/test_project_crud.py index b920fca..0b6b3e1 100644 --- a/backend/tests/crud/syndarix/test_project_crud.py +++ b/backend/tests/crud/syndarix/test_project_crud.py @@ -242,7 +242,7 @@ class TestProjectFilters: # Filter by ACTIVE status async with AsyncTestingSessionLocal() as session: - projects, total = await project_crud.get_multi_with_filters( + projects, _total = await project_crud.get_multi_with_filters( session, status=ProjectStatus.ACTIVE, ) @@ -319,7 +319,7 @@ class TestProjectFilters: _test_engine, AsyncTestingSessionLocal = async_test_db async with AsyncTestingSessionLocal() as session: - for i, name in enumerate(["Charlie", "Alice", "Bob"]): + for _i, name in enumerate(["Charlie", "Alice", "Bob"]): project_data = ProjectCreate( name=name, slug=f"sort-project-{name.lower()}", diff --git a/backend/tests/crud/syndarix/test_sprint_crud.py b/backend/tests/crud/syndarix/test_sprint_crud.py index c63ab5c..f7c7b53 100644 --- a/backend/tests/crud/syndarix/test_sprint_crud.py +++ b/backend/tests/crud/syndarix/test_sprint_crud.py @@ -482,7 +482,7 @@ class TestSprintVelocity: end_date=today - timedelta(days=14 * (i - 1)), status=SprintStatus.COMPLETED, planned_points=20, - completed_points=15 + i, + velocity=15 + i, ) await sprint_crud.create(session, obj_in=sprint_data) @@ -498,8 +498,8 @@ class TestSprintVelocity: assert "sprint_number" in data assert "sprint_name" in data assert "planned_points" in data - assert "completed_points" in data assert "velocity" in data + assert "velocity_ratio" in data class TestSprintWithIssueCounts: diff --git a/backend/tests/crud/test_base.py b/backend/tests/crud/test_base.py index e6a6b9c..c8fc48b 100644 --- a/backend/tests/crud/test_base.py +++ b/backend/tests/crud/test_base.py @@ -266,7 +266,8 @@ class TestCRUDBaseUpdate: "statement", {}, Exception("UNIQUE constraint failed") ), ): - update_data = UserUpdate(email=async_test_user.email) + # Use dict since UserUpdate doesn't allow email changes + update_data = {"email": async_test_user.email} with pytest.raises(ValueError, match="already exists"): await user_crud.update( diff --git a/backend/tests/models/syndarix/conftest.py b/backend/tests/models/syndarix/conftest.py index 8ec3187..f0d60fa 100644 --- a/backend/tests/models/syndarix/conftest.py +++ b/backend/tests/models/syndarix/conftest.py @@ -21,7 +21,6 @@ from app.models.syndarix import ( ProjectStatus, Sprint, SprintStatus, - SyncStatus, ) from app.models.user import User diff --git a/backend/tests/models/syndarix/test_agent_instance.py b/backend/tests/models/syndarix/test_agent_instance.py index 14ca60e..f07f9e0 100644 --- a/backend/tests/models/syndarix/test_agent_instance.py +++ b/backend/tests/models/syndarix/test_agent_instance.py @@ -7,8 +7,6 @@ import uuid from datetime import UTC, datetime from decimal import Decimal -import pytest - from app.models.syndarix import ( AgentInstance, AgentStatus, @@ -45,6 +43,7 @@ class TestAgentInstanceModel: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Alice", ) db_session.add(instance) db_session.commit() @@ -90,6 +89,7 @@ class TestAgentInstanceModel: id=instance_id, agent_type_id=agent_type.id, project_id=project.id, + name="Bob", status=AgentStatus.WORKING, current_task="Implementing user authentication", short_term_memory={"context": "Working on auth", "recent_files": ["auth.py"]}, @@ -132,6 +132,7 @@ class TestAgentInstanceModel: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Charlie", ) db_session.add(instance) db_session.commit() @@ -158,10 +159,12 @@ class TestAgentInstanceModel: id=instance_id, agent_type_id=agent_type.id, project_id=project.id, + name="Dave", status=AgentStatus.IDLE, ) repr_str = repr(instance) + assert "Dave" in repr_str assert str(instance_id) in repr_str assert str(agent_type.id) in repr_str assert str(project.id) in repr_str @@ -185,11 +188,12 @@ class TestAgentInstanceStatus: db_session.add(agent_type) db_session.commit() - for status in AgentStatus: + for idx, status in enumerate(AgentStatus): instance = AgentInstance( id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name=f"Agent-{idx}", status=status, ) db_session.add(instance) @@ -216,6 +220,7 @@ class TestAgentInstanceStatus: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Eve", status=AgentStatus.IDLE, ) db_session.add(instance) @@ -248,6 +253,7 @@ class TestAgentInstanceStatus: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Frank", status=AgentStatus.WORKING, current_task="Working on something", session_id="active-session", @@ -291,6 +297,7 @@ class TestAgentInstanceMetrics: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Grace", ) db_session.add(instance) db_session.commit() @@ -335,6 +342,7 @@ class TestAgentInstanceMetrics: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Henry", tokens_used=10_000_000_000, # 10 billion tokens cost_incurred=Decimal("100000.0000"), # $100,000 ) @@ -381,6 +389,7 @@ class TestAgentInstanceShortTermMemory: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Ivy", short_term_memory=memory, ) db_session.add(instance) @@ -409,6 +418,7 @@ class TestAgentInstanceShortTermMemory: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="Jack", short_term_memory={"initial": "state"}, ) db_session.add(instance) diff --git a/backend/tests/models/syndarix/test_issue.py b/backend/tests/models/syndarix/test_issue.py index d258773..4f0dc23 100644 --- a/backend/tests/models/syndarix/test_issue.py +++ b/backend/tests/models/syndarix/test_issue.py @@ -6,14 +6,13 @@ Unit tests for the Issue model. import uuid from datetime import UTC, datetime, timedelta -import pytest - from app.models.syndarix import ( AgentInstance, AgentType, Issue, IssuePriority, IssueStatus, + IssueType, Project, Sprint, SprintStatus, @@ -74,15 +73,16 @@ class TestIssueModel: project_id=project.id, title="Full Issue", body="A complete issue with all fields set", + type=IssueType.BUG, status=IssueStatus.IN_PROGRESS, priority=IssuePriority.CRITICAL, labels=["bug", "security", "urgent"], story_points=8, human_assignee="john.doe@example.com", - external_tracker="gitea", - external_id="gitea-123", - external_url="https://gitea.example.com/issues/123", - external_number=123, + external_tracker_type="gitea", + external_issue_id="gitea-123", + remote_url="https://gitea.example.com/issues/123", + external_issue_number=123, sync_status=SyncStatus.SYNCED, last_synced_at=now, external_updated_at=now, @@ -94,14 +94,15 @@ class TestIssueModel: assert retrieved.title == "Full Issue" assert retrieved.body == "A complete issue with all fields set" + assert retrieved.type == IssueType.BUG assert retrieved.status == IssueStatus.IN_PROGRESS assert retrieved.priority == IssuePriority.CRITICAL assert retrieved.labels == ["bug", "security", "urgent"] assert retrieved.story_points == 8 assert retrieved.human_assignee == "john.doe@example.com" - assert retrieved.external_tracker == "gitea" - assert retrieved.external_id == "gitea-123" - assert retrieved.external_number == 123 + assert retrieved.external_tracker_type == "gitea" + assert retrieved.external_issue_id == "gitea-123" + assert retrieved.external_issue_number == 123 assert retrieved.sync_status == SyncStatus.SYNCED def test_issue_timestamps(self, db_session): @@ -201,8 +202,8 @@ class TestIssueSyncStatus: id=uuid.uuid4(), project_id=project.id, title=f"Issue {sync_status.value}", - external_tracker="gitea", - external_id=f"ext-{sync_status.value}", + external_tracker_type="gitea", + external_issue_id=f"ext-{sync_status.value}", sync_status=sync_status, ) db_session.add(issue) @@ -280,6 +281,7 @@ class TestIssueAssignment: id=uuid.uuid4(), agent_type_id=agent_type.id, project_id=project.id, + name="TaskBot", ) db_session.add(agent_instance) db_session.commit() @@ -368,10 +370,10 @@ class TestIssueExternalTracker: id=uuid.uuid4(), project_id=project.id, title="Gitea Synced Issue", - external_tracker="gitea", - external_id="abc123xyz", - external_url="https://gitea.example.com/org/repo/issues/42", - external_number=42, + external_tracker_type="gitea", + external_issue_id="abc123xyz", + remote_url="https://gitea.example.com/org/repo/issues/42", + external_issue_number=42, sync_status=SyncStatus.SYNCED, last_synced_at=now, external_updated_at=now, @@ -380,10 +382,10 @@ class TestIssueExternalTracker: db_session.commit() retrieved = db_session.query(Issue).filter_by(title="Gitea Synced Issue").first() - assert retrieved.external_tracker == "gitea" - assert retrieved.external_id == "abc123xyz" - assert retrieved.external_number == 42 - assert "/issues/42" in retrieved.external_url + assert retrieved.external_tracker_type == "gitea" + assert retrieved.external_issue_id == "abc123xyz" + assert retrieved.external_issue_number == 42 + assert "/issues/42" in retrieved.remote_url def test_github_integration(self, db_session): """Test GitHub external tracker fields.""" @@ -395,17 +397,17 @@ class TestIssueExternalTracker: id=uuid.uuid4(), project_id=project.id, title="GitHub Synced Issue", - external_tracker="github", - external_id="gh-12345", - external_url="https://github.com/org/repo/issues/100", - external_number=100, + external_tracker_type="github", + external_issue_id="gh-12345", + remote_url="https://github.com/org/repo/issues/100", + external_issue_number=100, ) db_session.add(issue) db_session.commit() retrieved = db_session.query(Issue).filter_by(title="GitHub Synced Issue").first() - assert retrieved.external_tracker == "github" - assert retrieved.external_number == 100 + assert retrieved.external_tracker_type == "github" + assert retrieved.external_issue_number == 100 class TestIssueLifecycle: diff --git a/backend/tests/models/syndarix/test_sprint.py b/backend/tests/models/syndarix/test_sprint.py index be4914a..9916929 100644 --- a/backend/tests/models/syndarix/test_sprint.py +++ b/backend/tests/models/syndarix/test_sprint.py @@ -50,7 +50,7 @@ class TestSprintModel: assert retrieved.status == SprintStatus.PLANNED # Default assert retrieved.goal is None assert retrieved.planned_points is None - assert retrieved.completed_points is None + assert retrieved.velocity is None def test_create_sprint_with_all_fields(self, db_session): """Test creating a sprint with all optional fields.""" @@ -75,7 +75,7 @@ class TestSprintModel: end_date=today + timedelta(days=14), status=SprintStatus.ACTIVE, planned_points=34, - completed_points=21, + velocity=21, ) db_session.add(sprint) db_session.commit() @@ -87,7 +87,7 @@ class TestSprintModel: assert retrieved.goal == "Complete all authentication features" assert retrieved.status == SprintStatus.ACTIVE assert retrieved.planned_points == 34 - assert retrieved.completed_points == 21 + assert retrieved.velocity == 21 def test_sprint_timestamps(self, db_session): """Test that timestamps are automatically set.""" @@ -214,12 +214,12 @@ class TestSprintLifecycle: # Complete the sprint sprint.status = SprintStatus.COMPLETED - sprint.completed_points = 18 + sprint.velocity = 18 db_session.commit() retrieved = db_session.query(Sprint).filter_by(name="Sprint to Complete").first() assert retrieved.status == SprintStatus.COMPLETED - assert retrieved.completed_points == 18 + assert retrieved.velocity == 18 def test_cancel_sprint(self, db_session): """Test cancelling a sprint.""" @@ -338,14 +338,14 @@ class TestSprintPoints: start_date=today, end_date=today + timedelta(days=14), planned_points=0, - completed_points=0, + velocity=0, ) db_session.add(sprint) db_session.commit() retrieved = db_session.query(Sprint).filter_by(name="Zero Points Sprint").first() assert retrieved.planned_points == 0 - assert retrieved.completed_points == 0 + assert retrieved.velocity == 0 def test_sprint_velocity_calculation(self, db_session): """Test that we can calculate velocity from points.""" @@ -363,16 +363,16 @@ class TestSprintPoints: end_date=today + timedelta(days=14), status=SprintStatus.COMPLETED, planned_points=21, - completed_points=18, + velocity=18, ) db_session.add(sprint) db_session.commit() retrieved = db_session.query(Sprint).filter_by(name="Velocity Sprint").first() - # Calculate velocity - velocity = retrieved.completed_points / retrieved.planned_points - assert velocity == pytest.approx(18 / 21, rel=0.01) + # Calculate completion ratio from velocity + completion_ratio = retrieved.velocity / retrieved.planned_points + assert completion_ratio == pytest.approx(18 / 21, rel=0.01) def test_sprint_overdelivery(self, db_session): """Test sprint where completed > planned (stretch goals).""" @@ -390,13 +390,13 @@ class TestSprintPoints: end_date=today + timedelta(days=14), status=SprintStatus.COMPLETED, planned_points=20, - completed_points=25, # Completed more than planned + velocity=25, # Completed more than planned ) db_session.add(sprint) db_session.commit() retrieved = db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first() - assert retrieved.completed_points > retrieved.planned_points + assert retrieved.velocity > retrieved.planned_points class TestSprintNumber: diff --git a/backend/tests/schemas/syndarix/conftest.py b/backend/tests/schemas/syndarix/conftest.py index 3d3d742..76c5457 100644 --- a/backend/tests/schemas/syndarix/conftest.py +++ b/backend/tests/schemas/syndarix/conftest.py @@ -65,4 +65,5 @@ def valid_agent_instance_data(valid_uuid): return { "agent_type_id": valid_uuid, "project_id": valid_uuid, + "name": "TestAgent", } diff --git a/backend/tests/schemas/syndarix/test_agent_instance_schemas.py b/backend/tests/schemas/syndarix/test_agent_instance_schemas.py index 39b7b54..c663122 100644 --- a/backend/tests/schemas/syndarix/test_agent_instance_schemas.py +++ b/backend/tests/schemas/syndarix/test_agent_instance_schemas.py @@ -3,7 +3,6 @@ Tests for AgentInstance schema validation. """ -import uuid from decimal import Decimal import pytest @@ -41,6 +40,7 @@ class TestAgentInstanceCreateValidation: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="WorkingAgent", status=AgentStatus.WORKING, current_task="Processing feature request", short_term_memory={"context": "working"}, @@ -59,6 +59,7 @@ class TestAgentInstanceCreateValidation: with pytest.raises(ValidationError) as exc_info: AgentInstanceCreate( project_id=valid_uuid, + name="TestAgent", ) errors = exc_info.value.errors() @@ -69,11 +70,23 @@ class TestAgentInstanceCreateValidation: with pytest.raises(ValidationError) as exc_info: AgentInstanceCreate( agent_type_id=valid_uuid, + name="TestAgent", ) errors = exc_info.value.errors() assert any("project_id" in str(e).lower() for e in errors) + def test_agent_instance_create_name_required(self, valid_uuid): + """Test that name is required.""" + with pytest.raises(ValidationError) as exc_info: + AgentInstanceCreate( + agent_type_id=valid_uuid, + project_id=valid_uuid, + ) + + errors = exc_info.value.errors() + assert any("name" in str(e).lower() for e in errors) + class TestAgentInstanceUpdateValidation: """Tests for AgentInstanceUpdate schema validation.""" @@ -145,6 +158,7 @@ class TestAgentStatusEnum: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name=f"Agent{status.value}", status=status, ) assert instance.status == status @@ -155,6 +169,7 @@ class TestAgentStatusEnum: AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", status="invalid", # type: ignore ) @@ -167,6 +182,7 @@ class TestAgentInstanceShortTermMemory: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", short_term_memory={}, ) assert instance.short_term_memory == {} @@ -185,6 +201,7 @@ class TestAgentInstanceShortTermMemory: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="MemoryAgent", short_term_memory=memory, ) assert instance.short_term_memory == memory @@ -200,6 +217,7 @@ class TestAgentInstanceStringFields: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", long_term_memory_ref=long_ref, ) assert instance.long_term_memory_ref == long_ref @@ -212,6 +230,7 @@ class TestAgentInstanceStringFields: AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", long_term_memory_ref=too_long, ) @@ -225,6 +244,7 @@ class TestAgentInstanceStringFields: instance = AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", session_id=long_session, ) assert instance.session_id == long_session @@ -237,6 +257,7 @@ class TestAgentInstanceStringFields: AgentInstanceCreate( agent_type_id=valid_uuid, project_id=valid_uuid, + name="TestAgent", session_id=too_long, ) diff --git a/backend/tests/schemas/syndarix/test_issue_schemas.py b/backend/tests/schemas/syndarix/test_issue_schemas.py index f89a68d..b51f9d1 100644 --- a/backend/tests/schemas/syndarix/test_issue_schemas.py +++ b/backend/tests/schemas/syndarix/test_issue_schemas.py @@ -55,17 +55,17 @@ class TestIssueCreateValidation: story_points=5, assigned_agent_id=agent_id, sprint_id=sprint_id, - external_tracker="gitea", - external_id="gitea-123", - external_url="https://gitea.example.com/issues/123", - external_number=123, + external_tracker_type="gitea", + external_issue_id="gitea-123", + remote_url="https://gitea.example.com/issues/123", + external_issue_number=123, ) assert issue.status == IssueStatus.IN_PROGRESS assert issue.priority == IssuePriority.HIGH assert issue.labels == ["bug", "security"] assert issue.story_points == 5 - assert issue.external_tracker == "gitea" + assert issue.external_tracker_type == "gitea" def test_issue_create_title_empty_fails(self, valid_uuid): """Test that empty title raises ValidationError.""" @@ -188,10 +188,10 @@ class TestIssueExternalTrackerValidation: issue = IssueCreate( project_id=valid_uuid, title="Test Issue", - external_tracker=tracker, - external_id="ext-123", + external_tracker_type=tracker, + external_issue_id="ext-123", ) - assert issue.external_tracker == tracker + assert issue.external_tracker_type == tracker def test_invalid_external_tracker(self, valid_uuid): """Test that invalid external tracker raises ValidationError.""" @@ -199,8 +199,8 @@ class TestIssueExternalTrackerValidation: IssueCreate( project_id=valid_uuid, title="Test Issue", - external_tracker="invalid", # type: ignore - external_id="ext-123", + external_tracker_type="invalid", # type: ignore + external_issue_id="ext-123", ) diff --git a/backend/tests/schemas/syndarix/test_sprint_schemas.py b/backend/tests/schemas/syndarix/test_sprint_schemas.py index 1fdde30..13dfb7d 100644 --- a/backend/tests/schemas/syndarix/test_sprint_schemas.py +++ b/backend/tests/schemas/syndarix/test_sprint_schemas.py @@ -34,7 +34,7 @@ class TestSprintCreateValidation: assert sprint.status == SprintStatus.PLANNED assert sprint.goal is None assert sprint.planned_points is None - assert sprint.completed_points is None + assert sprint.velocity is None def test_sprint_create_with_all_fields(self, valid_uuid): """Test creating sprint with all optional fields.""" @@ -49,7 +49,7 @@ class TestSprintCreateValidation: end_date=today + timedelta(days=14), status=SprintStatus.PLANNED, planned_points=21, - completed_points=0, + velocity=0, ) assert sprint.name == "Full Sprint" @@ -252,8 +252,8 @@ class TestSprintPointsValidation: errors = exc_info.value.errors() assert any("planned_points" in str(e).lower() for e in errors) - def test_valid_completed_points(self, valid_uuid): - """Test valid completed_points values.""" + def test_valid_velocity(self, valid_uuid): + """Test valid velocity values.""" today = date.today() for points in [0, 5, 21]: @@ -263,26 +263,26 @@ class TestSprintPointsValidation: number=1, start_date=today, end_date=today + timedelta(days=14), - completed_points=points, + velocity=points, ) - assert sprint.completed_points == points + assert sprint.velocity == points - def test_completed_points_negative_fails(self, valid_uuid): - """Test that negative completed_points raises ValidationError.""" + def test_velocity_negative_fails(self, valid_uuid): + """Test that negative velocity raises ValidationError.""" today = date.today() with pytest.raises(ValidationError) as exc_info: SprintCreate( project_id=valid_uuid, - name="Negative Completed Sprint", + name="Negative Velocity Sprint", number=1, start_date=today, end_date=today + timedelta(days=14), - completed_points=-1, + velocity=-1, ) errors = exc_info.value.errors() - assert any("completed_points" in str(e).lower() for e in errors) + assert any("velocity" in str(e).lower() for e in errors) class TestSprintUpdateValidation: @@ -310,7 +310,7 @@ class TestSprintUpdateValidation: end_date=today + timedelta(days=21), status=SprintStatus.ACTIVE, planned_points=34, - completed_points=20, + velocity=20, ) assert update.name == "Updated Name" diff --git a/backend/tests/tasks/test_agent_tasks.py b/backend/tests/tasks/test_agent_tasks.py index d1525b8..ea571e0 100644 --- a/backend/tests/tasks/test_agent_tasks.py +++ b/backend/tests/tasks/test_agent_tasks.py @@ -12,9 +12,8 @@ Note: These tests mock actual execution since they would require LLM calls and database access in production. """ -import pytest -from unittest.mock import patch, MagicMock import uuid +from unittest.mock import patch class TestRunAgentStepTask: @@ -22,8 +21,8 @@ class TestRunAgentStepTask: def test_run_agent_step_task_exists(self): """Test that run_agent_step task is registered.""" - from app.celery_app import celery_app import app.tasks.agent # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.agent.run_agent_step" in celery_app.tasks @@ -93,8 +92,8 @@ class TestSpawnAgentTask: def test_spawn_agent_task_exists(self): """Test that spawn_agent task is registered.""" - from app.celery_app import celery_app import app.tasks.agent # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.agent.spawn_agent" in celery_app.tasks @@ -165,8 +164,8 @@ class TestTerminateAgentTask: def test_terminate_agent_task_exists(self): """Test that terminate_agent task is registered.""" - from app.celery_app import celery_app import app.tasks.agent # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.agent.terminate_agent" in celery_app.tasks @@ -236,8 +235,8 @@ class TestAgentTaskRouting: def test_run_agent_step_routing(self): """Test that run_agent_step task routes to agent queue.""" - from app.tasks.agent import run_agent_step from app.celery_app import celery_app + from app.tasks.agent import run_agent_step # Get the routing configuration for this specific task task_name = run_agent_step.name @@ -293,12 +292,13 @@ class TestAgentTaskSignatures: def test_agent_task_chain_creation(self): """Test that agent tasks can be chained together.""" from celery import chain - from app.tasks.agent import spawn_agent, run_agent_step, terminate_agent + + from app.tasks.agent import spawn_agent # Create a chain of tasks (this doesn't execute, just builds the chain) agent_type_id = str(uuid.uuid4()) project_id = str(uuid.uuid4()) - agent_instance_id = str(uuid.uuid4()) + str(uuid.uuid4()) # Note: In real usage, the chain would pass results between tasks workflow = chain( @@ -314,8 +314,8 @@ class TestAgentTaskLogging: def test_run_agent_step_logs_execution(self): """Test that run_agent_step logs when executed.""" + from app.tasks.agent import run_agent_step - import logging agent_instance_id = str(uuid.uuid4()) context = {} diff --git a/backend/tests/tasks/test_celery_config.py b/backend/tests/tasks/test_celery_config.py index 41be887..81f8ceb 100644 --- a/backend/tests/tasks/test_celery_config.py +++ b/backend/tests/tasks/test_celery_config.py @@ -9,8 +9,6 @@ These tests verify: - Beat schedule is configured for periodic tasks """ -import pytest -from unittest.mock import patch, MagicMock class TestCeleryAppConfiguration: @@ -172,10 +170,9 @@ class TestTaskDiscovery: def test_agent_tasks_are_discoverable(self): """Test that agent tasks can be discovered and accessed.""" - from app.celery_app import celery_app - # Force task registration by importing import app.tasks.agent # noqa: F401 + from app.celery_app import celery_app # Check that agent tasks are registered registered_tasks = celery_app.tasks @@ -186,10 +183,9 @@ class TestTaskDiscovery: def test_git_tasks_are_discoverable(self): """Test that git tasks can be discovered and accessed.""" - from app.celery_app import celery_app - # Force task registration by importing import app.tasks.git # noqa: F401 + from app.celery_app import celery_app registered_tasks = celery_app.tasks @@ -201,10 +197,9 @@ class TestTaskDiscovery: def test_sync_tasks_are_discoverable(self): """Test that sync tasks can be discovered and accessed.""" - from app.celery_app import celery_app - # Force task registration by importing import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app registered_tasks = celery_app.tasks @@ -216,10 +211,9 @@ class TestTaskDiscovery: def test_workflow_tasks_are_discoverable(self): """Test that workflow tasks can be discovered and accessed.""" - from app.celery_app import celery_app - # Force task registration by importing import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app registered_tasks = celery_app.tasks @@ -231,10 +225,9 @@ class TestTaskDiscovery: def test_cost_tasks_are_discoverable(self): """Test that cost tasks can be discovered and accessed.""" - from app.celery_app import celery_app - # Force task registration by importing import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app registered_tasks = celery_app.tasks diff --git a/backend/tests/tasks/test_cost_tasks.py b/backend/tests/tasks/test_cost_tasks.py index 55b75cd..fb77718 100644 --- a/backend/tests/tasks/test_cost_tasks.py +++ b/backend/tests/tasks/test_cost_tasks.py @@ -12,9 +12,8 @@ Note: These tests mock actual execution since they would require database access and Redis operations in production. """ -import pytest -from unittest.mock import patch import uuid +from unittest.mock import patch class TestAggregateDailyCostsTask: @@ -22,8 +21,8 @@ class TestAggregateDailyCostsTask: def test_aggregate_daily_costs_task_exists(self): """Test that aggregate_daily_costs task is registered.""" - from app.celery_app import celery_app import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.cost.aggregate_daily_costs" in celery_app.tasks @@ -55,8 +54,8 @@ class TestCheckBudgetThresholdsTask: def test_check_budget_thresholds_task_exists(self): """Test that check_budget_thresholds task is registered.""" - from app.celery_app import celery_app import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.cost.check_budget_thresholds" in celery_app.tasks @@ -85,8 +84,8 @@ class TestRecordLlmUsageTask: def test_record_llm_usage_task_exists(self): """Test that record_llm_usage task is registered.""" - from app.celery_app import celery_app import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.cost.record_llm_usage" in celery_app.tasks @@ -159,8 +158,8 @@ class TestGenerateCostReportTask: def test_generate_cost_report_task_exists(self): """Test that generate_cost_report task is registered.""" - from app.celery_app import celery_app import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.cost.generate_cost_report" in celery_app.tasks @@ -211,8 +210,8 @@ class TestResetDailyBudgetCountersTask: def test_reset_daily_budget_counters_task_exists(self): """Test that reset_daily_budget_counters task is registered.""" - from app.celery_app import celery_app import app.tasks.cost # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.cost.reset_daily_budget_counters" in celery_app.tasks @@ -363,7 +362,8 @@ class TestCostTaskSignatures: def test_cost_task_chain_creation(self): """Test that cost tasks can be chained together.""" from celery import chain - from app.tasks.cost import record_llm_usage, check_budget_thresholds + + from app.tasks.cost import check_budget_thresholds, record_llm_usage agent_id = str(uuid.uuid4()) project_id = str(uuid.uuid4()) diff --git a/backend/tests/tasks/test_git_tasks.py b/backend/tests/tasks/test_git_tasks.py index 3d52e09..3902725 100644 --- a/backend/tests/tasks/test_git_tasks.py +++ b/backend/tests/tasks/test_git_tasks.py @@ -12,9 +12,8 @@ Note: These tests mock actual execution since they would require Git operations and external APIs in production. """ -import pytest -from unittest.mock import patch import uuid +from unittest.mock import patch class TestCloneRepositoryTask: @@ -22,8 +21,8 @@ class TestCloneRepositoryTask: def test_clone_repository_task_exists(self): """Test that clone_repository task is registered.""" - from app.celery_app import celery_app import app.tasks.git # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.git.clone_repository" in celery_app.tasks @@ -72,8 +71,8 @@ class TestCommitChangesTask: def test_commit_changes_task_exists(self): """Test that commit_changes task is registered.""" - from app.celery_app import celery_app import app.tasks.git # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.git.commit_changes" in celery_app.tasks @@ -114,8 +113,8 @@ class TestCreateBranchTask: def test_create_branch_task_exists(self): """Test that create_branch task is registered.""" - from app.celery_app import celery_app import app.tasks.git # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.git.create_branch" in celery_app.tasks @@ -156,8 +155,8 @@ class TestCreatePullRequestTask: def test_create_pull_request_task_exists(self): """Test that create_pull_request task is registered.""" - from app.celery_app import celery_app import app.tasks.git # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.git.create_pull_request" in celery_app.tasks @@ -201,8 +200,8 @@ class TestPushChangesTask: def test_push_changes_task_exists(self): """Test that push_changes task is registered.""" - from app.celery_app import celery_app import app.tasks.git # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.git.push_changes" in celery_app.tasks @@ -254,7 +253,6 @@ class TestGitTaskRouting: def test_all_git_tasks_match_routing_pattern(self): """Test that all git task names match the routing pattern.""" - from app.tasks import git task_names = [ "app.tasks.git.clone_repository", diff --git a/backend/tests/tasks/test_sync_tasks.py b/backend/tests/tasks/test_sync_tasks.py index 00f0a11..88fe377 100644 --- a/backend/tests/tasks/test_sync_tasks.py +++ b/backend/tests/tasks/test_sync_tasks.py @@ -12,9 +12,8 @@ Note: These tests mock actual execution since they would require external API calls in production. """ -import pytest -from unittest.mock import patch import uuid +from unittest.mock import patch class TestSyncIssuesIncrementalTask: @@ -22,8 +21,8 @@ class TestSyncIssuesIncrementalTask: def test_sync_issues_incremental_task_exists(self): """Test that sync_issues_incremental task is registered.""" - from app.celery_app import celery_app import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.sync.sync_issues_incremental" in celery_app.tasks @@ -56,8 +55,8 @@ class TestSyncIssuesFullTask: def test_sync_issues_full_task_exists(self): """Test that sync_issues_full task is registered.""" - from app.celery_app import celery_app import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.sync.sync_issues_full" in celery_app.tasks @@ -90,8 +89,8 @@ class TestProcessWebhookEventTask: def test_process_webhook_event_task_exists(self): """Test that process_webhook_event task is registered.""" - from app.celery_app import celery_app import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.sync.process_webhook_event" in celery_app.tasks @@ -149,8 +148,8 @@ class TestSyncProjectIssuesTask: def test_sync_project_issues_task_exists(self): """Test that sync_project_issues task is registered.""" - from app.celery_app import celery_app import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.sync.sync_project_issues" in celery_app.tasks @@ -190,8 +189,8 @@ class TestPushIssueToExternalTask: def test_push_issue_to_external_task_exists(self): """Test that push_issue_to_external task is registered.""" - from app.celery_app import celery_app import app.tasks.sync # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.sync.push_issue_to_external" in celery_app.tasks diff --git a/backend/tests/tasks/test_workflow_tasks.py b/backend/tests/tasks/test_workflow_tasks.py index 58c5748..fb9293b 100644 --- a/backend/tests/tasks/test_workflow_tasks.py +++ b/backend/tests/tasks/test_workflow_tasks.py @@ -12,9 +12,8 @@ Note: These tests mock actual execution since they would require database access and state machine operations in production. """ -import pytest -from unittest.mock import patch import uuid +from unittest.mock import patch class TestRecoverStaleWorkflowsTask: @@ -22,8 +21,8 @@ class TestRecoverStaleWorkflowsTask: def test_recover_stale_workflows_task_exists(self): """Test that recover_stale_workflows task is registered.""" - from app.celery_app import celery_app import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.workflow.recover_stale_workflows" in celery_app.tasks @@ -59,8 +58,8 @@ class TestExecuteWorkflowStepTask: def test_execute_workflow_step_task_exists(self): """Test that execute_workflow_step task is registered.""" - from app.celery_app import celery_app import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.workflow.execute_workflow_step" in celery_app.tasks @@ -111,8 +110,8 @@ class TestHandleApprovalResponseTask: def test_handle_approval_response_task_exists(self): """Test that handle_approval_response task is registered.""" - from app.celery_app import celery_app import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.workflow.handle_approval_response" in celery_app.tasks @@ -167,8 +166,8 @@ class TestStartSprintWorkflowTask: def test_start_sprint_workflow_task_exists(self): """Test that start_sprint_workflow task is registered.""" - from app.celery_app import celery_app import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.workflow.start_sprint_workflow" in celery_app.tasks @@ -198,8 +197,8 @@ class TestStartStoryWorkflowTask: def test_start_story_workflow_task_exists(self): """Test that start_story_workflow task is registered.""" - from app.celery_app import celery_app import app.tasks.workflow # noqa: F401 + from app.celery_app import celery_app assert "app.tasks.workflow.start_story_workflow" in celery_app.tasks @@ -331,15 +330,14 @@ class TestWorkflowTaskSignatures: def test_workflow_chain_creation(self): """Test that workflow tasks can be chained together.""" from celery import chain + from app.tasks.workflow import ( start_sprint_workflow, - execute_workflow_step, - handle_approval_response, ) project_id = str(uuid.uuid4()) sprint_id = str(uuid.uuid4()) - workflow_id = str(uuid.uuid4()) + str(uuid.uuid4()) # Build a chain (doesn't execute, just creates the workflow) workflow = chain( diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 741ae66..54c808c 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -17,9 +17,10 @@ services: db: - image: postgres:17-alpine + image: pgvector/pgvector:pg17 volumes: - postgres_data:/var/lib/postgresql/data/ + # Note: Port not exposed in production for security environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -33,6 +34,20 @@ services: - app-network restart: unless-stopped + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - app-network + restart: unless-stopped + backend: # REPLACE THIS with your actual image from your container registry # Examples: @@ -48,16 +63,133 @@ services: - ENVIRONMENT=production - DEBUG=false - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - REDIS_URL=redis://redis:6379/0 depends_on: db: condition: service_healthy + redis: + condition: service_healthy networks: - app-network restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s # Uncomment if you need persistent data storage for uploads, etc. # volumes: # - ${HOST_DATA_FILES_DIR:-./data}:${DATA_FILES_DIR:-/app/data} + # Celery workers for background task processing (per ADR-003) + celery-agent: + # REPLACE THIS with your backend image + image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=agent + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "agent", "-l", "info", "-c", "4"] + deploy: + resources: + limits: + cpus: '2.0' + memory: 4G + reservations: + cpus: '0.5' + memory: 512M + + celery-git: + # REPLACE THIS with your backend image + image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=git + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "git", "-l", "info", "-c", "2"] + deploy: + resources: + limits: + cpus: '1.0' + memory: 2G + reservations: + cpus: '0.25' + memory: 256M + + celery-sync: + # REPLACE THIS with your backend image + image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=sync + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "sync", "-l", "info", "-c", "2"] + deploy: + resources: + limits: + cpus: '1.0' + memory: 2G + reservations: + cpus: '0.25' + memory: 256M + + celery-beat: + # REPLACE THIS with your backend image + image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "beat", "-l", "info"] + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.1' + memory: 128M + frontend: # REPLACE THIS with your actual image from your container registry # Examples: @@ -69,7 +201,8 @@ services: - NODE_ENV=production - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} depends_on: - - backend + backend: + condition: service_healthy networks: - app-network restart: unless-stopped @@ -92,6 +225,7 @@ services: volumes: postgres_data: + redis_data: networks: app-network: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f839132..e30fdc7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -58,6 +58,12 @@ services: condition: service_healthy redis: condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s networks: - app-network command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] @@ -171,7 +177,8 @@ services: - NODE_ENV=development - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} depends_on: - - backend + backend: + condition: service_healthy command: npm run dev networks: - app-network diff --git a/docker-compose.yml b/docker-compose.yml index dbfaa12..b2e4229 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: db: - image: postgres:17-alpine + image: pgvector/pgvector:pg17 volumes: - postgres_data:/var/lib/postgresql/data/ - ports: - - "5432:5432" + # Note: Port not exposed in production for security + # Access via internal network only environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -18,6 +18,20 @@ services: - app-network restart: unless-stopped + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - app-network + restart: unless-stopped + backend: build: context: ./backend @@ -33,12 +47,137 @@ services: - ENVIRONMENT=production - DEBUG=false - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - REDIS_URL=redis://redis:6379/0 depends_on: db: condition: service_healthy + redis: + condition: service_healthy networks: - app-network restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Celery workers for background task processing (per ADR-003) + celery-agent: + build: + context: ./backend + dockerfile: Dockerfile + target: production + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=agent + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "agent", "-l", "info", "-c", "4"] + deploy: + resources: + limits: + cpus: '2.0' + memory: 4G + reservations: + cpus: '0.5' + memory: 512M + + celery-git: + build: + context: ./backend + dockerfile: Dockerfile + target: production + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=git + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "git", "-l", "info", "-c", "2"] + deploy: + resources: + limits: + cpus: '1.0' + memory: 2G + reservations: + cpus: '0.25' + memory: 256M + + celery-sync: + build: + context: ./backend + dockerfile: Dockerfile + target: production + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + - CELERY_QUEUE=sync + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "-Q", "sync", "-l", "info", "-c", "2"] + deploy: + resources: + limits: + cpus: '1.0' + memory: 2G + reservations: + cpus: '0.25' + memory: 256M + + celery-beat: + build: + context: ./backend + dockerfile: Dockerfile + target: production + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=redis://redis:6379/0 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "beat", "-l", "info"] + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.1' + memory: 128M frontend: build: @@ -53,14 +192,16 @@ services: - NODE_ENV=production - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} depends_on: - - backend + backend: + condition: service_healthy networks: - app-network restart: unless-stopped volumes: postgres_data: + redis_data: networks: app-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d76e0ac..1a6434b 100755 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -83,6 +83,21 @@ export default [ }, }, + // Relaxed rules for prototype/design exploration pages + { + files: ['src/app/**/prototypes/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }, + ], + }, + }, + // Relaxed rules for test files { files: ['tests/**/*.{ts,tsx}', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'], diff --git a/frontend/src/app/[locale]/prototypes/activity-feed/page.tsx b/frontend/src/app/[locale]/prototypes/activity-feed/page.tsx index 8c10042..9add8cf 100644 --- a/frontend/src/app/[locale]/prototypes/activity-feed/page.tsx +++ b/frontend/src/app/[locale]/prototypes/activity-feed/page.tsx @@ -1,42 +1,23 @@ 'use client'; import { useState, useEffect } from 'react'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Activity, Bot, MessageSquare, - PlayCircle, - PauseCircle, CheckCircle2, AlertCircle, - Clock, GitPullRequest, - GitBranch, CircleDot, XCircle, Zap, - Users, - ChevronRight, Settings, Filter, Bell, @@ -94,6 +75,14 @@ const eventTypeConfig = { }, }; +// Filter state type +type FilterState = { + types: string[]; + agents: string[]; + projects: string[]; + showActionRequired: boolean; +}; + // Mock activity events const mockEvents = [ { @@ -493,18 +482,13 @@ function FilterPanel({ onFiltersChange, onClose, }: { - filters: { - types: string[]; - agents: string[]; - projects: string[]; - showActionRequired: boolean; - }; - onFiltersChange: (filters: typeof filters) => void; + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; onClose: () => void; }) { const eventTypes = Object.entries(eventTypeConfig); const agents = ['Backend Engineer', 'Frontend Engineer', 'Architect', 'Product Owner', 'QA Engineer', 'DevOps Engineer']; - const projects = ['E-Commerce Platform', 'Mobile App', 'API Gateway']; + const _projects = ['E-Commerce Platform', 'Mobile App', 'API Gateway']; const toggleType = (type: string) => { const newTypes = filters.types.includes(type) diff --git a/frontend/src/app/[locale]/prototypes/agent-configuration/page.tsx b/frontend/src/app/[locale]/prototypes/agent-configuration/page.tsx index 88b962b..4074df8 100644 --- a/frontend/src/app/[locale]/prototypes/agent-configuration/page.tsx +++ b/frontend/src/app/[locale]/prototypes/agent-configuration/page.tsx @@ -42,7 +42,6 @@ import { Zap, Code, FileText, - GitBranch, CheckCircle2, AlertTriangle, } from 'lucide-react'; @@ -866,7 +865,7 @@ function AgentTypeEditorView({ export default function AgentConfigurationPrototype() { const [view, setView] = useState('list'); - const [selectedId, setSelectedId] = useState(null); + const [_selectedId, setSelectedId] = useState(null); const [isCreating, setIsCreating] = useState(false); const handleSelectType = (id: string) => { diff --git a/frontend/src/app/[locale]/prototypes/issue-management/page.tsx b/frontend/src/app/[locale]/prototypes/issue-management/page.tsx index f768625..4fd70b9 100644 --- a/frontend/src/app/[locale]/prototypes/issue-management/page.tsx +++ b/frontend/src/app/[locale]/prototypes/issue-management/page.tsx @@ -4,7 +4,6 @@ import { useState } from 'react'; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; @@ -53,7 +52,6 @@ import { Calendar, Tag, Settings, - Download, Upload, Trash2, Edit, @@ -919,7 +917,7 @@ function IssueDetailView({ onBack }: { onBack: () => void }) { export default function IssueManagementPrototype() { const [view, setView] = useState<'list' | 'detail'>('list'); - const [selectedIssueId, setSelectedIssueId] = useState(null); + const [_selectedIssueId, setSelectedIssueId] = useState(null); const handleSelectIssue = (id: string) => { setSelectedIssueId(id); diff --git a/frontend/src/app/[locale]/prototypes/project-dashboard/page.tsx b/frontend/src/app/[locale]/prototypes/project-dashboard/page.tsx index 11d8a9d..a387553 100644 --- a/frontend/src/app/[locale]/prototypes/project-dashboard/page.tsx +++ b/frontend/src/app/[locale]/prototypes/project-dashboard/page.tsx @@ -10,7 +10,6 @@ import { } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Select, SelectContent, @@ -300,7 +299,7 @@ function ProgressBar({ value, className }: { value: number; className?: string } } export default function ProjectDashboardPrototype() { - const [selectedView, setSelectedView] = useState('overview'); + const [_selectedView, _setSelectedView] = useState('overview'); return (
diff --git a/frontend/src/lib/api/generated/sdk.gen.ts b/frontend/src/lib/api/generated/sdk.gen.ts index 5fe9b19..a0eb5e7 100644 --- a/frontend/src/lib/api/generated/sdk.gen.ts +++ b/frontend/src/lib/api/generated/sdk.gen.ts @@ -3,7 +3,7 @@ import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteOauthClientData, DeleteOauthClientErrors, DeleteOauthClientResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOauthAuthorizationUrlData, GetOauthAuthorizationUrlErrors, GetOauthAuthorizationUrlResponses, GetOauthServerMetadataData, GetOauthServerMetadataResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HandleOauthCallbackData, HandleOauthCallbackErrors, HandleOauthCallbackResponses, HealthCheckData, HealthCheckResponses, ListMyOauthConsentsData, ListMyOauthConsentsResponses, ListMySessionsData, ListMySessionsResponses, ListOauthAccountsData, ListOauthAccountsResponses, ListOauthClientsData, ListOauthClientsResponses, ListOauthProvidersData, ListOauthProvidersResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, OauthProviderAuthorizeData, OauthProviderAuthorizeErrors, OauthProviderAuthorizeResponses, OauthProviderConsentData, OauthProviderConsentErrors, OauthProviderConsentResponses, OauthProviderIntrospectData, OauthProviderIntrospectErrors, OauthProviderIntrospectResponses, OauthProviderRevokeData, OauthProviderRevokeErrors, OauthProviderRevokeResponses, OauthProviderTokenData, OauthProviderTokenErrors, OauthProviderTokenResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterOauthClientData, RegisterOauthClientErrors, RegisterOauthClientResponses, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeMyOauthConsentData, RevokeMyOauthConsentErrors, RevokeMyOauthConsentResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, StartOauthLinkData, StartOauthLinkErrors, StartOauthLinkResponses, UnlinkOauthAccountData, UnlinkOauthAccountErrors, UnlinkOauthAccountResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; +import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteOauthClientData, DeleteOauthClientErrors, DeleteOauthClientResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOauthAuthorizationUrlData, GetOauthAuthorizationUrlErrors, GetOauthAuthorizationUrlResponses, GetOauthServerMetadataData, GetOauthServerMetadataResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HandleOauthCallbackData, HandleOauthCallbackErrors, HandleOauthCallbackResponses, HealthCheckData, HealthCheckResponses, ListMyOauthConsentsData, ListMyOauthConsentsResponses, ListMySessionsData, ListMySessionsResponses, ListOauthAccountsData, ListOauthAccountsResponses, ListOauthClientsData, ListOauthClientsResponses, ListOauthProvidersData, ListOauthProvidersResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, OauthProviderAuthorizeData, OauthProviderAuthorizeErrors, OauthProviderAuthorizeResponses, OauthProviderConsentData, OauthProviderConsentErrors, OauthProviderConsentResponses, OauthProviderIntrospectData, OauthProviderIntrospectErrors, OauthProviderIntrospectResponses, OauthProviderRevokeData, OauthProviderRevokeErrors, OauthProviderRevokeResponses, OauthProviderTokenData, OauthProviderTokenErrors, OauthProviderTokenResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterOauthClientData, RegisterOauthClientErrors, RegisterOauthClientResponses, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeMyOauthConsentData, RevokeMyOauthConsentErrors, RevokeMyOauthConsentResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, SendTestEventData, SendTestEventErrors, SendTestEventResponses, StartOauthLinkData, StartOauthLinkErrors, StartOauthLinkResponses, StreamProjectEventsData, StreamProjectEventsErrors, StreamProjectEventsResponses, UnlinkOauthAccountData, UnlinkOauthAccountErrors, UnlinkOauthAccountResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; export type Options = Options2 & { /** @@ -1288,6 +1288,74 @@ export const getOrganizationMembers = (opt }); }; +/** + * Stream Project Events + * + * Stream real-time events for a project via Server-Sent Events (SSE). + * + * **Authentication**: Required (Bearer token) + * **Authorization**: Must have access to the project + * + * **SSE Event Format**: + * ``` + * event: agent.status_changed + * id: 550e8400-e29b-41d4-a716-446655440000 + * data: {"id": "...", "type": "agent.status_changed", "project_id": "...", ...} + * + * : keepalive + * + * event: issue.created + * id: 550e8400-e29b-41d4-a716-446655440001 + * data: {...} + * ``` + * + * **Reconnection**: Include the `Last-Event-ID` header with the last received + * event ID to resume from where you left off. + * + * **Keepalive**: The server sends a comment (`: keepalive`) every 30 seconds + * to keep the connection alive. + * + * **Rate Limit**: 10 connections/minute per IP + */ +export const streamProjectEvents = (options: Options) => { + return (options.client ?? client).sse.get({ + responseType: 'text', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/projects/{project_id}/events/stream', + ...options + }); +}; + +/** + * Send Test Event (Development Only) + * + * Send a test event to a project's event stream. This endpoint is + * intended for development and testing purposes. + * + * **Authentication**: Required (Bearer token) + * **Authorization**: Must have access to the project + * + * **Note**: This endpoint should be disabled or restricted in production. + */ +export const sendTestEvent = (options: Options) => { + return (options.client ?? client).post({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/projects/{project_id}/events/test', + ...options + }); +}; + /** * OAuth Server Metadata * diff --git a/frontend/src/lib/api/generated/types.gen.ts b/frontend/src/lib/api/generated/types.gen.ts index 22dc731..198ee95 100644 --- a/frontend/src/lib/api/generated/types.gen.ts +++ b/frontend/src/lib/api/generated/types.gen.ts @@ -3186,6 +3186,94 @@ export type GetOrganizationMembersResponses = { export type GetOrganizationMembersResponse = GetOrganizationMembersResponses[keyof GetOrganizationMembersResponses]; +export type StreamProjectEventsData = { + body?: never; + headers?: { + /** + * Last-Event-Id + */ + 'Last-Event-ID'?: string | null; + }; + path: { + /** + * Project Id + */ + project_id: string; + }; + query?: never; + url: '/api/v1/projects/{project_id}/events/stream'; +}; + +export type StreamProjectEventsErrors = { + /** + * Not authenticated + */ + 401: unknown; + /** + * Not authorized to access this project + */ + 403: unknown; + /** + * Project not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type StreamProjectEventsError = StreamProjectEventsErrors[keyof StreamProjectEventsErrors]; + +export type StreamProjectEventsResponses = { + /** + * SSE stream established + */ + 200: unknown; +}; + +export type SendTestEventData = { + body?: never; + path: { + /** + * Project Id + */ + project_id: string; + }; + query?: never; + url: '/api/v1/projects/{project_id}/events/test'; +}; + +export type SendTestEventErrors = { + /** + * Not authenticated + */ + 401: unknown; + /** + * Not authorized to access this project + */ + 403: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SendTestEventError = SendTestEventErrors[keyof SendTestEventErrors]; + +export type SendTestEventResponses = { + /** + * Response Send Test Event + * + * Test event sent + */ + 200: { + [key: string]: unknown; + }; +}; + +export type SendTestEventResponse = SendTestEventResponses[keyof SendTestEventResponses]; + export type GetOauthServerMetadataData = { body?: never; path?: never; diff --git a/frontend/src/lib/api/hooks/useAuth.ts b/frontend/src/lib/api/hooks/useAuth.ts index f47b5df..d5b9d57 100755 --- a/frontend/src/lib/api/hooks/useAuth.ts +++ b/frontend/src/lib/api/hooks/useAuth.ts @@ -121,7 +121,7 @@ export function useLogin(onSuccess?: () => void) { const { access_token, refresh_token, user, expires_in } = data; // Update auth store with user and tokens - await setAuth(user as User, access_token, refresh_token || '', expires_in); + await setAuth(user as User, access_token, refresh_token || '', expires_in ?? undefined); // Invalidate and refetch user data queryClient.invalidateQueries({ queryKey: authKeys.all }); @@ -194,7 +194,7 @@ export function useRegister(onSuccess?: () => void) { const { access_token, refresh_token, user, expires_in } = data; // Update auth store with user and tokens (auto-login) - await setAuth(user as User, access_token, refresh_token || '', expires_in); + await setAuth(user as User, access_token, refresh_token || '', expires_in ?? undefined); // Invalidate and refetch user data queryClient.invalidateQueries({ queryKey: authKeys.all }); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 28e1a98..71489a7 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -7,21 +7,15 @@ * @module lib/api/types */ -import type { Token, UserResponse } from './generated/types.gen'; +import type { Token } from './generated/types.gen'; /** - * Extended Token Response + * Token with User Response * - * The actual backend response includes additional fields not captured in OpenAPI spec: - * - user: UserResponse object - * - expires_in: Token expiration in seconds - * - * TODO: Update backend OpenAPI spec to include these fields + * Alias for Token type which now includes user and expires_in fields. + * Kept for backwards compatibility with existing type guards. */ -export interface TokenWithUser extends Token { - user: UserResponse; - expires_in?: number; -} +export type TokenWithUser = Token; /** * Success Response (for operations that return success messages) diff --git a/frontend/src/lib/hooks/useProjectEvents.ts b/frontend/src/lib/hooks/useProjectEvents.ts index beff1f2..bf36003 100644 --- a/frontend/src/lib/hooks/useProjectEvents.ts +++ b/frontend/src/lib/hooks/useProjectEvents.ts @@ -129,6 +129,7 @@ export function useProjectEvents( const currentRetryDelayRef = useRef(initialRetryDelay); const isManualDisconnectRef = useRef(false); const mountedRef = useRef(true); + const pingHandlerRef = useRef<(() => void) | null>(null); /** * Update connection state and notify callback @@ -191,6 +192,12 @@ export function useProjectEvents( retryTimeoutRef.current = null; } + // Remove ping listener before closing to prevent memory leak + if (eventSourceRef.current && pingHandlerRef.current) { + eventSourceRef.current.removeEventListener('ping', pingHandlerRef.current); + pingHandlerRef.current = null; + } + if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -286,12 +293,15 @@ export function useProjectEvents( }; // Handle specific event types from backend - eventSource.addEventListener('ping', () => { + // Store handler reference for proper cleanup + const pingHandler = () => { // Keep-alive ping from server, no action needed if (config.debug.api) { console.log('[SSE] Received ping'); } - }); + }; + pingHandlerRef.current = pingHandler; + eventSource.addEventListener('ping', pingHandler); eventSource.onerror = (err) => { if (!mountedRef.current) return; @@ -355,30 +365,26 @@ export function useProjectEvents( clearProjectEvents(projectId); }, [clearProjectEvents, projectId]); - // Auto-connect on mount if enabled + // Consolidated connection management effect + // Handles both initial mount and auth state changes to prevent race conditions useEffect(() => { mountedRef.current = true; - if (autoConnect && isAuthenticated && projectId) { - connect(); + // Connect when authenticated with a project and not manually disconnected + if (autoConnect && isAuthenticated && accessToken && projectId) { + if (connectionState === 'disconnected' && !isManualDisconnectRef.current) { + connect(); + } + } else if (!isAuthenticated && connectionState !== 'disconnected') { + // Disconnect when auth is lost + disconnect(); } return () => { mountedRef.current = false; cleanup(); }; - }, [autoConnect, isAuthenticated, projectId, connect, cleanup]); - - // Reconnect when auth changes - useEffect(() => { - if (isAuthenticated && accessToken && connectionState === 'disconnected' && autoConnect) { - if (!isManualDisconnectRef.current) { - connect(); - } - } else if (!isAuthenticated && connectionState !== 'disconnected') { - disconnect(); - } - }, [isAuthenticated, accessToken, connectionState, autoConnect, connect, disconnect]); + }, [autoConnect, isAuthenticated, accessToken, projectId, connectionState, connect, disconnect, cleanup]); return { events, diff --git a/frontend/src/mocks/handlers/generated.ts b/frontend/src/mocks/handlers/generated.ts index 23e0bfa..f5040cc 100644 --- a/frontend/src/mocks/handlers/generated.ts +++ b/frontend/src/mocks/handlers/generated.ts @@ -8,7 +8,7 @@ * * For custom handler behavior, use src/mocks/handlers/overrides.ts * - * Generated: 2025-11-26T12:21:51.098Z + * Generated: 2025-12-30T02:14:59.598Z */ import { http, HttpResponse, delay } from 'msw'; @@ -579,4 +579,28 @@ export const generatedHandlers = [ message: 'Operation successful' }); }), + + /** + * Stream Project Events + */ + http.get(`${API_BASE_URL}/api/v1/projects/:project_id/events/stream`, async ({ request, params }) => { + await delay(NETWORK_DELAY); + + return HttpResponse.json({ + success: true, + message: 'Operation successful' + }); + }), + + /** + * Send Test Event (Development Only) + */ + http.post(`${API_BASE_URL}/api/v1/projects/:project_id/events/test`, async ({ request, params }) => { + await delay(NETWORK_DELAY); + + return HttpResponse.json({ + success: true, + message: 'Operation successful' + }); + }), ];