diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 00ce76b..0000000 --- a/alembic.ini +++ /dev/null @@ -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 diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 215e3ed..0000000 --- a/alembic/env.py +++ /dev/null @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 958df87..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -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"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/alembic/versions/59dd2d9071fd_initial_schema.py b/alembic/versions/59dd2d9071fd_initial_schema.py deleted file mode 100644 index 27846ce..0000000 --- a/alembic/versions/59dd2d9071fd_initial_schema.py +++ /dev/null @@ -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 ### diff --git a/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py b/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py deleted file mode 100644 index 58eee32..0000000 --- a/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py +++ /dev/null @@ -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') diff --git a/alembic/versions/a1b2c3d4e5f6_remove_adapters_update_agents.py b/alembic/versions/a1b2c3d4e5f6_remove_adapters_update_agents.py deleted file mode 100644 index 547e955..0000000 --- a/alembic/versions/a1b2c3d4e5f6_remove_adapters_update_agents.py +++ /dev/null @@ -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') diff --git a/docker-compose.yml b/docker-compose.yml index e6f6c9e..855048a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt index 4e31697..bb4728d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/tracker/api/__init__.py b/src/tracker/api/__init__.py index e69de29..dff53e5 100644 --- a/src/tracker/api/__init__.py +++ b/src/tracker/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/src/tracker/api/agents.py b/src/tracker/api/agents.py deleted file mode 100644 index b068f1d..0000000 --- a/src/tracker/api/agents.py +++ /dev/null @@ -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() diff --git a/src/tracker/api/auth.py b/src/tracker/api/auth.py new file mode 100644 index 0000000..d7e2264 --- /dev/null +++ b/src/tracker/api/auth.py @@ -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, + ) diff --git a/src/tracker/api/chats.py b/src/tracker/api/chats.py deleted file mode 100644 index 20f7f17..0000000 --- a/src/tracker/api/chats.py +++ /dev/null @@ -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 diff --git a/src/tracker/api/labels.py b/src/tracker/api/labels.py deleted file mode 100644 index d2763d3..0000000 --- a/src/tracker/api/labels.py +++ /dev/null @@ -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() diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py new file mode 100644 index 0000000..1b493cd --- /dev/null +++ b/src/tracker/api/members.py @@ -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} diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py new file mode 100644 index 0000000..cd04854 --- /dev/null +++ b/src/tracker/api/messages.py @@ -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()] diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index 5380b03..b1f47aa 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -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} diff --git a/src/tracker/api/steps.py b/src/tracker/api/steps.py new file mode 100644 index 0000000..1eeacf8 --- /dev/null +++ b/src/tracker/api/steps.py @@ -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} diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index f74e993..f071a2b 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -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} diff --git a/src/tracker/app.py b/src/tracker/app.py index a89fd86..ada657c 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -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"} diff --git a/src/tracker/init_db.py b/src/tracker/init_db.py new file mode 100644 index 0000000..1acd22e --- /dev/null +++ b/src/tracker/init_db.py @@ -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()) diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index 28126f7..043bfc3 100644 --- a/src/tracker/models/__init__.py +++ b/src/tracker/models/__init__.py @@ -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", ] diff --git a/src/tracker/models/agent.py b/src/tracker/models/agent.py deleted file mode 100644 index c933461..0000000 --- a/src/tracker/models/agent.py +++ /dev/null @@ -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 diff --git a/src/tracker/models/base.py b/src/tracker/models/base.py index 3d5b8f0..d2e6233 100644 --- a/src/tracker/models/base.py +++ b/src/tracker/models/base.py @@ -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 diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py index dee4c0f..80d044a 100644 --- a/src/tracker/models/chat.py +++ b/src/tracker/models/chat.py @@ -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") diff --git a/src/tracker/models/label.py b/src/tracker/models/label.py deleted file mode 100644 index f11517c..0000000 --- a/src/tracker/models/label.py +++ /dev/null @@ -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 diff --git a/src/tracker/models/member.py b/src/tracker/models/member.py new file mode 100644 index 0000000..735fc2f --- /dev/null +++ b/src/tracker/models/member.py @@ -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") diff --git a/src/tracker/models/project.py b/src/tracker/models/project.py index f2f6142..3b55e02 100644 --- a/src/tracker/models/project.py +++ b/src/tracker/models/project.py @@ -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") diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index 5636c9f..811661e 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -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") diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index 8e839e1..9c5c74e 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -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 + logger.info("WS disconnect: %s", slug) + except Exception as e: + logger.error("WS error for %s: %s", slug, e) finally: - await manager.disconnect(ws_id) + 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 _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") +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 chat_id or not content: - await manager.send(ws_id, "error", {"message": "chat_id and content required"}) + 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 - # 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"}) + 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 - db_msg = ChatMessage( - chat_id=chat_id, - sender_type=sender_type, - sender_id=sender_id, - sender_name=sender_name, - content=content, + 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, ) - 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) - except Exception as e: - logger.error("Error saving chat message: %s", e) - await manager.send(ws_id, "error", {"message": "Failed to save message"}) +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) diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py index 3b80f26..6ab21ca 100644 --- a/src/tracker/ws/manager.py +++ b/src/tracker/ws/manager.py @@ -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:" + 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()