diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..47eb6e8 --- /dev/null +++ b/.env.dev @@ -0,0 +1,7 @@ +# Dev environment +TRACKER_DATABASE_URL=postgresql+asyncpg://team_board:team_board@postgres:5432/team_board_dev +TRACKER_REDIS_URL=redis://redis:6379/0 +TRACKER_ENV=dev +TRACKER_JWT_SECRET=dev-secret-not-for-production +TRACKER_HOST=0.0.0.0 +TRACKER_PORT=8100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d5d32e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.env +.env.production +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f10e4bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini . +COPY alembic/ alembic/ +COPY src/ src/ + +ENV PYTHONPATH=/app/src +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8100 + +CMD ["sh", "-c", "alembic upgrade head && uvicorn tracker.app:app --host 0.0.0.0 --port 8100"] diff --git a/README.md b/README.md index 5ad8c30..80b0dfd 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,43 @@ -# Team Board — Backend +# Team Board Tracker -API сервисы для Team Board. Python + FastAPI, микросервисная архитектура. - -## Сервисы - -| Сервис | Порт | Описание | -|--------|------|----------| -| gateway | 8000 | API Gateway, аутентификация | -| projects | 8001 | Проекты, Git интеграция | -| tasks | 8002 | Задачи, канбан, подзадачи | -| agents | 8003 | AI агенты, OpenClaw интеграция | -| chat | 8004 | Чаты проектов | - -## Структура - -``` -backend/ -├── services/ -│ ├── gateway/ -│ ├── projects/ -│ ├── tasks/ -│ ├── agents/ -│ └── chat/ -├── shared/ -│ └── openclaw_client.py -├── docker-compose.yml -└── README.md -``` - -## OpenClaw Integration - -### Архитектура - -``` -Team Board (agents service) - │ - │ HTTP POST /hooks/agent - ▼ -OpenClaw Gateway (localhost:18789) - │ - │ sessions_spawn - ▼ -Субагенты (изолированные сессии) - │ - │ результат - ▼ -Callback → Team Board -``` - -### Конфигурация - -```env -# OpenClaw -OPENCLAW_URL=http://localhost:18789 -OPENCLAW_TOKEN=team-board-secret-token -``` - -### OpenClaw Client - -```python -# shared/openclaw_client.py - -import httpx -from typing import Optional - -class OpenClawClient: - def __init__(self, base_url: str, token: str): - self.base_url = base_url - self.headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - - async def wake(self, text: str, mode: str = "now") -> dict: - """Разбудить основную сессию""" - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/hooks/wake", - headers=self.headers, - json={"text": text, "mode": mode} - ) - return response.json() - - async def run_agent( - self, - message: str, - session_key: str, - agent_id: str = "main", - deliver: bool = False, - timeout: int = 300 - ) -> dict: - """Запустить задачу в изолированной сессии""" - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/hooks/agent", - headers=self.headers, - json={ - "message": message, - "name": "TeamBoard", - "agentId": agent_id, - "sessionKey": session_key, - "deliver": deliver, - "timeoutSeconds": timeout - } - ) - return response.json() - - async def get_session_history(self, session_key: str) -> dict: - """Получить историю сессии""" - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.base_url}/api/sessions/{session_key}/history", - headers=self.headers - ) - return response.json() -``` - -## Agents Service - -### Модели - -```python -# services/agents/models.py - -from sqlalchemy import Column, String, Integer, Text, Boolean -from sqlalchemy.dialects.postgresql import UUID, TIMESTAMPTZ -from shared.database import Base -import uuid - -class Agent(Base): - __tablename__ = "agents" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String(100), nullable=False) - slug = Column(String(50), unique=True, nullable=False) - - # Настройки - model = Column(String(100), default="claude-opus-4-5") - system_prompt = Column(Text) - max_concurrent = Column(Integer, default=1) - - # Статус - status = Column(String(20), default="idle") - current_tasks = Column(Integer, default=0) - is_enabled = Column(Boolean, default=True) - - # Timestamps - created_at = Column(TIMESTAMPTZ, server_default="now()") - updated_at = Column(TIMESTAMPTZ, server_default="now()", onupdate="now()") -``` - -### API Endpoints - -```python -# services/agents/routes.py - -from fastapi import APIRouter, Depends, HTTPException -from .models import Agent -from .schemas import AgentCreate, AgentUpdate, TaskAssignment -from shared.openclaw_client import OpenClawClient - -router = APIRouter(prefix="/agents", tags=["agents"]) - -@router.get("/") -async def list_agents(): - """Список всех агентов""" - pass - -@router.post("/") -async def create_agent(data: AgentCreate): - """Создать нового агента""" - pass - -@router.put("/{agent_id}") -async def update_agent(agent_id: str, data: AgentUpdate): - """Обновить агента""" - pass - -@router.post("/{agent_id}/assign") -async def assign_task(agent_id: str, data: TaskAssignment): - """Назначить задачу агенту""" - agent = await Agent.get(agent_id) - - if not agent.is_enabled: - raise HTTPException(400, "Agent is disabled") - - if agent.current_tasks >= agent.max_concurrent: - raise HTTPException(400, "Agent is busy") - - # Запускаем через OpenClaw - client = OpenClawClient(settings.OPENCLAW_URL, settings.OPENCLAW_TOKEN) - - result = await client.run_agent( - message=data.prompt, - session_key=f"task:{data.task_id}", - agent_id=agent.slug - ) - - # Обновляем статус - agent.current_tasks += 1 - agent.status = "working" - await agent.save() - - return result - -@router.post("/callback") -async def agent_callback(data: dict): - """Callback от OpenClaw после выполнения задачи""" - session_key = data.get("sessionKey") - # Обновить задачу, уменьшить current_tasks агента - pass -``` - -## Запуск - -```bash -# Development -cd services/tasks -pip install -r requirements.txt -uvicorn app:app --reload --port 8002 - -# Docker -docker-compose up -d -``` +Ядро Team Board — управление проектами, задачами, агентами, чатами. ## Стек -- Python 3.12 -- FastAPI -- PostgreSQL -- Redis -- SQLAlchemy -- httpx (async HTTP client) +- Python 3.12, FastAPI, SQLAlchemy 2 (async) +- PostgreSQL 16, Redis 7 +- WebSocket для real-time +- Docker Compose (dev) -## Переменные окружения +## Запуск (dev) -```env -# Database -DATABASE_URL=postgresql://team_board:password@localhost:5432/team_board - -# Redis -REDIS_URL=redis://localhost:6379 - -# Auth -AUTHENTIK_CLIENT_ID=... -AUTHENTIK_CLIENT_SECRET=... - -# OpenClaw -OPENCLAW_URL=http://localhost:18789 -OPENCLAW_TOKEN=team-board-secret-token +```bash +docker compose up --build ``` + +- Tracker API: http://localhost:8100 +- Swagger UI: http://localhost:8100/docs +- PostgreSQL: localhost:5433 +- Redis: localhost:6380 + +## Конфигурация + +Все настройки через переменные окружения с префиксом `TRACKER_`: + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `TRACKER_DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL | +| `TRACKER_REDIS_URL` | `redis://localhost:6379/0` | Redis | +| `TRACKER_ENV` | `dev` | Окружение | +| `TRACKER_PORT` | `8100` | Порт | +| `TRACKER_JWT_SECRET` | - | Секрет для JWT | + +## API + +- `GET /health` — healthcheck +- `GET/POST /api/v1/projects` — проекты +- `GET/POST /api/v1/tasks` — задачи +- `GET/POST /api/v1/agents` — агенты +- `GET/POST /api/v1/agents/adapters` — адаптеры +- `GET/POST /api/v1/labels` — лейблы +- `WS /ws` — WebSocket diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..00ce76b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://team_board:team_board@postgres:5432/team_board_dev + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..215e3ed --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +"""Alembic env — async PostgreSQL.""" + +import asyncio +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from tracker.models import Base # noqa: E402 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +# Override URL from env if set +db_url = os.getenv("TRACKER_DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online(): + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..958df87 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/services/agents/.gitkeep b/alembic/versions/.gitkeep similarity index 100% rename from services/agents/.gitkeep rename to alembic/versions/.gitkeep diff --git a/alembic/versions/59dd2d9071fd_initial_schema.py b/alembic/versions/59dd2d9071fd_initial_schema.py new file mode 100644 index 0000000..27846ce --- /dev/null +++ b/alembic/versions/59dd2d9071fd_initial_schema.py @@ -0,0 +1,160 @@ +"""initial schema + +Revision ID: 59dd2d9071fd +Revises: +Create Date: 2026-02-15 17:42:19.160196 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '59dd2d9071fd' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('adapters', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('provider', sa.String(length=50), nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('capabilities', postgresql.ARRAY(sa.String()), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('labels', + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('projects', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('git_repo', sa.String(length=500), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('agents', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=False), + sa.Column('adapter_id', sa.UUID(), nullable=False), + sa.Column('system_prompt', sa.Text(), nullable=True), + sa.Column('subscription_mode', sa.String(length=20), nullable=False), + sa.Column('max_concurrent', sa.Integer(), nullable=False), + sa.Column('timeout_seconds', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('host', sa.String(length=255), nullable=True), + sa.Column('pid', sa.Integer(), nullable=True), + sa.Column('restart_count', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['adapter_id'], ['adapters.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('tasks', + sa.Column('project_id', sa.UUID(), nullable=False), + sa.Column('parent_id', sa.UUID(), nullable=True), + sa.Column('title', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('priority', sa.String(length=20), nullable=False), + sa.Column('requires_pr', sa.Boolean(), nullable=False), + sa.Column('pr_url', sa.String(length=500), nullable=True), + sa.Column('assigned_agent_id', sa.UUID(), nullable=True), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['assigned_agent_id'], ['agents.id'], ), + sa.ForeignKeyConstraint(['parent_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chats', + sa.Column('project_id', sa.UUID(), nullable=True), + sa.Column('task_id', sa.UUID(), nullable=True), + sa.Column('kind', sa.String(length=20), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_dependencies', + sa.Column('task_id', sa.UUID(), nullable=False), + sa.Column('depends_on_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['depends_on_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_files', + sa.Column('task_id', sa.UUID(), nullable=False), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=True), + sa.Column('file_path', sa.String(length=1000), nullable=False), + sa.Column('uploaded_by', sa.UUID(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_labels', + sa.Column('task_id', sa.UUID(), nullable=False), + sa.Column('label_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['label_id'], ['labels.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chat_messages', + sa.Column('chat_id', sa.UUID(), nullable=False), + sa.Column('sender_type', sa.String(length=20), nullable=False), + sa.Column('sender_id', sa.UUID(), nullable=True), + sa.Column('sender_name', sa.String(length=255), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('chat_messages') + op.drop_table('task_labels') + op.drop_table('task_files') + op.drop_table('task_dependencies') + op.drop_table('chats') + op.drop_table('tasks') + op.drop_table('agents') + op.drop_table('projects') + op.drop_table('labels') + op.drop_table('adapters') + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index a2d7819..ae64ccc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,102 +1,44 @@ -version: "3.8" - services: - # API Gateway - gateway: - build: ./services/gateway + tracker: + build: . ports: - - "8000:8000" - environment: - - DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - - REDIS_URL=redis://redis:6379 - - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID} - - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET} + - "8100:8100" + env_file: + - .env.dev depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_started + volumes: + - ./src:/app/src # hot reload in dev + command: > + sh -c "alembic upgrade head && + uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src" - # Projects Service - projects: - build: ./services/projects + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: team_board + POSTGRES_PASSWORD: team_board + POSTGRES_DB: team_board_dev ports: - - "8001:8001" - environment: - - DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - - GITEA_URL=https://git.uix.su - - GITEA_TOKEN=${GITEA_TOKEN} - depends_on: - - postgres + - "5433:5432" # 5433 on host to avoid conflict with system postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U team_board -d team_board_dev"] + interval: 5s + timeout: 3s + retries: 5 - # Tasks Service - tasks: - build: ./services/tasks - ports: - - "8002:8002" - environment: - - DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - - REDIS_URL=redis://redis:6379 - depends_on: - - postgres - - redis - - # Agents Service - agents: - build: ./services/agents - ports: - - "8003:8003" - environment: - - DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - - REDIS_URL=redis://redis:6379 - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENAI_API_KEY=${OPENAI_API_KEY} - depends_on: - - postgres - - redis - - # Chat Service - chat: - build: ./services/chat - ports: - - "8004:8004" - environment: - - DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - - REDIS_URL=redis://redis:6379 - depends_on: - - postgres - - redis - - # Frontend - frontend: - build: ./frontend - ports: - - "3000:3000" - environment: - - NEXT_PUBLIC_API_URL=http://gateway:8000 - depends_on: - - gateway - - # PostgreSQL (используем существующий на хосте) - # postgres: - # image: postgres:16 - # environment: - # - POSTGRES_USER=team_board - # - POSTGRES_PASSWORD=${DB_PASSWORD} - # - POSTGRES_DB=team_board - # volumes: - # - postgres_data:/var/lib/postgresql/data - - # Redis для очередей и pub/sub redis: image: redis:7-alpine ports: - - "6379:6379" + - "6380:6379" # 6380 on host to avoid conflict volumes: - - redis_data:/data + - redisdata:/data volumes: - # postgres_data: - redis_data: - -networks: - default: - name: team-board + pgdata: + redisdata: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e31697 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.30 +alembic>=1.14 +pydantic-settings>=2.7 +redis>=5.0 +websockets>=14.0 diff --git a/services/chat/.gitkeep b/src/tracker/__init__.py similarity index 100% rename from services/chat/.gitkeep rename to src/tracker/__init__.py diff --git a/services/gateway/.gitkeep b/src/tracker/api/__init__.py similarity index 100% rename from services/gateway/.gitkeep rename to src/tracker/api/__init__.py diff --git a/src/tracker/api/agents.py b/src/tracker/api/agents.py new file mode 100644 index 0000000..ebcc4c8 --- /dev/null +++ b/src/tracker/api/agents.py @@ -0,0 +1,118 @@ +"""Agents and Adapters CRUD API.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tracker.database import get_db +from tracker.models import Adapter, Agent + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +# --- Adapters --- + +class AdapterCreate(BaseModel): + name: str + provider: str + config: dict = {} + capabilities: list[str] = [] + + +class AdapterOut(BaseModel): + id: uuid.UUID + name: str + provider: str + capabilities: list[str] + + model_config = {"from_attributes": True} + + +@router.get("/adapters", response_model=list[AdapterOut]) +async def list_adapters(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Adapter).order_by(Adapter.created_at)) + return result.scalars().all() + + +@router.post("/adapters", response_model=AdapterOut, status_code=201) +async def create_adapter(data: AdapterCreate, db: AsyncSession = Depends(get_db)): + adapter = Adapter(**data.model_dump()) + db.add(adapter) + await db.commit() + await db.refresh(adapter) + return adapter + + +# --- Agents --- + +class AgentCreate(BaseModel): + name: str + slug: str + adapter_id: uuid.UUID + system_prompt: str | None = None + subscription_mode: str = "mentions" + max_concurrent: int = 1 + timeout_seconds: int = 600 + + +class AgentUpdate(BaseModel): + name: str | None = None + system_prompt: str | None = None + subscription_mode: str | None = None + max_concurrent: int | None = None + timeout_seconds: int | None = None + status: str | None = None + + +class AgentOut(BaseModel): + id: uuid.UUID + name: str + slug: str + adapter_id: uuid.UUID + subscription_mode: str + max_concurrent: int + timeout_seconds: int + status: str + host: str | None + pid: int | None + restart_count: int + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[AgentOut]) +async def list_agents(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Agent).order_by(Agent.created_at)) + return result.scalars().all() + + +@router.post("/", response_model=AgentOut, status_code=201) +async def create_agent(data: AgentCreate, db: AsyncSession = Depends(get_db)): + agent = Agent(**data.model_dump()) + db.add(agent) + await db.commit() + await db.refresh(agent) + return agent + + +@router.get("/{agent_id}", response_model=AgentOut) +async def get_agent(agent_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + agent = await db.get(Agent, agent_id) + if not agent: + raise HTTPException(404, "Agent not found") + return agent + + +@router.patch("/{agent_id}", response_model=AgentOut) +async def update_agent(agent_id: uuid.UUID, data: AgentUpdate, db: AsyncSession = Depends(get_db)): + agent = await db.get(Agent, agent_id) + if not agent: + raise HTTPException(404, "Agent not found") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(agent, k, v) + await db.commit() + await db.refresh(agent) + return agent diff --git a/src/tracker/api/labels.py b/src/tracker/api/labels.py new file mode 100644 index 0000000..d2763d3 --- /dev/null +++ b/src/tracker/api/labels.py @@ -0,0 +1,50 @@ +"""Labels CRUD API.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tracker.database import get_db +from tracker.models import Label + +router = APIRouter(prefix="/labels", tags=["labels"]) + + +class LabelCreate(BaseModel): + name: str + color: str | None = None + + +class LabelOut(BaseModel): + id: uuid.UUID + name: str + color: str | None + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[LabelOut]) +async def list_labels(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Label).order_by(Label.name)) + return result.scalars().all() + + +@router.post("/", response_model=LabelOut, status_code=201) +async def create_label(data: LabelCreate, db: AsyncSession = Depends(get_db)): + label = Label(**data.model_dump()) + db.add(label) + await db.commit() + await db.refresh(label) + return label + + +@router.delete("/{label_id}", status_code=204) +async def delete_label(label_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + label = await db.get(Label, label_id) + if not label: + raise HTTPException(404, "Label not found") + await db.delete(label) + await db.commit() diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py new file mode 100644 index 0000000..9eabfe6 --- /dev/null +++ b/src/tracker/api/projects.py @@ -0,0 +1,80 @@ +"""Projects CRUD API.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tracker.database import get_db +from tracker.models import Project + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class ProjectCreate(BaseModel): + name: str + slug: str + description: str | None = None + git_repo: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + git_repo: str | None = None + + +class ProjectOut(BaseModel): + id: uuid.UUID + name: str + slug: str + description: str | None + git_repo: str | None + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[ProjectOut]) +async def list_projects(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Project).order_by(Project.created_at)) + return result.scalars().all() + + +@router.post("/", response_model=ProjectOut, status_code=201) +async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)): + project = Project(**data.model_dump()) + db.add(project) + await db.commit() + await db.refresh(project) + return project + + +@router.get("/{project_id}", response_model=ProjectOut) +async def get_project(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + project = await db.get(Project, project_id) + if not project: + raise HTTPException(404, "Project not found") + return project + + +@router.patch("/{project_id}", response_model=ProjectOut) +async def update_project(project_id: uuid.UUID, data: ProjectUpdate, db: AsyncSession = Depends(get_db)): + project = await db.get(Project, project_id) + if not project: + raise HTTPException(404, "Project not found") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(project, k, v) + await db.commit() + await db.refresh(project) + return project + + +@router.delete("/{project_id}", status_code=204) +async def delete_project(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + project = await db.get(Project, project_id) + if not project: + raise HTTPException(404, "Project not found") + await db.delete(project) + await db.commit() diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py new file mode 100644 index 0000000..acf89ef --- /dev/null +++ b/src/tracker/api/tasks.py @@ -0,0 +1,104 @@ +"""Tasks CRUD API.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from tracker.database import get_db +from tracker.models import Task + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +class TaskCreate(BaseModel): + project_id: uuid.UUID + parent_id: uuid.UUID | None = None + title: str + description: str | None = None + status: str = "draft" + priority: str = "medium" + requires_pr: bool = False + assigned_agent_id: uuid.UUID | None = None + + +class TaskUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None + priority: str | None = None + requires_pr: bool | None = None + assigned_agent_id: uuid.UUID | None = None + position: int | None = None + + +class TaskOut(BaseModel): + id: uuid.UUID + project_id: uuid.UUID + parent_id: uuid.UUID | None + title: str + description: str | None + status: str + priority: str + requires_pr: bool + pr_url: str | None + assigned_agent_id: uuid.UUID | None + position: int + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[TaskOut]) +async def list_tasks( + project_id: uuid.UUID | None = None, + status: str | None = None, + db: AsyncSession = Depends(get_db), +): + q = select(Task).order_by(Task.position, Task.created_at) + if project_id: + q = q.where(Task.project_id == project_id) + if status: + q = q.where(Task.status == status) + result = await db.execute(q) + return result.scalars().all() + + +@router.post("/", response_model=TaskOut, status_code=201) +async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db)): + task = Task(**data.model_dump()) + db.add(task) + await db.commit() + await db.refresh(task) + return task + + +@router.get("/{task_id}", response_model=TaskOut) +async def get_task(task_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + task = await db.get(Task, task_id) + if not task: + raise HTTPException(404, "Task not found") + return task + + +@router.patch("/{task_id}", response_model=TaskOut) +async def update_task(task_id: uuid.UUID, data: TaskUpdate, db: AsyncSession = Depends(get_db)): + task = await db.get(Task, task_id) + if not task: + raise HTTPException(404, "Task not found") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(task, k, v) + await db.commit() + await db.refresh(task) + return task + + +@router.delete("/{task_id}", status_code=204) +async def delete_task(task_id: uuid.UUID, db: AsyncSession = Depends(get_db)): + task = await db.get(Task, task_id) + if not task: + raise HTTPException(404, "Task not found") + await db.delete(task) + await db.commit() diff --git a/src/tracker/app.py b/src/tracker/app.py new file mode 100644 index 0000000..c1e89e7 --- /dev/null +++ b/src/tracker/app.py @@ -0,0 +1,34 @@ +"""FastAPI application.""" + +import logging + +from fastapi import FastAPI + +from tracker.api import agents, labels, projects, tasks +from tracker.config import settings +from tracker.ws.handler import router as ws_router + +logging.basicConfig( + level=logging.DEBUG if settings.env == "dev" else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) + +app = FastAPI( + title="Team Board Tracker", + version="0.1.0", + docs_url="/docs" if settings.env == "dev" else None, +) + +# REST API +app.include_router(projects.router, prefix="/api/v1") +app.include_router(tasks.router, prefix="/api/v1") +app.include_router(agents.router, prefix="/api/v1") +app.include_router(labels.router, prefix="/api/v1") + +# WebSocket +app.include_router(ws_router) + + +@app.get("/health") +async def health(): + return {"status": "ok", "version": "0.1.0"} diff --git a/src/tracker/config.py b/src/tracker/config.py new file mode 100644 index 0000000..295d285 --- /dev/null +++ b/src/tracker/config.py @@ -0,0 +1,38 @@ +"""Tracker configuration — all settings from environment variables.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Database + database_url: str = "postgresql+asyncpg://team_board:team_board@localhost:5432/team_board" + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # Server + host: str = "0.0.0.0" + port: int = 8100 + + # Auth + jwt_secret: str = "change-me-in-production" + authentik_url: str = "" + authentik_client_id: str = "" + authentik_client_secret: str = "" + + # Gitea + gitea_url: str = "https://git.uix.su" + gitea_token: str = "" + + # Agent defaults + agent_heartbeat_interval: int = 30 # seconds + agent_heartbeat_timeout: int = 90 # seconds + agent_max_restarts: int = 3 + + # Environment + env: str = "dev" # dev | production + + model_config = {"env_prefix": "TRACKER_"} + + +settings = Settings() diff --git a/src/tracker/database.py b/src/tracker/database.py new file mode 100644 index 0000000..68f2c39 --- /dev/null +++ b/src/tracker/database.py @@ -0,0 +1,13 @@ +"""Database engine and session factory.""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from tracker.config import settings + +engine = create_async_engine(settings.database_url, echo=(settings.env == "dev")) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db() -> AsyncSession: # type: ignore[misc] + async with async_session() as session: + yield session diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py new file mode 100644 index 0000000..9fd4159 --- /dev/null +++ b/src/tracker/models/__init__.py @@ -0,0 +1,20 @@ +from tracker.models.base import Base +from tracker.models.project import Project +from tracker.models.task import Task, TaskDependency, TaskFile, TaskLabel +from tracker.models.agent import Agent, Adapter +from tracker.models.label import Label +from tracker.models.chat import Chat, ChatMessage + +__all__ = [ + "Base", + "Project", + "Task", + "TaskDependency", + "TaskFile", + "TaskLabel", + "Agent", + "Adapter", + "Label", + "Chat", + "ChatMessage", +] diff --git a/src/tracker/models/agent.py b/src/tracker/models/agent.py new file mode 100644 index 0000000..775fb02 --- /dev/null +++ b/src/tracker/models/agent.py @@ -0,0 +1,42 @@ +"""Agent and Adapter models.""" + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from tracker.models.base import Base + +if TYPE_CHECKING: + pass + + +class Adapter(Base): + __tablename__ = "adapters" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + provider: Mapped[str] = mapped_column(String(50), nullable=False) # anthropic, openai, openclaw, cli, ... + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list) + + agents: Mapped[list["Agent"]] = relationship(back_populates="adapter") + + +class Agent(Base): + __tablename__ = "agents" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + adapter_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("adapters.id"), nullable=False) + system_prompt: Mapped[str | None] = mapped_column(Text) + subscription_mode: Mapped[str] = mapped_column(String(20), default="mentions") # all, mentions, assigned + max_concurrent: Mapped[int] = mapped_column(Integer, default=1) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=600) + status: Mapped[str] = mapped_column(String(20), default="offline") # online, offline, busy, dead + host: Mapped[str | None] = mapped_column(String(255), default="localhost") + pid: Mapped[int | None] = mapped_column(Integer) + restart_count: Mapped[int] = mapped_column(Integer, default=0) + + adapter: Mapped["Adapter"] = relationship(back_populates="agents") diff --git a/src/tracker/models/base.py b/src/tracker/models/base.py new file mode 100644 index 0000000..3d5b8f0 --- /dev/null +++ b/src/tracker/models/base.py @@ -0,0 +1,20 @@ +"""SQLAlchemy base model with common fields.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py new file mode 100644 index 0000000..dee4c0f --- /dev/null +++ b/src/tracker/models/chat.py @@ -0,0 +1,36 @@ +"""Chat and message models.""" + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from tracker.models.base import Base + +if TYPE_CHECKING: + from tracker.models.project import Project + + +class Chat(Base): + __tablename__ = "chats" + + project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id")) + task_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id")) + kind: Mapped[str] = mapped_column(String(20), default="project") # lobby, project, task + + project: Mapped["Project | None"] = relationship(back_populates="chats") + messages: Mapped[list["ChatMessage"]] = relationship(back_populates="chat", cascade="all, delete-orphan") + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("chats.id"), nullable=False) + sender_type: Mapped[str] = mapped_column(String(20), nullable=False) # human, agent, system + sender_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # agent_id or null + sender_name: Mapped[str | None] = mapped_column(String(255)) + content: Mapped[str] = mapped_column(Text, nullable=False) + + chat: Mapped["Chat"] = relationship(back_populates="messages") diff --git a/src/tracker/models/label.py b/src/tracker/models/label.py new file mode 100644 index 0000000..f11517c --- /dev/null +++ b/src/tracker/models/label.py @@ -0,0 +1,13 @@ +"""Label model.""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from tracker.models.base import Base + + +class Label(Base): + __tablename__ = "labels" + + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + color: Mapped[str | None] = mapped_column(String(7)) # #rrggbb diff --git a/src/tracker/models/project.py b/src/tracker/models/project.py new file mode 100644 index 0000000..a1a5204 --- /dev/null +++ b/src/tracker/models/project.py @@ -0,0 +1,25 @@ +"""Project model.""" + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from tracker.models.base import Base + +if TYPE_CHECKING: + from tracker.models.task import Task + from tracker.models.chat import Chat + + +class Project(Base): + __tablename__ = "projects" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + git_repo: Mapped[str | None] = mapped_column(String(500)) + + tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan") + chats: Mapped[list["Chat"]] = relationship(back_populates="project", cascade="all, delete-orphan") diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py new file mode 100644 index 0000000..5ceb155 --- /dev/null +++ b/src/tracker/models/task.py @@ -0,0 +1,77 @@ +"""Task model with dependencies, labels, and files.""" + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from tracker.models.base import Base + +if TYPE_CHECKING: + from tracker.models.project import Project + from tracker.models.agent import Agent + + +class Task(Base): + __tablename__ = "tasks" + + project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id")) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(20), default="draft") # draft, ready, in_progress, review, completed, blocked + priority: Mapped[str] = mapped_column(String(20), default="medium") # low, medium, high, critical + requires_pr: Mapped[bool] = mapped_column(Boolean, default=False) + pr_url: Mapped[str | None] = mapped_column(String(500)) + assigned_agent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id")) + position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column + + project: Mapped["Project"] = relationship(back_populates="tasks") + parent: Mapped["Task | None"] = relationship(remote_side="Task.id", back_populates="subtasks") + subtasks: Mapped[list["Task"]] = relationship(back_populates="parent") + assigned_agent: Mapped["Agent | None"] = relationship() + labels: Mapped[list["TaskLabel"]] = relationship(back_populates="task", cascade="all, delete-orphan") + files: Mapped[list["TaskFile"]] = relationship(back_populates="task", cascade="all, delete-orphan") + + # Dependencies (this task depends on...) + dependencies: Mapped[list["TaskDependency"]] = relationship( + foreign_keys="TaskDependency.task_id", back_populates="task", cascade="all, delete-orphan" + ) + # Reverse: tasks that depend on this one + dependents: Mapped[list["TaskDependency"]] = relationship( + foreign_keys="TaskDependency.depends_on_id", back_populates="depends_on", cascade="all, delete-orphan" + ) + + +class TaskDependency(Base): + __tablename__ = "task_dependencies" + + task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False) + depends_on_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False) + + task: Mapped["Task"] = relationship(foreign_keys=[task_id], back_populates="dependencies") + depends_on: Mapped["Task"] = relationship(foreign_keys=[depends_on_id], back_populates="dependents") + + +class TaskLabel(Base): + __tablename__ = "task_labels" + + task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False) + label_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("labels.id"), nullable=False) + + task: Mapped["Task"] = relationship(back_populates="labels") + label = relationship("Label") + + +class TaskFile(Base): + __tablename__ = "task_files" + + task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(100)) + file_path: Mapped[str] = mapped_column(String(1000), nullable=False) + uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + + task: Mapped["Task"] = relationship(back_populates="files") diff --git a/services/projects/.gitkeep b/src/tracker/services/__init__.py similarity index 100% rename from services/projects/.gitkeep rename to src/tracker/services/__init__.py diff --git a/services/tasks/.gitkeep b/src/tracker/ws/__init__.py similarity index 100% rename from services/tasks/.gitkeep rename to src/tracker/ws/__init__.py diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py new file mode 100644 index 0000000..78d5546 --- /dev/null +++ b/src/tracker/ws/handler.py @@ -0,0 +1,42 @@ +"""WebSocket endpoint handler.""" + +import json +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from tracker.ws.manager import manager + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.websocket("/ws") +async def websocket_endpoint(ws: WebSocket, client_type: str = "human", client_id: str | None = None): + ws_id = await manager.connect(ws, client_type=client_type, client_id=client_id) + try: + while True: + raw = await ws.receive_text() + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await manager.send(ws_id, "error", {"message": "Invalid JSON"}) + continue + + event = msg.get("event") + if event == "agent.heartbeat": + await manager.send(ws_id, "agent.heartbeat.ack", {}) + elif event == "chat.send": + # Broadcast to all for now; will scope to project/task later + await manager.broadcast("chat.message", { + "sender": ws_id, + "content": msg.get("content", ""), + "chat_id": msg.get("chat_id"), + }, exclude=ws_id) + else: + logger.debug("Unknown WS event: %s", event) + except WebSocketDisconnect: + pass + finally: + await manager.disconnect(ws_id) diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py new file mode 100644 index 0000000..88688c1 --- /dev/null +++ b/src/tracker/ws/manager.py @@ -0,0 +1,66 @@ +"""WebSocket connection manager.""" + +import asyncio +import json +import logging +from dataclasses import dataclass, field + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +@dataclass +class Connection: + ws: WebSocket + client_type: str = "unknown" # human, agent + client_id: str | None = None + subscriptions: set[str] = field(default_factory=set) # project slugs + + +class ConnectionManager: + def __init__(self): + self._connections: dict[str, Connection] = {} # ws id -> Connection + self._lock = asyncio.Lock() + + async def connect(self, ws: WebSocket, client_type: str = "unknown", client_id: str | None = None) -> str: + await ws.accept() + ws_id = f"{client_type}:{client_id or id(ws)}" + conn = Connection(ws=ws, client_type=client_type, client_id=client_id) + async with self._lock: + self._connections[ws_id] = conn + logger.info("WS connected: %s", ws_id) + return ws_id + + async def disconnect(self, ws_id: str): + async with self._lock: + self._connections.pop(ws_id, None) + logger.info("WS disconnected: %s", ws_id) + + async def send(self, ws_id: str, event: str, data: dict): + conn = self._connections.get(ws_id) + if conn: + try: + await conn.ws.send_json({"event": event, **data}) + except Exception: + await self.disconnect(ws_id) + + async def broadcast(self, event: str, data: dict, exclude: str | None = None): + msg = {"event": event, **data} + dead = [] + for ws_id, conn in list(self._connections.items()): + if ws_id == exclude: + continue + try: + await conn.ws.send_json(msg) + except Exception: + dead.append(ws_id) + for ws_id in dead: + await self.disconnect(ws_id) + + @property + def active_count(self) -> int: + return len(self._connections) + + +manager = ConnectionManager()