v0.2.0: new models (Member, Unified Message, Steps, Watchers), REST API, WS v2
Some checks failed
Deploy Tracker / deploy (push) Failing after 1s

- Member + AgentConfig replaces User + Agent
- Unified Message (chat + task comments)
- Steps (task checklist)
- Watchers on tasks
- Auth API (JWT login)
- Members API (CRUD + token generation)
- Tasks API (take/reject/assign/watch)
- Steps API (CRUD)
- Messages API (unified)
- WS handler v2 (auth, heartbeat, project.subscribe, chat.send, filtering)
- Removed: Alembic, Label model, old Agent model
- DB: create_all on startup (dev), init_db.py for seed
This commit is contained in:
Markov 2026-02-22 17:20:23 +01:00
parent c2b6099fac
commit e51a4fa4a9
31 changed files with 1337 additions and 1086 deletions

View File

@ -1,35 +0,0 @@
[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

View File

@ -1,61 +0,0 @@
"""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()

View File

@ -1,25 +0,0 @@
"""${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

@ -1,160 +0,0 @@
"""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,55 +0,0 @@
"""add task number and project key_prefix
Revision ID: 864b0ce5d657
Revises: 59dd2d9071fd
Create Date: 2026-02-15 19:27:43.338390
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '864b0ce5d657'
down_revision: Union[str, None] = '59dd2d9071fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add as nullable first
op.add_column('projects', sa.Column('key_prefix', sa.String(length=10), nullable=True))
op.add_column('projects', sa.Column('task_counter', sa.Integer(), nullable=True))
op.add_column('tasks', sa.Column('number', sa.Integer(), nullable=True))
# Fill defaults
op.execute("UPDATE projects SET key_prefix = UPPER(LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3)) WHERE key_prefix IS NULL")
op.execute("UPDATE projects SET key_prefix = 'PRJ' WHERE key_prefix IS NULL OR key_prefix = ''")
op.execute("UPDATE projects SET task_counter = 0 WHERE task_counter IS NULL")
op.execute("UPDATE tasks SET number = 0 WHERE number IS NULL")
# Number existing tasks sequentially per project
op.execute("""
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at) as rn
FROM tasks
)
UPDATE tasks SET number = numbered.rn FROM numbered WHERE tasks.id = numbered.id
""")
# Update project counters
op.execute("""
UPDATE projects SET task_counter = COALESCE(
(SELECT MAX(number) FROM tasks WHERE tasks.project_id = projects.id), 0
)
""")
# Make NOT NULL
op.alter_column('projects', 'key_prefix', nullable=False)
op.alter_column('projects', 'task_counter', nullable=False, server_default='0')
op.alter_column('tasks', 'number', nullable=False, server_default='0')
def downgrade() -> None:
op.drop_column('tasks', 'number')
op.drop_column('projects', 'task_counter')
op.drop_column('projects', 'key_prefix')

View File

@ -1,64 +0,0 @@
"""remove adapters, update agents: capabilities + token on agent
Revision ID: a1b2c3d4e5f6
Revises: 864b0ce5d657
Create Date: 2026-02-15 22:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '864b0ce5d657'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new columns to agents
op.add_column('agents', sa.Column('token', sa.String(255), unique=True))
op.add_column('agents', sa.Column('capabilities', postgresql.ARRAY(sa.String()), server_default='{}', nullable=False))
# Generate tokens for existing agents
op.execute("UPDATE agents SET token = 'agent-' || md5(random()::text || clock_timestamp()::text) WHERE token IS NULL")
op.alter_column('agents', 'token', nullable=False)
# Drop old columns from agents
op.drop_constraint('agents_adapter_id_fkey', 'agents', type_='foreignkey')
op.drop_column('agents', 'adapter_id')
op.drop_column('agents', 'system_prompt')
op.drop_column('agents', 'host')
op.drop_column('agents', 'pid')
op.drop_column('agents', 'restart_count')
# Change default subscription_mode
op.alter_column('agents', 'subscription_mode', server_default='assigned')
# Drop adapters table
op.drop_table('adapters')
def downgrade() -> None:
# Recreate adapters
op.create_table(
'adapters',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('provider', sa.String(50), nullable=False),
sa.Column('config', postgresql.JSONB, nullable=False, server_default='{}'),
sa.Column('capabilities', postgresql.ARRAY(sa.String()), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
)
# Re-add columns to agents
op.add_column('agents', sa.Column('adapter_id', postgresql.UUID(as_uuid=True)))
op.add_column('agents', sa.Column('system_prompt', sa.Text()))
op.add_column('agents', sa.Column('host', sa.String(255), server_default='localhost'))
op.add_column('agents', sa.Column('pid', sa.Integer()))
op.add_column('agents', sa.Column('restart_count', sa.Integer(), server_default='0'))
op.drop_column('agents', 'token')
op.drop_column('agents', 'capabilities')

View File

@ -12,9 +12,7 @@ services:
condition: service_started
volumes:
- ./src:/app/src
command: >
sh -c "alembic upgrade head &&
uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src"
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
postgres:
image: postgres:16-alpine

View File

@ -2,7 +2,7 @@ fastapi>=0.115
uvicorn[standard]>=0.34
sqlalchemy[asyncio]>=2.0
asyncpg>=0.30
alembic>=1.14
pydantic-settings>=2.7
redis>=5.0
PyJWT>=2.8
python-multipart>=0.0.9
websockets>=14.0

View File

@ -0,0 +1 @@
"""API package."""

View File

@ -1,92 +0,0 @@
"""Agents CRUD API."""
import secrets
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 Agent
router = APIRouter(prefix="/agents", tags=["agents"])
class AgentCreate(BaseModel):
name: str
slug: str
capabilities: list[str] = []
subscription_mode: str = "assigned"
max_concurrent: int = 1
timeout_seconds: int = 600
class AgentUpdate(BaseModel):
name: str | None = None
capabilities: list[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
token: str
capabilities: list[str]
subscription_mode: str
max_concurrent: int
timeout_seconds: int
status: str
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)):
dump = data.model_dump()
dump["token"] = f"agent-{secrets.token_hex(16)}"
agent = Agent(**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
@router.delete("/{agent_id}", status_code=204)
async def delete_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")
await db.delete(agent)
await db.commit()

104
src/tracker/api/auth.py Normal file
View File

@ -0,0 +1,104 @@
"""Auth API — login, JWT."""
import hashlib
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from tracker.config import settings
from tracker.database import get_db
from tracker.models import Member
router = APIRouter(tags=["auth"])
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 72
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def create_jwt(member_id: str, slug: str, role: str) -> str:
payload = {
"sub": str(member_id),
"slug": slug,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
def decode_jwt(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
async def get_current_member(
authorization: str = Header(None),
db: AsyncSession = Depends(get_db),
) -> Member:
"""Extract member from JWT or agent token."""
if not authorization:
raise HTTPException(401, "Missing authorization header")
token = authorization.removeprefix("Bearer ").strip()
# Try JWT first
try:
payload = decode_jwt(token)
result = await db.execute(select(Member).where(Member.id == payload["sub"]))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(401, "Member not found")
return member
except HTTPException:
pass
# Try agent token
result = await db.execute(select(Member).where(Member.token == token))
member = result.scalar_one_or_none()
if member:
return member
raise HTTPException(401, "Invalid credentials")
class LoginRequest(BaseModel):
login: str
password: str
class LoginResponse(BaseModel):
token: str
member_id: str
slug: str
role: str
@router.post("/auth/login", response_model=LoginResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Member).where(
(Member.slug == req.login) | (Member.name == req.login)
)
)
member = result.scalar_one_or_none()
if not member or member.password_hash != hash_password(req.password):
raise HTTPException(401, "Invalid login or password")
token = create_jwt(str(member.id), member.slug, member.role)
return LoginResponse(
token=token,
member_id=str(member.id),
slug=member.slug,
role=member.role,
)

View File

@ -1,114 +0,0 @@
"""Chats and messages API."""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
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 Chat, ChatMessage, Project
router = APIRouter(prefix="/chats", tags=["chats"])
class ChatOut(BaseModel):
id: uuid.UUID
project_id: uuid.UUID | None
task_id: uuid.UUID | None
kind: str
model_config = {"from_attributes": True}
class MessageCreate(BaseModel):
content: str
sender_type: str = "human" # human, agent, system
sender_id: uuid.UUID | None = None
sender_name: str | None = None
class MessageOut(BaseModel):
id: uuid.UUID
chat_id: uuid.UUID
sender_type: str
sender_id: uuid.UUID | None
sender_name: str | None
content: str
created_at: datetime
model_config = {"from_attributes": True}
@router.get("/lobby", response_model=ChatOut)
async def get_or_create_lobby(db: AsyncSession = Depends(get_db)):
"""Get or create the global lobby chat."""
result = await db.execute(select(Chat).where(Chat.kind == "lobby"))
chat = result.scalar_one_or_none()
if not chat:
chat = Chat(kind="lobby")
db.add(chat)
await db.commit()
await db.refresh(chat)
return chat
@router.get("/project/{project_id}", response_model=ChatOut)
async def get_or_create_project_chat(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Get or create a project chat."""
project = await db.get(Project, project_id)
if not project:
raise HTTPException(404, "Project not found")
result = await db.execute(
select(Chat).where(Chat.project_id == project_id, Chat.kind == "project")
)
chat = result.scalar_one_or_none()
if not chat:
chat = Chat(project_id=project_id, kind="project")
db.add(chat)
await db.commit()
await db.refresh(chat)
return chat
@router.get("/{chat_id}/messages", response_model=list[MessageOut])
async def list_messages(
chat_id: uuid.UUID,
limit: int = Query(50, ge=1, le=200),
before: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db),
):
"""Get messages for a chat (newest first, paginated)."""
chat = await db.get(Chat, chat_id)
if not chat:
raise HTTPException(404, "Chat not found")
q = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
if before:
ref = await db.get(ChatMessage, before)
if ref:
q = q.where(ChatMessage.created_at < ref.created_at)
q = q.order_by(ChatMessage.created_at.desc()).limit(limit)
result = await db.execute(q)
messages = list(result.scalars().all())
messages.reverse() # return oldest-first
return messages
@router.post("/{chat_id}/messages", response_model=MessageOut, status_code=201)
async def create_message(chat_id: uuid.UUID, data: MessageCreate, db: AsyncSession = Depends(get_db)):
"""Post a message to a chat (REST fallback — prefer WebSocket)."""
chat = await db.get(Chat, chat_id)
if not chat:
raise HTTPException(404, "Chat not found")
msg = ChatMessage(chat_id=chat_id, **data.model_dump())
db.add(msg)
await db.commit()
await db.refresh(msg)
return msg

View File

@ -1,50 +0,0 @@
"""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()

204
src/tracker/api/members.py Normal file
View File

@ -0,0 +1,204 @@
"""Members API — CRUD for members (humans + agents)."""
import secrets
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 Member, AgentConfig
router = APIRouter(tags=["members"])
# --- Schemas ---
class AgentConfigSchema(BaseModel):
capabilities: list[str] = []
chat_listen: str = "mentions"
task_listen: str = "mentions"
prompt: str | None = None
model: str | None = None
class MemberOut(BaseModel):
id: str
name: str
slug: str
type: str
role: str
status: str
avatar_url: str | None = None
agent_config: AgentConfigSchema | None = None
class Config:
from_attributes = True
class MemberCreate(BaseModel):
name: str
slug: str
type: str = "agent" # human | agent
role: str = "member"
agent_config: AgentConfigSchema | None = None
class MemberCreateResponse(BaseModel):
"""Includes token — shown only once at creation."""
id: str
name: str
slug: str
type: str
role: str
token: str | None = None
agent_config: AgentConfigSchema | None = None
class MemberUpdate(BaseModel):
name: str | None = None
role: str | None = None
status: str | None = None
avatar_url: str | None = None
agent_config: AgentConfigSchema | None = None
# --- Helpers ---
def _member_to_out(m: Member) -> dict:
d = {
"id": str(m.id),
"name": m.name,
"slug": m.slug,
"type": m.type,
"role": m.role,
"status": m.status,
"avatar_url": m.avatar_url,
}
if m.agent_config:
d["agent_config"] = AgentConfigSchema(
capabilities=m.agent_config.capabilities or [],
chat_listen=m.agent_config.chat_listen,
task_listen=m.agent_config.task_listen,
prompt=m.agent_config.prompt,
model=m.agent_config.model,
)
return d
# --- Endpoints ---
@router.get("/members", response_model=list[MemberOut])
async def list_members(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Member).options(selectinload(Member.agent_config))
)
return [_member_to_out(m) for m in result.scalars()]
@router.get("/members/{slug}", response_model=MemberOut)
async def get_member(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
return _member_to_out(member)
@router.post("/members", response_model=MemberCreateResponse)
async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
# Check slug uniqueness
existing = await db.execute(select(Member).where(Member.slug == req.slug))
if existing.scalar_one_or_none():
raise HTTPException(409, f"Slug '{req.slug}' already taken")
token = None
if req.type in ("agent", "bridge"):
token = f"tb-{secrets.token_hex(16)}"
member = Member(
name=req.name,
slug=req.slug,
type=req.type,
role=req.role,
auth_method="token" if req.type in ("agent", "bridge") else "password",
token=token,
status="offline",
)
db.add(member)
await db.flush() # get member.id
if req.agent_config and req.type == "agent":
config = AgentConfig(
member_id=member.id,
capabilities=req.agent_config.capabilities,
chat_listen=req.agent_config.chat_listen,
task_listen=req.agent_config.task_listen,
prompt=req.agent_config.prompt,
model=req.agent_config.model,
)
db.add(config)
await db.commit()
resp = {
"id": str(member.id),
"name": member.name,
"slug": member.slug,
"type": member.type,
"role": member.role,
"token": token,
}
if req.agent_config:
resp["agent_config"] = req.agent_config
return resp
@router.patch("/members/{slug}", response_model=MemberOut)
async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
if req.name is not None:
member.name = req.name
if req.role is not None:
member.role = req.role
if req.status is not None:
member.status = req.status
if req.avatar_url is not None:
member.avatar_url = req.avatar_url
if req.agent_config and member.type == "agent":
if not member.agent_config:
member.agent_config = AgentConfig(member_id=member.id)
ac = req.agent_config
if ac.capabilities is not None:
member.agent_config.capabilities = ac.capabilities
if ac.chat_listen is not None:
member.agent_config.chat_listen = ac.chat_listen
if ac.task_listen is not None:
member.agent_config.task_listen = ac.task_listen
if ac.prompt is not None:
member.agent_config.prompt = ac.prompt
if ac.model is not None:
member.agent_config.model = ac.model
await db.commit()
await db.refresh(member)
return _member_to_out(member)
@router.patch("/members/me/status")
async def update_my_status(status: str, db: AsyncSession = Depends(get_db)):
"""Quick status update (used by agents)."""
# TODO: get current member from auth
return {"status": status}

135
src/tracker/api/messages.py Normal file
View File

@ -0,0 +1,135 @@
"""Messages API — unified messages for chats and task comments."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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 Message, Chat, Attachment
router = APIRouter(tags=["messages"])
# --- Schemas ---
class AttachmentOut(BaseModel):
id: str
filename: str
mime_type: str | None = None
size: int
class MessageOut(BaseModel):
id: str
chat_id: str | None = None
task_id: str | None = None
parent_id: str | None = None
author_type: str
author_slug: str
content: str
mentions: list[str] = []
voice_url: str | None = None
attachments: list[AttachmentOut] = []
created_at: str
class MessageCreate(BaseModel):
chat_id: str | None = None
task_id: str | None = None
parent_id: str | None = None
author_type: str = "human"
author_slug: str = "admin"
content: str
mentions: list[str] = []
voice_url: str | None = None
# --- Helpers ---
def _message_out(m: Message) -> dict:
return {
"id": str(m.id),
"chat_id": str(m.chat_id) if m.chat_id else None,
"task_id": str(m.task_id) if m.task_id else None,
"parent_id": str(m.parent_id) if m.parent_id else None,
"author_type": m.author_type,
"author_slug": m.author_slug,
"content": m.content,
"mentions": m.mentions or [],
"voice_url": m.voice_url,
"attachments": [
{"id": str(a.id), "filename": a.filename, "mime_type": a.mime_type, "size": a.size}
for a in (m.attachments or [])
],
"created_at": m.created_at.isoformat() if m.created_at else "",
}
# --- Endpoints ---
@router.get("/messages", response_model=list[MessageOut])
async def list_messages(
chat_id: Optional[str] = Query(None),
task_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None),
limit: int = Query(50, le=200),
offset: int = Query(0),
db: AsyncSession = Depends(get_db),
):
q = select(Message).options(selectinload(Message.attachments))
if chat_id:
q = q.where(Message.chat_id == uuid.UUID(chat_id))
if task_id:
q = q.where(Message.task_id == uuid.UUID(task_id))
if parent_id:
q = q.where(Message.parent_id == uuid.UUID(parent_id))
# For top-level messages only (no threads), if no parent_id filter
if not parent_id and (chat_id or task_id):
q = q.where(Message.parent_id.is_(None))
q = q.order_by(Message.created_at).offset(offset).limit(limit)
result = await db.execute(q)
return [_message_out(m) for m in result.scalars()]
@router.post("/messages", response_model=MessageOut)
async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db)):
if not req.chat_id and not req.task_id:
raise HTTPException(400, "Either chat_id or task_id must be provided")
msg = Message(
chat_id=uuid.UUID(req.chat_id) if req.chat_id else None,
task_id=uuid.UUID(req.task_id) if req.task_id else None,
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
author_type=req.author_type,
author_slug=req.author_slug,
content=req.content,
mentions=req.mentions,
voice_url=req.voice_url,
)
db.add(msg)
await db.commit()
result2 = await db.execute(
select(Message).where(Message.id == msg.id).options(selectinload(Message.attachments))
)
msg = result2.scalar_one()
return _message_out(msg)
@router.get("/messages/{message_id}/replies", response_model=list[MessageOut])
async def list_replies(message_id: str, db: AsyncSession = Depends(get_db)):
"""Get thread replies for a message."""
result = await db.execute(
select(Message)
.where(Message.parent_id == uuid.UUID(message_id))
.options(selectinload(Message.attachments))
.order_by(Message.created_at)
)
return [_message_out(m) for m in result.scalars()]

