chore: remove legacy Chat/Message/TaskAction, clean imports
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

This commit is contained in:
markov 2026-03-18 20:05:27 +01:00
parent 8b3a9b2148
commit c5243fad83
11 changed files with 22 additions and 367 deletions

View File

@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db from ..database import get_db
from ..models import Member from ..models import Member
from ..models.chat import Attachment, Message # Legacy imports from ..models.event import EventAttachment
from .schemas import UploadOut from .schemas import UploadOut
router = APIRouter(tags=["attachments"]) router = APIRouter(tags=["attachments"])
@ -69,7 +69,7 @@ async def download_attachment(
): ):
"""Download an attachment by ID.""" """Download an attachment by ID."""
result = await db.execute( 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() att = result.scalar_one_or_none()
if not att: if not att:

View File

@ -1,7 +1,6 @@
"""ORM → Pydantic converters. Single place for all model-to-schema transformations.""" """ORM → Pydantic converters. Single place for all model-to-schema transformations."""
from ..models import Event, EventAttachment, Member, ProjectFile, Step, Task from ..models import Event, EventAttachment, Member, ProjectFile, Step, Task
from ..models.chat import Message, Attachment # Legacy for backward compatibility
from ..enums import MemberType from ..enums import MemberType
from .schemas import ( from .schemas import (
AgentConfigOut, AgentConfigOut,
@ -9,7 +8,6 @@ from .schemas import (
EventOut, EventOut,
MemberBrief, MemberBrief,
MemberOut, MemberOut,
MessageOut,
ProjectFileOut, ProjectFileOut,
StepOut, StepOut,
SubtaskBrief, 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: 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( return AttachmentOut(
id=str(a.id), id=str(a.id),
filename=a.filename, 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: def task_out(t: Task, project_slug: str = "") -> TaskOut:

View File

@ -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()]

View File

@ -9,9 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..database import get_db from ..database import get_db
from ..enums import ChatKind, MemberRole, ProjectStatus from ..enums import MemberRole, ProjectStatus
from ..models import Project, Member, ProjectMember from ..models import Project, Member, ProjectMember
from ..models.chat import Chat # Legacy import
from ..ws.manager import manager from ..ws.manager import manager
from .schemas import ProjectOut 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: 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( return ProjectOut(
id=str(p.id), id=str(p.id),
name=p.name, name=p.name,
@ -72,7 +67,6 @@ async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
repo_urls=p.repo_urls or [], repo_urls=p.repo_urls or [],
status=p.status, status=p.status,
task_counter=p.task_counter, task_counter=p.task_counter,
chat_id=str(chat.id) if chat else None,
auto_assign=p.auto_assign, 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, repo_urls=req.repo_urls,
) )
db.add(project) db.add(project)
await db.flush()
chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
db.add(chat)
await db.commit() await db.commit()
out = await _project_out(project, db) out = await _project_out(project, db)

View File

@ -62,22 +62,6 @@ class SubtaskBrief(BaseModel):
assignee: MemberBrief | None = None 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): class EventOut(BaseModel):
@ -142,7 +126,6 @@ class ProjectOut(BaseModel):
repo_urls: list[str] = [] repo_urls: list[str] = []
status: str status: str
task_counter: int task_counter: int
chat_id: str | None = None
auto_assign: bool = False auto_assign: bool = False
class Config: class Config:

View File

@ -12,13 +12,12 @@ from sqlalchemy.orm import selectinload, joinedload
from ..database import get_db from ..database import get_db
from ..enums import ( 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 import Task, Step, Project, Member, TaskLink, Event
from ..models.chat import Message, Chat # Legacy imports
from .auth import get_current_member from .auth import get_current_member
from .schemas import TaskOut, MessageOut, MemberBrief from .schemas import TaskOut, MemberBrief
from .converters import task_out, message_out, member_brief from .converters import task_out, member_brief
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])

View File

@ -169,15 +169,14 @@ app.add_middleware(
) )
# Routers # 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 from .ws.handler import router as ws_router # noqa: E402
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(members.router, prefix="/api/v1") app.include_router(members.router, prefix="/api/v1")
app.include_router(projects.router, prefix="/api/v1") app.include_router(projects.router, prefix="/api/v1")
app.include_router(tasks.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") # Events API (replaces messages)
app.include_router(events.router, prefix="/api/v1") # New events API
app.include_router(steps.router, prefix="/api/v1") app.include_router(steps.router, prefix="/api/v1")
app.include_router(attachments.router, prefix="/api/v1") app.include_router(attachments.router, prefix="/api/v1")
app.include_router(project_files.router, prefix="/api/v1") app.include_router(project_files.router, prefix="/api/v1")

View File

@ -32,10 +32,6 @@ class AuthMethod(StrEnum):
TOKEN = "token" TOKEN = "token"
class ChatKind(StrEnum):
LOBBY = "lobby"
PROJECT = "project"
class ListenMode(StrEnum): class ListenMode(StrEnum):
ALL = "all" ALL = "all"

View File

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

View File

@ -12,7 +12,6 @@ from .base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from .task import Task from .task import Task
from .chat import Chat
from .member import Member 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 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") 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") members: Mapped[list["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan")

View File

@ -9,12 +9,11 @@ from sqlalchemy.orm import selectinload
from ..database import async_session from ..database import async_session
from ..enums import ( from ..enums import (
AuthMethod, ChatKind, ListenMode, MemberRole, MemberStatus, MemberType, AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType,
ProjectStatus, WSEventType, ProjectStatus, WSEventType,
) )
from ..models import Member, AgentConfig, Project, ProjectMember, Task, Event from ..models import Member, AgentConfig, Project, ProjectMember, Task, Event
from ..models.chat import Chat, Message # Legacy imports from ..api.schemas import MemberBrief
from ..api.schemas import MessageOut, MemberBrief
from .manager import ConnectedClient, manager from .manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws") 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") @router.websocket("/ws")
@ -334,15 +314,9 @@ async def _handle_chat_send(session_id: str, data: dict):
if project_id: if project_id:
resolved_project_id = uuid.UUID(project_id) resolved_project_id = uuid.UUID(project_id)
elif chat_id: elif chat_id:
# Legacy: resolve project_id from chat_id # Legacy chat_id support removed - use project_id instead
chat_result = await db.execute(select(Chat).where(Chat.id == uuid.UUID(chat_id))) await client.ws.send_json({"type": WSEventType.ERROR, "message": "chat_id deprecated, use project_id"})
chat = chat_result.scalar_one_or_none() return
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
elif task_id: elif task_id:
# Task comment # Task comment
event_type = "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") chat_id = data.get("chat_id")
task_id = data.get("task_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: async with async_session() as db:
if chat_id: from ..models import Task
chat_result = await db.execute(select(Chat).where(Chat.id == uuid.UUID(chat_id))) task_result = await db.execute(select(Task).where(Task.id == uuid.UUID(task_id)))
chat = chat_result.scalar_one_or_none() task = task_result.scalar_one_or_none()
if chat and chat.project_id: if task:
project_id = str(chat.project_id) project_id = str(task.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)
payload = { payload = {
"type": event_type, "type": event_type,