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:
Markov 2026-02-15 18:42:45 +01:00
parent bbbc65bc48
commit 04be227740
31 changed files with 1167 additions and 335 deletions

7
.env.dev Normal file
View 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
View File

@ -0,0 +1,8 @@
__pycache__/
*.pyc
.env
.env.production
*.egg-info/
dist/
build/
.venv/

17
Dockerfile Normal file
View 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
View File

@ -1,252 +1,43 @@
# Team Board — Backend # Team Board Tracker
API сервисы для Team Board. Python + FastAPI, микросервисная архитектура. Ядро Team Board — управление проектами, задачами, агентами, чатами.
## Сервисы
| Сервис | Порт | Описание |
|--------|------|----------|
| 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
```
## Стек ## Стек
- Python 3.12 - Python 3.12, FastAPI, SQLAlchemy 2 (async)
- FastAPI - PostgreSQL 16, Redis 7
- PostgreSQL - WebSocket для real-time
- Redis - Docker Compose (dev)
- SQLAlchemy
- httpx (async HTTP client)
## Переменные окружения ## Запуск (dev)
```env ```bash
# Database docker compose up --build
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
``` ```
- 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
View 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
View 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
View 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"}

View 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 ###

View File

@ -1,102 +1,44 @@
version: "3.8"
services: services:
# API Gateway tracker:
gateway: build: .
build: ./services/gateway
ports: ports:
- "8000:8000" - "8100:8100"
environment: env_file:
- DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - .env.dev
- REDIS_URL=redis://redis:6379
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
depends_on: depends_on:
- postgres postgres:
- redis 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 postgres:
projects: image: postgres:16-alpine
build: ./services/projects environment:
POSTGRES_USER: team_board
POSTGRES_PASSWORD: team_board
POSTGRES_DB: team_board_dev
ports: ports:
- "8001:8001" - "5433:5432" # 5433 on host to avoid conflict with system postgres
environment: volumes:
- DATABASE_URL=postgresql://team_board:${DB_PASSWORD}@postgres:5432/team_board - pgdata:/var/lib/postgresql/data
- GITEA_URL=https://git.uix.su healthcheck:
- GITEA_TOKEN=${GITEA_TOKEN} test: ["CMD-SHELL", "pg_isready -U team_board -d team_board_dev"]
depends_on: interval: 5s
- postgres 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: redis:
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- "6379:6379" - "6380:6379" # 6380 on host to avoid conflict
volumes: volumes:
- redis_data:/data - redisdata:/data
volumes: volumes:
# postgres_data: pgdata:
redis_data: redisdata:
networks:
default:
name: team-board

8
requirements.txt Normal file
View 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
View 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
View 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()

View 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
View 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
View 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
View 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
View 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

View 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",
]

View 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")

View 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()
)

View 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")

View 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

View 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")

View 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
View 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
View 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()