View File

@ -1,6 +1,4 @@
"""Projects CRUD API."""
import uuid
"""Projects API."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@ -8,81 +6,114 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from tracker.database import get_db
from tracker.models import Project
from tracker.models import Project, Chat
router = APIRouter(prefix="/projects", tags=["projects"])
router = APIRouter(tags=["projects"])
class ProjectCreate(BaseModel):
name: str
slug: str
description: str | None = None
key_prefix: str | None = None # auto-generated from name if not provided
git_repo: str | None = None
repo_urls: list[str] = []
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
git_repo: str | None = None
repo_urls: list[str] | None = None
status: str | None = None
class ProjectOut(BaseModel):
id: uuid.UUID
id: str
name: str
slug: str
description: str | None
key_prefix: str
description: str | None = None
repo_urls: list[str] = []
status: str
task_counter: int
git_repo: str | None
model_config = {"from_attributes": True}
class Config:
from_attributes = True
@router.get("/", response_model=list[ProjectOut])
def _project_out(p: Project) -> dict:
return {
"id": str(p.id),
"name": p.name,
"slug": p.slug,
"description": p.description,
"repo_urls": p.repo_urls or [],
"status": p.status,
"task_counter": p.task_counter,
}
@router.get("/projects", 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()
result = await db.execute(select(Project).where(Project.status == "active"))
return [_project_out(p) for p in result.scalars()]
@router.post("/", response_model=ProjectOut, status_code=201)
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
dump = data.model_dump()
# Auto-generate key_prefix from name (first 3 chars, uppercase)
if not dump.get("key_prefix"):
prefix = "".join(c for c in data.name.upper() if c.isalpha())[:3]
dump["key_prefix"] = prefix or "PRJ"
project = Project(**dump)
@router.get("/projects/{slug}", response_model=ProjectOut)
async def get_project(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
return _project_out(project)
@router.post("/projects", response_model=ProjectOut)
async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(Project).where(Project.slug == req.slug))
if existing.scalar_one_or_none():
raise HTTPException(409, f"Slug '{req.slug}' already taken")
project = Project(
name=req.name,
slug=req.slug,
description=req.description,
repo_urls=req.repo_urls,
)
db.add(project)
await db.flush()
# Create project chat
chat = Chat(project_id=project.id, kind="project")
db.add(chat)
await db.commit()
await db.refresh(project)
return project
return _project_out(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)
@router.patch("/projects/{slug}", response_model=ProjectOut)
async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
return project
if req.name is not None:
project.name = req.name
if req.description is not None:
project.description = req.description
if req.repo_urls is not None:
project.repo_urls = req.repo_urls
if req.status is not None:
project.status = req.status
@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
return _project_out(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)
@router.delete("/projects/{slug}")
async def delete_project(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
await db.delete(project)
await db.commit()
return {"ok": True}

105
src/tracker/api/steps.py Normal file
View File

@ -0,0 +1,105 @@
"""Steps API — CRUD for task steps (checklist)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from tracker.database import get_db
from tracker.models import Step, Task
router = APIRouter(tags=["steps"])
class StepCreate(BaseModel):
title: str
class StepUpdate(BaseModel):
title: str | None = None
done: bool | None = None
class StepOut(BaseModel):
id: str
task_id: str
title: str
done: bool
position: int
def _step_out(s: Step) -> dict:
return {
"id": str(s.id),
"task_id": str(s.task_id),
"title": s.title,
"done": s.done,
"position": s.position,
}
@router.get("/tasks/{task_id}/steps", response_model=list[StepOut])
async def list_steps(task_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Step).where(Step.task_id == uuid.UUID(task_id)).order_by(Step.position)
)
return [_step_out(s) for s in result.scalars()]
@router.post("/tasks/{task_id}/steps", response_model=StepOut)
async def create_step(task_id: str, req: StepCreate, db: AsyncSession = Depends(get_db)):
# Verify task exists
task = await db.get(Task, uuid.UUID(task_id))
if not task:
raise HTTPException(404, "Task not found")
# Get max position
result = await db.execute(
select(func.coalesce(func.max(Step.position), -1)).where(Step.task_id == task.id)
)
max_pos = result.scalar()
step = Step(
task_id=task.id,
title=req.title,
done=False,
position=max_pos + 1,
)
db.add(step)
await db.commit()
await db.refresh(step)
return _step_out(step)
@router.patch("/tasks/{task_id}/steps/{step_id}", response_model=StepOut)
async def update_step(task_id: str, step_id: str, req: StepUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Step).where(Step.id == uuid.UUID(step_id), Step.task_id == uuid.UUID(task_id))
)
step = result.scalar_one_or_none()
if not step:
raise HTTPException(404, "Step not found")
if req.title is not None:
step.title = req.title
if req.done is not None:
step.done = req.done
await db.commit()
await db.refresh(step)
return _step_out(step)
@router.delete("/tasks/{task_id}/steps/{step_id}")
async def delete_step(task_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Step).where(Step.id == uuid.UUID(step_id), Step.task_id == uuid.UUID(task_id))
)
step = result.scalar_one_or_none()
if not step:
raise HTTPException(404, "Step not found")
await db.delete(step)
await db.commit()
return {"ok": True}

