diff --git a/src/tracker/api/attachments.py b/src/tracker/api/attachments.py index 4d2b891..7bad49a 100644 --- a/src/tracker/api/attachments.py +++ b/src/tracker/api/attachments.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..models import Member -from ..models.chat import Attachment, Message # Legacy imports +from ..models.event import EventAttachment from .schemas import UploadOut router = APIRouter(tags=["attachments"]) @@ -69,7 +69,7 @@ async def download_attachment( ): """Download an attachment by ID.""" result = await db.execute( - select(Attachment).where(Attachment.id == uuid.UUID(attachment_id)) + select(EventAttachment).where(EventAttachment.id == uuid.UUID(attachment_id)) ) att = result.scalar_one_or_none() if not att: diff --git a/src/tracker/api/converters.py b/src/tracker/api/converters.py index 739d8b4..a3589c5 100644 --- a/src/tracker/api/converters.py +++ b/src/tracker/api/converters.py @@ -1,7 +1,6 @@ """ORM → Pydantic converters. Single place for all model-to-schema transformations.""" from ..models import Event, EventAttachment, Member, ProjectFile, Step, Task -from ..models.chat import Message, Attachment # Legacy for backward compatibility from ..enums import MemberType from .schemas import ( AgentConfigOut, @@ -9,7 +8,6 @@ from .schemas import ( EventOut, MemberBrief, MemberOut, - MessageOut, ProjectFileOut, StepOut, SubtaskBrief, @@ -17,13 +15,6 @@ from .schemas import ( ) -def _resolve_mentions(m: Message) -> list[MemberBrief]: - """Convert stored mention slugs to MemberBrief objects. - Uses pre-loaded relationships if available, otherwise returns slug-only briefs.""" - if not m.mentions: - return [] - # For now: return slug-only briefs (id will be empty until we store mention IDs) - return [MemberBrief(id="", slug=slug, name=slug) for slug in m.mentions] def member_brief(m: Member | None) -> MemberBrief | None: @@ -58,7 +49,7 @@ def member_out(m: Member) -> MemberOut: ) -def attachment_out(a: Attachment | EventAttachment) -> AttachmentOut: +def attachment_out(a: EventAttachment) -> AttachmentOut: return AttachmentOut( id=str(a.id), filename=a.filename, @@ -78,24 +69,7 @@ def step_out(s: Step) -> StepOut: ) -def message_out(m: Message) -> MessageOut: - return MessageOut( - 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_id=str(m.author_id) if m.author_id else None, - author=member_brief(m.author) if m.author else None, - content=m.content, - thinking=m.thinking, - tool_log=m.tool_log, - mentions=_resolve_mentions(m), - actor=member_brief(m.actor) if hasattr(m, 'actor') and m.actor else None, - voice_url=m.voice_url, - attachments=[attachment_out(a) for a in (m.attachments or [])], - created_at=m.created_at.isoformat() if m.created_at else "", - ) + def task_out(t: Task, project_slug: str = "") -> TaskOut: diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py deleted file mode 100644 index 13c4ae5..0000000 --- a/src/tracker/api/messages.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Messages API — unified messages for chats and task comments.""" - -import os -import uuid -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from ..database import get_db -from ..enums import AuthorType, ChatKind -from ..models.chat import Message, Chat, Attachment # Legacy imports -from .schemas import MessageOut -from .converters import message_out - -router = APIRouter(tags=["messages"]) - -UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads") - - -# --- Schemas --- - -class AttachmentInput(BaseModel): - """Uploaded file info from /upload endpoint.""" - file_id: str # UUID from upload - filename: str - mime_type: str | None = None - size: int = 0 - storage_name: str # filename on disk - - -class MessageCreate(BaseModel): - chat_id: str | None = None - task_id: str | None = None - parent_id: str | None = None - content: str - thinking: str | None = None - mentions: list[str] = [] - voice_url: str | None = None - attachments: list[AttachmentInput] = [] - - -# --- 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), selectinload(Message.author), selectinload(Message.actor)) - - 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)) - - # Get newest N messages (DESC), then reverse to chronological order - q = q.order_by(Message.created_at.desc()).offset(offset).limit(limit) - result = await db.execute(q) - messages = [message_out(m).model_dump() for m in result.scalars()] - messages.reverse() - return messages - - -@router.post("/messages", response_model=MessageOut) -async def create_message(req: MessageCreate, request: Request, 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") - - # Resolve author from auth — never trust client-provided author fields - member = getattr(request.state, "member", None) - if not member: - raise HTTPException(401, "Not authenticated") - author_id = member.id - author_type = member.type - - 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=author_type, - author_id=author_id, - content=req.content, - thinking=req.thinking, - mentions=req.mentions, - voice_url=req.voice_url, - ) - db.add(msg) - await db.flush() # get msg.id - - # Create attachment records - for att_in in req.attachments: - att = Attachment( - message_id=msg.id, - filename=att_in.filename, - mime_type=att_in.mime_type, - size=att_in.size, - storage_path=att_in.storage_name, - ) - db.add(att) - - await db.commit() - result2 = await db.execute( - select(Message).where(Message.id == msg.id).options( - selectinload(Message.attachments), - selectinload(Message.author) - ) - ) - msg = result2.scalar_one() - - author_id = str(msg.author_id) if msg.author_id else None - - # Build response using shared converter - msg_data = message_out(msg).model_dump() - - # Broadcast via WebSocket - from ..ws.manager import manager - from ..models.task import Task - - project_id = None - if req.chat_id: - chat_result = await db.execute(select(Chat).where(Chat.id == uuid.UUID(req.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 == ChatKind.LOBBY: - await manager.broadcast_all( - {"type": "message.new", "data": msg_data}, - exclude_member_id=author_id, - ) - return message_out(msg) - - # If no chat_id but task_id — resolve project from task - if not project_id and req.task_id: - task_result = await db.execute(select(Task).where(Task.id == uuid.UUID(req.task_id))) - task = task_result.scalar_one_or_none() - if task and task.project_id: - project_id = str(task.project_id) - - if project_id: - await manager.broadcast_message(project_id, msg_data, author_id=author_id) - else: - await manager.broadcast_all( - {"type": "message.new", "data": msg_data}, - exclude_member_id=author_id, - ) - - 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), selectinload(Message.author)) - .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 f01bec6..2d94548 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -9,9 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db -from ..enums import ChatKind, MemberRole, ProjectStatus +from ..enums import MemberRole, ProjectStatus from ..models import Project, Member, ProjectMember -from ..models.chat import Chat # Legacy import from ..ws.manager import manager from .schemas import ProjectOut @@ -60,10 +59,6 @@ async def _get_project(project_id: str, db: AsyncSession) -> Project: async def _project_out(p: Project, db: AsyncSession) -> ProjectOut: - chat_result = await db.execute( - select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT) - ) - chat = chat_result.scalar_one_or_none() return ProjectOut( id=str(p.id), name=p.name, @@ -72,7 +67,6 @@ async def _project_out(p: Project, db: AsyncSession) -> ProjectOut: repo_urls=p.repo_urls or [], status=p.status, task_counter=p.task_counter, - chat_id=str(chat.id) if chat else None, auto_assign=p.auto_assign, ) @@ -103,11 +97,6 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db)) repo_urls=req.repo_urls, ) db.add(project) - await db.flush() - - chat = Chat(project_id=project.id, kind=ChatKind.PROJECT) - db.add(chat) - await db.commit() out = await _project_out(project, db) diff --git a/src/tracker/api/schemas.py b/src/tracker/api/schemas.py index 5cbb0f0..aec9d93 100644 --- a/src/tracker/api/schemas.py +++ b/src/tracker/api/schemas.py @@ -62,22 +62,6 @@ class SubtaskBrief(BaseModel): assignee: MemberBrief | None = None -class MessageOut(BaseModel): - id: str - chat_id: str | None = None - task_id: str | None = None - parent_id: str | None = None - author_type: str - author_id: str | None = None - author: MemberBrief | None = None - content: str - thinking: str | None = None - tool_log: list[dict] | None = None - mentions: list[MemberBrief] = [] - actor: MemberBrief | None = None - voice_url: str | None = None - attachments: list[AttachmentOut] = [] - created_at: str class EventOut(BaseModel): @@ -142,7 +126,6 @@ class ProjectOut(BaseModel): repo_urls: list[str] = [] status: str task_counter: int - chat_id: str | None = None auto_assign: bool = False class Config: diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index d603fc3..74adc66 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -12,13 +12,12 @@ from sqlalchemy.orm import selectinload, joinedload from ..database import get_db from ..enums import ( - AuthorType, ChatKind, MemberType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, + AuthorType, MemberType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, ) from ..models import Task, Step, Project, Member, TaskLink, Event -from ..models.chat import Message, Chat # Legacy imports from .auth import get_current_member -from .schemas import TaskOut, MessageOut, MemberBrief -from .converters import task_out, message_out, member_brief +from .schemas import TaskOut, MemberBrief +from .converters import task_out, member_brief router = APIRouter(tags=["tasks"]) diff --git a/src/tracker/app.py b/src/tracker/app.py index c42d53c..c94e175 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -169,15 +169,14 @@ app.add_middleware( ) # Routers -from .api import auth, members, projects, tasks, messages, events, steps, attachments, project_files, labels # noqa: E402 +from .api import auth, members, projects, tasks, events, steps, attachments, project_files, labels # noqa: E402 from .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(messages.router, prefix="/api/v1") # Backward compatibility -app.include_router(events.router, prefix="/api/v1") # New events API +app.include_router(events.router, prefix="/api/v1") # Events API (replaces messages) app.include_router(steps.router, prefix="/api/v1") app.include_router(attachments.router, prefix="/api/v1") app.include_router(project_files.router, prefix="/api/v1") diff --git a/src/tracker/enums.py b/src/tracker/enums.py index b38890c..c40e2d2 100644 --- a/src/tracker/enums.py +++ b/src/tracker/enums.py @@ -32,10 +32,6 @@ class AuthMethod(StrEnum): TOKEN = "token" -class ChatKind(StrEnum): - LOBBY = "lobby" - PROJECT = "project" - class ListenMode(StrEnum): ALL = "all" diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py deleted file mode 100644 index c7349c8..0000000 --- a/src/tracker/models/chat.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Chat and Message models — unified message for chats and task comments.""" - -import uuid -from typing import TYPE_CHECKING - -from sqlalchemy import ForeignKey, Integer, JSON, String, Text -from sqlalchemy.dialects.postgresql import ARRAY, UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from ..enums import ChatKind -from .base import Base - -if TYPE_CHECKING: - from .member import Member - from .project import Project - - -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")) - kind: Mapped[str] = mapped_column(String(20), default=ChatKind.PROJECT) # lobby | project - - project: Mapped["Project | None"] = relationship(back_populates="chats") - messages: Mapped[list["Message"]] = relationship( - back_populates="chat", cascade="all, delete-orphan", - foreign_keys="Message.chat_id" - ) - - -class Message(Base): - """Unified message — works for chat messages AND task comments.""" - __tablename__ = "messages" - - # 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", ondelete="CASCADE")) - - # 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_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True) - - # Actor — who initiated the action (for system messages: who created/moved/assigned the task) - actor_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True) - - # Tool log — JSON array of tool calls [{name, args, result, error?}] - tool_log: Mapped[dict | None] = mapped_column(JSON, nullable=True) - - # Content - content: Mapped[str] = mapped_column(Text, nullable=False) - thinking: Mapped[str | None] = mapped_column(Text, nullable=True) # LLM reasoning/thinking block - mentions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) - voice_url: Mapped[str | None] = mapped_column(String(500)) - - # Relationships - chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id]) - author: Mapped["Member"] = relationship(foreign_keys=[author_id]) - actor: Mapped["Member | None"] = relationship(foreign_keys=[actor_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/project.py b/src/tracker/models/project.py index 0eda74b..e0ad692 100644 --- a/src/tracker/models/project.py +++ b/src/tracker/models/project.py @@ -12,7 +12,6 @@ from .base import Base if TYPE_CHECKING: from .task import Task - from .chat import Chat from .member import Member @@ -28,7 +27,6 @@ class Project(Base): auto_assign: Mapped[bool] = mapped_column(Boolean, default=False) # auto-assign tasks by labels ↔ capabilities tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan") - chats: Mapped[list["Chat"]] = relationship(back_populates="project", cascade="all, delete-orphan") members: Mapped[list["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan") diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index cc7290b..d26aa1b 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -9,12 +9,11 @@ from sqlalchemy.orm import selectinload from ..database import async_session from ..enums import ( - AuthMethod, ChatKind, ListenMode, MemberRole, MemberStatus, MemberType, + AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType, ProjectStatus, WSEventType, ) from ..models import Member, AgentConfig, Project, ProjectMember, Task, Event -from ..models.chat import Chat, Message # Legacy imports -from ..api.schemas import MessageOut, MemberBrief +from ..api.schemas import MemberBrief from .manager import ConnectedClient, manager logger = logging.getLogger("tracker.ws") @@ -31,26 +30,7 @@ def _to_member_brief(member: Member) -> MemberBrief: ) -def _to_message_out(msg: Message, author: Member | None = None) -> MessageOut: - # mentions stored as member IDs in DB - mention_briefs = [MemberBrief(id=mid, slug="", name="") for mid in (msg.mentions or [])] - return MessageOut( - id=str(msg.id), - chat_id=str(msg.chat_id) if msg.chat_id else None, - task_id=str(msg.task_id) if msg.task_id else None, - parent_id=str(msg.parent_id) if msg.parent_id else None, - author_type=msg.author_type, - author_id=str(msg.author_id) if msg.author_id else None, - author=_to_member_brief(author) if author else None, - content=msg.content, - thinking=msg.thinking, - tool_log=msg.tool_log, - mentions=mention_briefs, - actor=_to_member_brief(msg.actor) if hasattr(msg, 'actor') and msg.actor else None, - voice_url=msg.voice_url, - attachments=[], - created_at=msg.created_at.isoformat() if msg.created_at else "", - ) + @router.websocket("/ws") @@ -334,15 +314,9 @@ async def _handle_chat_send(session_id: str, data: dict): if project_id: resolved_project_id = uuid.UUID(project_id) elif chat_id: - # Legacy: resolve project_id from 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: - resolved_project_id = chat.project_id - elif chat and chat.kind == ChatKind.LOBBY: - # Skip lobby for now - Events need project_id - await client.ws.send_json({"type": WSEventType.ERROR, "message": "Lobby chat not supported yet"}) - return + # Legacy chat_id support removed - use project_id instead + await client.ws.send_json({"type": WSEventType.ERROR, "message": "chat_id deprecated, use project_id"}) + return elif task_id: # Task comment event_type = "task_comment" @@ -420,19 +394,13 @@ async def _handle_agent_stream(session_id: str, event_type: str, data: dict): chat_id = data.get("chat_id") task_id = data.get("task_id") - if not project_id and (chat_id or task_id): + if not project_id and task_id: async with async_session() as db: - 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 task_id: - from ..models import Task - task_result = await db.execute(select(Task).where(Task.id == uuid.UUID(task_id))) - task = task_result.scalar_one_or_none() - if task: - project_id = str(task.project_id) + from ..models import Task + task_result = await db.execute(select(Task).where(Task.id == uuid.UUID(task_id))) + task = task_result.scalar_one_or_none() + if task: + project_id = str(task.project_id) payload = { "type": event_type,