feat: tracker MVP — FastAPI, models, REST API, WebSocket, Docker
- Models: projects, tasks, agents, adapters, labels, chats - REST API: CRUD for all entities - WebSocket: connection manager, heartbeat - Alembic: async migrations, initial schema - Docker Compose: tracker + postgres + redis (dev) - All config via TRACKER_* env vars
This commit is contained in:
parent
bbbc65bc48
commit
04be227740
7
.env.dev
Normal file
7
.env.dev
Normal file
@ -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
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.production
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -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"]
|
||||
281
README.md
281
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
|
||||
|
||||
35
alembic.ini
Normal file
35
alembic.ini
Normal file
@ -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
|
||||
61
alembic/env.py
Normal file
61
alembic/env.py
Normal file
@ -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()
|
||||
25
alembic/script.py.mako
Normal file
25
alembic/script.py.mako
Normal file
@ -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"}
|
||||
160
alembic/versions/59dd2d9071fd_initial_schema.py
Normal file
160
alembic/versions/59dd2d9071fd_initial_schema.py
Normal file
@ -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 ###
|
||||
@ -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:
|
||||
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -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
|
||||
118
src/tracker/api/agents.py
Normal file
118
src/tracker/api/agents.py
Normal file
@ -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
|
||||
50
src/tracker/api/labels.py
Normal file
50
src/tracker/api/labels.py
Normal file
@ -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()
|
||||
80
src/tracker/api/projects.py
Normal file
80
src/tracker/api/projects.py
Normal file
@ -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()
|
||||
104
src/tracker/api/tasks.py
Normal file
104
src/tracker/api/tasks.py
Normal file
@ -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()
|
||||
34
src/tracker/app.py
Normal file
34
src/tracker/app.py
Normal file
@ -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"}
|
||||
38
src/tracker/config.py
Normal file
38
src/tracker/config.py
Normal file
@ -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()
|
||||
13
src/tracker/database.py
Normal file
13
src/tracker/database.py
Normal file
@ -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
|
||||
20
src/tracker/models/__init__.py
Normal file
20
src/tracker/models/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
42
src/tracker/models/agent.py
Normal file
42
src/tracker/models/agent.py
Normal file
@ -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")
|
||||
20
src/tracker/models/base.py
Normal file
20
src/tracker/models/base.py
Normal file
@ -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()
|
||||
)
|
||||
36
src/tracker/models/chat.py
Normal file
36
src/tracker/models/chat.py
Normal file
@ -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")
|
||||
13
src/tracker/models/label.py
Normal file
13
src/tracker/models/label.py
Normal file
@ -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
|
||||
25
src/tracker/models/project.py
Normal file
25
src/tracker/models/project.py
Normal file
@ -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")
|
||||
77
src/tracker/models/task.py
Normal file
77
src/tracker/models/task.py
Normal file
@ -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")
|
||||
42
src/tracker/ws/handler.py
Normal file
42
src/tracker/ws/handler.py
Normal file
@ -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)
|
||||
66
src/tracker/ws/manager.py
Normal file
66
src/tracker/ws/manager.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user