View File

@ -1,138 +1,275 @@
"""Tasks CRUD API."""
"""Tasks API — CRUD + take/reject/assign/watch."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.models import Task, Project
from tracker.models import Task, Step, Project
router = APIRouter(prefix="/tasks", tags=["tasks"])
router = APIRouter(tags=["tasks"])
# --- Schemas ---
class StepOut(BaseModel):
id: str
title: str
done: bool
position: int
class TaskOut(BaseModel):
id: str
project_id: str
parent_id: str | None = None
number: int
title: str
description: str | None = None
type: str
status: str
priority: str
labels: list[str] = []
assignee_slug: str | None = None
reviewer_slug: str | None = None
watchers: list[str] = []
depends_on: list[str] = []
position: int
time_spent: int
steps: list[StepOut] = []
class TaskCreate(BaseModel):
project_id: uuid.UUID
parent_id: uuid.UUID | None = None
title: str
description: str | None = None
status: str = "draft"
type: str = "task"
status: str = "backlog"
priority: str = "medium"
requires_pr: bool = False
assigned_agent_id: uuid.UUID | None = None
labels: list[str] = []
parent_id: str | None = None
assignee_slug: str | None = None
reviewer_slug: str | None = None
depends_on: list[str] = []
class TaskUpdate(BaseModel):
title: str | None = None
description: str | None = None
type: str | None = None
status: str | None = None
priority: str | None = None
requires_pr: bool | None = None
assigned_agent_id: uuid.UUID | None = None
labels: list[str] | None = None
assignee_slug: str | None = None
reviewer_slug: str | 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
number: int
key: str | None = None # e.g. "TEA-1"
position: int
model_config = {"from_attributes": True}
class RejectRequest(BaseModel):
reason: str
async def _enrich_tasks(tasks: list[Task], db: AsyncSession) -> list[TaskOut]:
"""Add key (e.g. TEA-1) to task outputs."""
if not tasks:
return []
# Collect project ids
project_ids = {t.project_id for t in tasks}
projects = {}
for pid in project_ids:
p = await db.get(Project, pid)
if p:
projects[p.id] = p.key_prefix
result = []
for t in tasks:
out = TaskOut.model_validate(t)
prefix = projects.get(t.project_id, "???")
out.key = f"{prefix}-{t.number}" if t.number else None
result.append(out)
return result
class AssignRequest(BaseModel):
assignee_slug: str
@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)
tasks = list(result.scalars().all())
return await _enrich_tasks(tasks, db)
# --- Helpers ---
def _task_out(t: Task) -> dict:
return {
"id": str(t.id),
"project_id": str(t.project_id),
"parent_id": str(t.parent_id) if t.parent_id else None,
"number": t.number,
"title": t.title,
"description": t.description,
"type": t.type,
"status": t.status,
"priority": t.priority,
"labels": t.labels or [],
"assignee_slug": t.assignee_slug,
"reviewer_slug": t.reviewer_slug,
"watchers": t.watchers or [],
"depends_on": [str(d) for d in (t.depends_on or [])],
"position": t.position,
"time_spent": t.time_spent,
"steps": [
{"id": str(s.id), "title": s.title, "done": s.done, "position": s.position}
for s in (t.steps or [])
],
}
@router.post("/", response_model=TaskOut, status_code=201)
async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db)):
# Get project and increment counter
project = await db.get(Project, data.project_id)
if not project:
raise HTTPException(404, "Project not found")
project.task_counter += 1
number = project.task_counter
task = Task(**data.model_dump(), number=number)
db.add(task)
await db.commit()
await db.refresh(task)
# Attach key
task_out = TaskOut.model_validate(task)
task_out.key = f"{project.key_prefix}-{number}"
return task_out
@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)
async def _get_task(task_id: str, db: AsyncSession) -> Task:
result = await db.execute(
select(Task).where(Task.id == uuid.UUID(task_id)).options(selectinload(Task.steps))
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "Task not found")
enriched = await _enrich_tasks([task], db)
return enriched[0]
@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")
# --- Endpoints ---
@router.get("/tasks", response_model=list[TaskOut])
async def list_tasks(
project_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
assignee: Optional[str] = Query(None),
label: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
q = select(Task).options(selectinload(Task.steps))
if project_id:
q = q.where(Task.project_id == uuid.UUID(project_id))
if status:
q = q.where(Task.status == status)
if assignee:
q = q.where(Task.assignee_slug == assignee)
if label:
q = q.where(Task.labels.contains([label]))
q = q.order_by(Task.position, Task.created_at)
result = await db.execute(q)
return [_task_out(t) for t in result.scalars()]
@router.get("/tasks/{task_id}", response_model=TaskOut)
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
return _task_out(task)
@router.post("/tasks", response_model=TaskOut)
async def create_task(
project_slug: str = Query(...),
req: TaskCreate = ...,
db: AsyncSession = Depends(get_db),
):
# Find project
result = await db.execute(select(Project).where(Project.slug == project_slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Increment task counter
project.task_counter += 1
number = project.task_counter
task = Task(
project_id=project.id,
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
number=number,
title=req.title,
description=req.description,
type=req.type,
status=req.status,
priority=req.priority,
labels=req.labels,
assignee_slug=req.assignee_slug,
reviewer_slug=req.reviewer_slug,
depends_on=[uuid.UUID(d) for d in req.depends_on] if req.depends_on else [],
watchers=[req.assignee_slug] if req.assignee_slug else [],
)
db.add(task)
await db.commit()
await db.refresh(task)
# Load steps
task_full = await _get_task(str(task.id), db)
return _task_out(task_full)
@router.patch("/tasks/{task_id}", response_model=TaskOut)
async def update_task(task_id: str, req: TaskUpdate, db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
if req.title is not None:
task.title = req.title
if req.description is not None:
task.description = req.description
if req.type is not None:
task.type = req.type
if req.status is not None:
task.status = req.status
if req.priority is not None:
task.priority = req.priority
if req.labels is not None:
task.labels = req.labels
if req.assignee_slug is not None:
task.assignee_slug = req.assignee_slug or None
if req.reviewer_slug is not None:
task.reviewer_slug = req.reviewer_slug or None
if req.position is not None:
task.position = req.position
await db.commit()
await db.refresh(task)
return _task_out(task)
@router.delete("/tasks/{task_id}")
async def delete_task(task_id: str, db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
await db.delete(task)
await db.commit()
return {"ok": True}
@router.post("/tasks/{task_id}/take", response_model=TaskOut)
async def take_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
"""Atomically take a task — only if not already assigned."""
task = await _get_task(task_id, db)
if task.assignee_slug:
raise HTTPException(409, f"Task already assigned to {task.assignee_slug}")
task.assignee_slug = slug
task.status = "in_progress"
# Add to watchers
if slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [slug]
await db.commit()
await db.refresh(task)
return _task_out(task)
@router.post("/tasks/{task_id}/reject")
async def reject_task(task_id: str, req: RejectRequest, db: AsyncSession = Depends(get_db)):
"""Reject a task with reason — unassign and return to todo."""
task = await _get_task(task_id, db)
old_assignee = task.assignee_slug
task.assignee_slug = None
task.status = "todo"
await db.commit()
return {"ok": True, "reason": req.reason, "old_assignee": old_assignee}
@router.post("/tasks/{task_id}/assign", response_model=TaskOut)
async def assign_task(task_id: str, req: AssignRequest, db: AsyncSession = Depends(get_db)):
"""Assign task to a member."""
task = await _get_task(task_id, db)
task.assignee_slug = req.assignee_slug
if req.assignee_slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [req.assignee_slug]
await db.commit()
await db.refresh(task)
return _task_out(task)
@router.post("/tasks/{task_id}/watch")
async def watch_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
if slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [slug]
await db.commit()
return {"ok": True, "watchers": task.watchers}
@router.delete("/tasks/{task_id}/watch")
async def unwatch_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
task.watchers = [w for w in (task.watchers or []) if w != slug]
await db.commit()
return {"ok": True, "watchers": task.watchers}

View File

@ -4,13 +4,15 @@ import logging
import time
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from tracker.api import agents, chats, labels, projects, tasks
from tracker.config import settings
from tracker.ws.handler import router as ws_router
from tracker.database import engine
from tracker.models import Base
logging.basicConfig(
level=logging.DEBUG if settings.env == "dev" else logging.INFO,
@ -18,17 +20,28 @@ logging.basicConfig(
)
logger = logging.getLogger("tracker")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create tables on startup (dev mode only)."""
if settings.env == "dev":
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured.")
yield
app = FastAPI(
title="Team Board Tracker",
version="0.1.0",
version="0.2.0",
docs_url="/docs" if settings.env == "dev" else None,
lifespan=lifespan,
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
body = None
if request.method in ("POST", "PUT", "PATCH"):
try:
body = await request.body()
@ -48,6 +61,7 @@ async def log_requests(request: Request, call_next):
logger.error("[ERR] %s %s%s (%.0fms)\n%s", request.method, request.url.path, str(e), elapsed, traceback.format_exc())
return JSONResponse({"error": str(e)}, status_code=500)
# CORS
app.add_middleware(
CORSMiddleware,
@ -56,17 +70,19 @@ app.add_middleware(
allow_headers=["*"],
)
# REST API
# Import and register routers (lazy to avoid circular imports)
from tracker.api import auth, members, projects, tasks, messages, steps # noqa: E402
from tracker.ws.handler import router as ws_router # noqa: E402
app.include_router(auth.router, prefix="/api/v1")
app.include_router(members.router, prefix="/api/v1")
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")
app.include_router(chats.router, prefix="/api/v1")
# WebSocket
app.include_router(messages.router, prefix="/api/v1")
app.include_router(steps.router, prefix="/api/v1")
app.include_router(ws_router)
@app.get("/health")
async def health():
return {"status": "ok", "version": "0.1.0"}
return {"status": "ok", "version": "0.2.0"}

52
src/tracker/init_db.py Normal file
View File

@ -0,0 +1,52 @@
"""Initialize database — drop all and create fresh tables + seed data."""
import asyncio
import hashlib
import logging
import uuid
from tracker.database import engine, async_session
from tracker.models import Base, Member, Chat
logger = logging.getLogger("tracker.init_db")
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
async def init_db():
"""Drop all tables and recreate from models. Seed with admin + lobby."""
async with engine.begin() as conn:
logger.info("Dropping all tables...")
await conn.run_sync(Base.metadata.drop_all)
logger.info("Creating all tables...")
await conn.run_sync(Base.metadata.create_all)
async with async_session() as session:
# Create admin (owner)
admin = Member(
name="Admin",
slug="admin",
type="human",
role="owner",
auth_method="password",
password_hash=hash_password("teamboard"),
status="offline",
)
session.add(admin)
# Create lobby chat
lobby = Chat(
kind="lobby",
project_id=None,
)
session.add(lobby)
await session.commit()
logger.info("Seed data created: admin user + lobby chat (id=%s)", lobby.id)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(init_db())

View File

@ -1,19 +1,19 @@
"""Models package — import all models for metadata discovery."""
from tracker.models.base import Base
from tracker.models.member import AgentConfig, Member
from tracker.models.project import Project
from tracker.models.task import Task, TaskDependency, TaskFile, TaskLabel
from tracker.models.agent import Agent
from tracker.models.label import Label
from tracker.models.chat import Chat, ChatMessage
from tracker.models.task import Step, Task
from tracker.models.chat import Attachment, Chat, Message
__all__ = [
"Base",
"Member",
"AgentConfig",
"Project",
"Task",
"TaskDependency",
"TaskFile",
"TaskLabel",
"Agent",
"Label",
"Step",
"Chat",
"ChatMessage",
"Message",
"Attachment",
]

View File

@ -1,22 +0,0 @@
"""Agent model."""
import uuid
from sqlalchemy import Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from tracker.models.base import Base
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)
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) # for WS auth
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
subscription_mode: Mapped[str] = mapped_column(String(20), default="assigned") # 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

View File

@ -1,7 +1,7 @@
"""SQLAlchemy base model with common fields."""
import uuid
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID

View File

@ -1,10 +1,10 @@
"""Chat and message models."""
"""Chat and Message models — unified message for chats and task comments."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base
@ -14,23 +14,54 @@ if TYPE_CHECKING:
class Chat(Base):
"""Chat room — lobby, project, or custom."""
__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
kind: Mapped[str] = mapped_column(String(20), default="project") # lobby | project
project: Mapped["Project | None"] = relationship(back_populates="chats")
messages: Mapped[list["ChatMessage"]] = relationship(back_populates="chat", cascade="all, delete-orphan")
messages: Mapped[list["Message"]] = relationship(
back_populates="chat", cascade="all, delete-orphan",
foreign_keys="Message.chat_id"
)
class ChatMessage(Base):
__tablename__ = "chat_messages"
class Message(Base):
"""Unified message — works for chat messages AND task comments."""
__tablename__ = "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))
# Context: one of chat_id or task_id must be set
chat_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chats.id"))
task_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"))
# Thread support
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"))
# Author
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system
author_slug: Mapped[str] = mapped_column(String(255), nullable=False)
# Content
content: Mapped[str] = mapped_column(Text, nullable=False)
mentions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
voice_url: Mapped[str | None] = mapped_column(String(500))
chat: Mapped["Chat"] = relationship(back_populates="messages")
# Relationships
chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id])
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
replies: Mapped[list["Message"]] = relationship(back_populates="parent")
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id")
class Attachment(Base):
"""File attachment on a message."""
__tablename__ = "attachments"
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"), nullable=False)
filename: Mapped[str] = mapped_column(String(500), nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(100))
size: Mapped[int] = mapped_column(Integer, default=0) # bytes
storage_path: Mapped[str] = mapped_column(String(1000), nullable=False)
message: Mapped["Message"] = relationship(back_populates="attachments")

View File

@ -1,13 +0,0 @@
"""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,43 @@
"""Member model — unified human + agent."""
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base
class Member(Base):
__tablename__ = "members"
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
type: Mapped[str] = mapped_column(String(20), nullable=False, default="human") # human | agent
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") # owner | member | observer | bridge
auth_method: Mapped[str] = mapped_column(String(20), nullable=False, default="password") # password | oauth | token
password_hash: Mapped[str | None] = mapped_column(String(255))
token: Mapped[str | None] = mapped_column(String(255), unique=True) # for agents/bridges
status: Mapped[str] = mapped_column(String(20), default="offline") # online | offline | busy
avatar_url: Mapped[str | None] = mapped_column(String(500))
# Agent-specific config (nullable for humans)
agent_config: Mapped["AgentConfig | None"] = relationship(
back_populates="member", uselist=False, cascade="all, delete-orphan"
)
class AgentConfig(Base):
__tablename__ = "agent_configs"
member_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id"), nullable=False, unique=True
)
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
chat_listen: Mapped[str] = mapped_column(String(20), default="mentions") # all | mentions
task_listen: Mapped[str] = mapped_column(String(20), default="mentions") # all | mentions
prompt: Mapped[str | None] = mapped_column(Text)
model: Mapped[str | None] = mapped_column(String(100))
member: Mapped["Member"] = relationship(back_populates="agent_config")

View File

@ -1,9 +1,9 @@
"""Project model."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base
@ -19,9 +19,9 @@ class Project(Base):
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)
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, default="PRJ") # e.g. "TEA", "DIC"
task_counter: Mapped[int] = mapped_column(Integer, default=0) # auto-increment for task numbers
git_repo: Mapped[str | None] = mapped_column(String(500))
repo_urls: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
status: Mapped[str] = mapped_column(String(20), default="active") # active | archived
task_counter: Mapped[int] = mapped_column(Integer, default=0) # for sequential task numbers
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

@ -1,17 +1,16 @@
"""Task model with dependencies, labels, and files."""
"""Task model with steps, watchers, dependencies."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import ARRAY, 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):
@ -19,60 +18,32 @@ class Task(Base):
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"))
number: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # project-scoped: TB-1, TB-2
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"))
number: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # project-scoped sequential number (TEA-1, TEA-2...)
type: Mapped[str] = mapped_column(String(20), default="task") # task | bug | epic | story
status: Mapped[str] = mapped_column(String(20), default="backlog") # backlog | todo | in_progress | in_review | done
priority: Mapped[str] = mapped_column(String(20), default="medium") # critical | high | medium | low
labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
assignee_slug: Mapped[str | None] = mapped_column(String(255))
reviewer_slug: Mapped[str | None] = mapped_column(String(255))
watchers: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # slugs
depends_on: Mapped[list[uuid.UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
time_spent: Mapped[int] = mapped_column(Integer, default=0) # minutes
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"
)
steps: Mapped[list["Step"]] = relationship(back_populates="task", cascade="all, delete-orphan", order_by="Step.position")
class TaskDependency(Base):
__tablename__ = "task_dependencies"
class Step(Base):
__tablename__ = "steps"
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)
title: Mapped[str] = mapped_column(String(500), nullable=False)
done: Mapped[bool] = mapped_column(Boolean, default=False)
position: Mapped[int] = mapped_column(Integer, default=0)
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")
task: Mapped["Task"] = relationship(back_populates="steps")

View File

@ -1,109 +1,251 @@
"""WebSocket endpoint handler."""
"""WebSocket handler — auth, heartbeat, chat.send, project subscriptions."""
import json
import logging
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from tracker.database import async_session
from tracker.models import Chat, ChatMessage
from tracker.ws.manager import manager
logger = logging.getLogger(__name__)
from tracker.models import Member, AgentConfig, Chat, Message, Project
from tracker.ws.manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws")
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)
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
slug = None
try:
# Wait for auth message
auth_msg = await ws.receive_json()
if auth_msg.get("type") != "auth":
await ws.send_json({"type": "auth.error", "message": "First message must be auth"})
await ws.close()
return
token = auth_msg.get("token", "")
slug = await _authenticate(ws, token)
if not slug:
return
# Main loop
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
data = await ws.receive_json()
msg_type = data.get("type")
event = msg.get("event")
if msg_type == "heartbeat":
await _handle_heartbeat(slug, data)
if event == "auth":
# Agent auth — for now accept any token, send auth.ok
token = msg.get("token", "")
logger.info("WS auth from %s, token=%s...", ws_id, token[:8] if token else "none")
await manager.send(ws_id, "auth.ok", {"init": {}})
elif msg_type == "ack":
pass # acknowledged, no action needed
elif event == "agent.heartbeat":
await manager.send(ws_id, "agent.heartbeat.ack", {})
elif msg_type == "chat.send":
await _handle_chat_send(slug, data)
elif event == "chat.send":
await _handle_chat_send(ws_id, msg)
elif msg_type == "project.subscribe":
await _handle_subscribe(slug, data)
elif event == "chat.subscribe":
# Subscribe to a chat room
chat_id = msg.get("chat_id")
if chat_id:
await manager.subscribe(ws_id, f"chat:{chat_id}")
await manager.send(ws_id, "chat.subscribed", {"chat_id": chat_id})
elif event == "chat.unsubscribe":
chat_id = msg.get("chat_id")
if chat_id:
await manager.unsubscribe(ws_id, f"chat:{chat_id}")
elif msg_type == "project.unsubscribe":
await _handle_unsubscribe(slug, data)
else:
logger.debug("Unknown WS event: %s", event)
await ws.send_json({"type": "error", "message": f"Unknown type: {msg_type}"})
except WebSocketDisconnect:
pass
finally:
await manager.disconnect(ws_id)
async def _handle_chat_send(ws_id: str, msg: dict):
"""Save message to DB and broadcast to chat subscribers."""
chat_id = msg.get("chat_id")
content = msg.get("content", "").strip()
sender_type = msg.get("sender_type", "human")
sender_id = msg.get("sender_id")
sender_name = msg.get("sender_name")
if not chat_id or not content:
await manager.send(ws_id, "error", {"message": "chat_id and content required"})
return
# Save to DB
try:
async with async_session() as db:
chat = await db.get(Chat, chat_id)
if not chat:
await manager.send(ws_id, "error", {"message": "Chat not found"})
return
db_msg = ChatMessage(
chat_id=chat_id,
sender_type=sender_type,
sender_id=sender_id,
sender_name=sender_name,
content=content,
)
db.add(db_msg)
await db.commit()
await db.refresh(db_msg)
# Broadcast to all subscribers of this chat
payload = {
"id": str(db_msg.id),
"chat_id": str(chat_id),
"sender_type": sender_type,
"sender_id": str(sender_id) if sender_id else None,
"sender_name": sender_name,
"content": content,
"created_at": str(db_msg.created_at),
}
await manager.broadcast_to_room(f"chat:{chat_id}", "chat.message", payload)
logger.info("WS disconnect: %s", slug)
except Exception as e:
logger.error("Error saving chat message: %s", e)
await manager.send(ws_id, "error", {"message": "Failed to save message"})
logger.error("WS error for %s: %s", slug, e)
finally:
if slug:
await manager.disconnect(slug)
# Update status to offline
async with async_session() as db:
result = await db.execute(select(Member).where(Member.slug == slug))
member = result.scalar_one_or_none()
if member:
member.status = "offline"
await db.commit()
# Notify others
await manager.broadcast_all(
{"type": "agent.status", "data": {"slug": slug, "status": "offline"}},
exclude=slug,
)
async def _authenticate(ws: WebSocket, token: str) -> str | None:
"""Authenticate by token, return slug or None."""
async with async_session() as db:
result = await db.execute(
select(Member).where(Member.token == token).options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
if not member:
# Try JWT auth (for BFF/web client)
from tracker.api.auth import decode_jwt
try:
payload = decode_jwt(token)
result = await db.execute(
select(Member).where(Member.id == payload["sub"])
.options(selectinload(Member.agent_config))
)
member = result.scalar_one_or_none()
except Exception:
pass
if not member:
await ws.send_json({"type": "auth.error", "message": "Invalid token"})
await ws.close()
return None
# Get listen modes
chat_listen = "all"
task_listen = "all"
if member.agent_config:
chat_listen = member.agent_config.chat_listen
task_listen = member.agent_config.task_listen
# Register connection
client = ConnectedClient(
ws=ws,
member_slug=member.slug,
member_type=member.type,
chat_listen=chat_listen,
task_listen=task_listen,
)
await manager.connect(client)
# Update status
member.status = "online"
await db.commit()
# Get lobby chat + projects
lobby = await db.execute(select(Chat).where(Chat.kind == "lobby"))
lobby_chat = lobby.scalar_one_or_none()
projects = await db.execute(select(Project).where(Project.status == "active"))
project_list = [{"id": str(p.id), "slug": p.slug, "name": p.name} for p in projects.scalars()]
online = list(manager.clients.keys())
await ws.send_json({
"type": "auth.ok",
"data": {
"slug": member.slug,
"lobby_chat_id": str(lobby_chat.id) if lobby_chat else None,
"projects": project_list,
"online": online,
},
})
# Notify others
await manager.broadcast_all(
{"type": "agent.status", "data": {"slug": member.slug, "status": "online"}},
exclude=member.slug,
)
return member.slug
async def _handle_heartbeat(slug: str, data: dict):
"""Update member status from heartbeat."""
status = data.get("status", "online")
async with async_session() as db:
result = await db.execute(select(Member).where(Member.slug == slug))
member = result.scalar_one_or_none()
if member:
member.status = status
await db.commit()
async def _handle_chat_send(slug: str, data: dict):
"""Handle chat message sent via WS."""
chat_id = data.get("chat_id")
task_id = data.get("task_id")
content = data.get("content", "")
mentions = data.get("mentions", [])
if not content:
return
async with async_session() as db:
# Get sender info
result = await db.execute(select(Member).where(Member.slug == slug))
member = result.scalar_one_or_none()
if not member:
return
msg = Message(
chat_id=uuid.UUID(chat_id) if chat_id else None,
task_id=uuid.UUID(task_id) if task_id else None,
author_type=member.type,
author_slug=member.slug,
content=content,
mentions=mentions,
)
db.add(msg)
await db.commit()
await db.refresh(msg)
msg_data = {
"id": str(msg.id),
"chat_id": chat_id,
"task_id": task_id,
"author_type": member.type,
"author_slug": member.slug,
"author_name": member.name,
"content": content,
"mentions": mentions,
"created_at": msg.created_at.isoformat(),
}
# Determine project_id for filtering
project_id = None
if chat_id:
chat_result = await db.execute(select(Chat).where(Chat.id == uuid.UUID(chat_id)))
chat = chat_result.scalar_one_or_none()
if chat and chat.project_id:
project_id = str(chat.project_id)
elif chat and chat.kind == "lobby":
# Lobby — broadcast to all
await manager.broadcast_all(
{"type": "message.new", "data": msg_data},
exclude=slug,
)
return
if project_id:
await manager.broadcast_message(project_id, msg_data, author_slug=slug)
else:
# Task comment or unlinked — broadcast to all
await manager.broadcast_all(
{"type": "message.new", "data": msg_data},
exclude=slug,
)
async def _handle_subscribe(slug: str, data: dict):
"""Subscribe to a project's events."""
project_id = data.get("project_id")
if not project_id:
return
client = manager.clients.get(slug)
if client:
client.subscribed_projects.add(project_id)
logger.info("%s subscribed to project %s", slug, project_id)
async def _handle_unsubscribe(slug: str, data: dict):
"""Unsubscribe from a project."""
project_id = data.get("project_id")
if not project_id:
return
client = manager.clients.get(slug)
if client:
client.subscribed_projects.discard(project_id)
logger.info("%s unsubscribed from project %s", slug, project_id)

View File

@ -1,111 +1,83 @@
"""WebSocket connection manager with room subscriptions."""
"""WebSocket connection manager with project subscriptions and filtering."""
import asyncio
import json
import logging
from dataclasses import dataclass, field
from fastapi import WebSocket
logger = logging.getLogger(__name__)
logger = logging.getLogger("tracker.ws")
@dataclass
class Connection:
class ConnectedClient:
ws: WebSocket
client_type: str = "unknown" # human, agent
client_id: str | None = None
rooms: set[str] = field(default_factory=set) # subscribed rooms: "chat:<id>"
member_slug: str
member_type: str # human | agent | bridge
chat_listen: str = "all" # all | mentions
task_listen: str = "all" # all | mentions
subscribed_projects: set[str] = field(default_factory=set) # project_ids
class ConnectionManager:
def __init__(self):
self._connections: dict[str, Connection] = {}
self._rooms: dict[str, set[str]] = {} # room -> set of ws_ids
self._lock = asyncio.Lock()
self.clients: dict[str, ConnectedClient] = {} # slug → client
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 connect(self, client: ConnectedClient):
self.clients[client.member_slug] = client
logger.info("WS connected: %s (%s)", client.member_slug, client.member_type)
async def disconnect(self, ws_id: str):
async with self._lock:
conn = self._connections.pop(ws_id, None)
if conn:
for room in conn.rooms:
s = self._rooms.get(room)
if s:
s.discard(ws_id)
if not s:
del self._rooms[room]
logger.info("WS disconnected: %s", ws_id)
async def disconnect(self, slug: str):
if slug in self.clients:
del self.clients[slug]
logger.info("WS disconnected: %s", slug)
async def subscribe(self, ws_id: str, room: str):
async with self._lock:
conn = self._connections.get(ws_id)
if conn:
conn.rooms.add(room)
self._rooms.setdefault(room, set()).add(ws_id)
async def unsubscribe(self, ws_id: str, room: str):
async with self._lock:
conn = self._connections.get(ws_id)
if conn:
conn.rooms.discard(room)
s = self._rooms.get(room)
if s:
s.discard(ws_id)
if not s:
del self._rooms[room]
async def send(self, ws_id: str, event: str, data: dict):
conn = self._connections.get(ws_id)
if conn:
async def send_to(self, slug: str, data: dict):
"""Send to specific client."""
client = self.clients.get(slug)
if client:
try:
await conn.ws.send_json({"event": event, **data})
await client.ws.send_json(data)
except Exception:
await self.disconnect(ws_id)
await self.disconnect(slug)
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:
async def broadcast_message(self, project_id: str, message: dict, author_slug: str):
"""Broadcast message.new — filtered by chat_listen."""
mentions = message.get("mentions", [])
for slug, client in list(self.clients.items()):
if slug == author_slug:
continue # don't echo to sender
if project_id not in client.subscribed_projects:
continue
try:
await conn.ws.send_json(msg)
except Exception:
dead.append(ws_id)
for ws_id in dead:
await self.disconnect(ws_id)
# Filter by chat_listen
if client.chat_listen == "mentions" and slug not in mentions:
continue
await self.send_to(slug, {"type": "message.new", "data": message})
async def broadcast_to_room(self, room: str, event: str, data: dict, exclude: str | None = None):
"""Send to all connections subscribed to a room."""
ws_ids = self._rooms.get(room, set())
msg = {"event": event, **data}
dead = []
for ws_id in list(ws_ids):
if ws_id == exclude:
continue
conn = self._connections.get(ws_id)
if not conn:
dead.append(ws_id)
continue
try:
await conn.ws.send_json(msg)
except Exception:
dead.append(ws_id)
for ws_id in dead:
await self.disconnect(ws_id)
async def broadcast_task_event(self, project_id: str, event_type: str, data: dict):
"""Broadcast task events — filtered by task_listen + ownership."""
assignee = data.get("assignee_slug")
reviewer = data.get("reviewer_slug")
watchers = data.get("watchers", [])
@property
def active_count(self) -> int:
return len(self._connections)
for slug, client in list(self.clients.items()):
if project_id not in client.subscribed_projects:
continue
# task_listen: all → get everything
if client.task_listen == "all":
await self.send_to(slug, {"type": event_type, "data": data})
continue
# task_listen: mentions → only if involved
if slug in (assignee, reviewer) or slug in watchers:
await self.send_to(slug, {"type": event_type, "data": data})
async def broadcast_all(self, data: dict, exclude: str | None = None):
"""Broadcast to all connected clients."""
for slug, client in list(self.clients.items()):
if slug == exclude:
continue
await self.send_to(slug, data)
manager = ConnectionManager()