diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py index b55a208..3fbdfed 100644 --- a/src/tracker/api/members.py +++ b/src/tracker/api/members.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from tracker.database import get_db +from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType from tracker.models import Member, AgentConfig router = APIRouter(tags=["members"]) @@ -19,8 +20,8 @@ router = APIRouter(tags=["members"]) class AgentConfigSchema(BaseModel): capabilities: list[str] = [] - chat_listen: str = "mentions" - task_listen: str = "mentions" + chat_listen: str = ListenMode.MENTIONS + task_listen: str = ListenMode.MENTIONS prompt: str | None = None model: str | None = None @@ -43,8 +44,8 @@ class MemberOut(BaseModel): class MemberCreate(BaseModel): name: str slug: str - type: str = "agent" # human | agent - role: str = "member" + type: str = MemberType.AGENT # human | agent + role: str = MemberRole.MEMBER agent_config: AgentConfigSchema | None = None @@ -79,7 +80,7 @@ def _member_to_out(m: Member) -> dict: "status": m.status, "avatar_url": m.avatar_url, } - if m.type == "agent": + if m.type == MemberType.AGENT: d["token"] = m.token if m.agent_config: d["agent_config"] = AgentConfigSchema( @@ -121,7 +122,7 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)): raise HTTPException(409, f"Slug '{req.slug}' already taken") token = None - if req.type in ("agent", "bridge"): + if req.type in (MemberType.AGENT, MemberType.BRIDGE): token = f"tb-{secrets.token_hex(32)}" member = Member( @@ -129,14 +130,14 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)): slug=req.slug, type=req.type, role=req.role, - auth_method="token" if req.type in ("agent", "bridge") else "password", + auth_method=AuthMethod.TOKEN if req.type in (MemberType.AGENT, MemberType.BRIDGE) else AuthMethod.PASSWORD, token=token, - status="offline", + status=MemberStatus.OFFLINE, ) db.add(member) await db.flush() # get member.id - if req.agent_config and req.type == "agent": + if req.agent_config and req.type == MemberType.AGENT: config = AgentConfig( member_id=member.id, capabilities=req.agent_config.capabilities, @@ -180,7 +181,7 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends if req.avatar_url is not None: member.avatar_url = req.avatar_url - if req.agent_config and member.type == "agent": + if req.agent_config and member.type == MemberType.AGENT: if not member.agent_config: member.agent_config = AgentConfig(member_id=member.id) ac = req.agent_config @@ -208,7 +209,7 @@ async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)): member = result.scalar_one_or_none() if not member: raise HTTPException(404, "Member not found") - if member.type != "agent": + if member.type != MemberType.AGENT: raise HTTPException(400, "Only agent tokens can be regenerated") token = f"tb-{secrets.token_hex(32)}" member.token = token @@ -224,7 +225,7 @@ async def revoke_token(slug: str, db: AsyncSession = Depends(get_db)): member = result.scalar_one_or_none() if not member: raise HTTPException(404, "Member not found") - if member.type != "agent": + if member.type != MemberType.AGENT: raise HTTPException(400, "Only agent tokens can be revoked") member.token = None await db.commit() diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py index 59e2daa..f27a685 100644 --- a/src/tracker/api/messages.py +++ b/src/tracker/api/messages.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from tracker.database import get_db +from tracker.enums import AuthorType, ChatKind from tracker.models import Message, Chat, Attachment router = APIRouter(tags=["messages"]) @@ -42,7 +43,7 @@ class MessageCreate(BaseModel): chat_id: str | None = None task_id: str | None = None parent_id: str | None = None - author_type: str = "human" + author_type: str = AuthorType.HUMAN author_slug: str = "admin" content: str mentions: list[str] = [] @@ -150,7 +151,7 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db)) 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": + elif chat and chat.kind == ChatKind.LOBBY: await manager.broadcast_all( {"type": "message.new", "data": msg_data}, exclude_slug=msg.author_slug, diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index f919c8a..b510a23 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from tracker.database import get_db +from tracker.enums import ChatKind, MemberRole, ProjectStatus from tracker.models import Project, Chat, Member, ProjectMember router = APIRouter(tags=["projects"]) @@ -58,7 +59,7 @@ class ProjectMemberOut(BaseModel): async def _project_out(p: Project, db: AsyncSession) -> dict: # Get project chat chat_result = await db.execute( - select(Chat).where(Chat.project_id == p.id, Chat.kind == "project") + select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT) ) chat = chat_result.scalar_one_or_none() return { @@ -75,7 +76,7 @@ async def _project_out(p: Project, db: AsyncSession) -> dict: @router.get("/projects", response_model=list[ProjectOut]) async def list_projects(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Project).where(Project.status == "active")) + result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE)) return [await _project_out(p, db) for p in result.scalars()] @@ -104,7 +105,7 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db)) await db.flush() # Create project chat - chat = Chat(project_id=project.id, kind="project") + chat = Chat(project_id=project.id, kind=ChatKind.PROJECT) db.add(chat) await db.commit() @@ -200,7 +201,7 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession project_member = ProjectMember( project_id=project.id, member_id=member.id, - role="member" + role=MemberRole.MEMBER ) db.add(project_member) await db.commit() @@ -235,7 +236,7 @@ async def remove_project_member(slug: str, member_slug: str, db: AsyncSession = raise HTTPException(404, "Member not in project") # Don't allow removing owner - if project_member.role == "owner": + if project_member.role == MemberRole.OWNER: raise HTTPException(400, "Cannot remove project owner") await db.delete(project_member) diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index 7ff74f7..999fe39 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload from tracker.database import get_db +from tracker.enums import AuthorType, ChatKind, TaskPriority, TaskStatus, TaskType from tracker.models import Task, Step, Project, Member, Message, Chat from tracker.api.auth import get_current_member @@ -49,9 +50,9 @@ class TaskOut(BaseModel): class TaskCreate(BaseModel): title: str description: str | None = None - type: str = "task" - status: str = "backlog" - priority: str = "medium" + type: str = TaskType.TASK + status: str = TaskStatus.BACKLOG + priority: str = TaskPriority.MEDIUM labels: list[str] = [] parent_id: str | None = None assignee_slug: str | None = None @@ -84,7 +85,7 @@ class AssignRequest(BaseModel): async def _get_project_chat_id(db: AsyncSession, project_id) -> str | None: """Find project chat UUID.""" result = await db.execute( - select(Chat).where(Chat.project_id == project_id, Chat.kind == "project") + select(Chat).where(Chat.project_id == project_id, Chat.kind == ChatKind.PROJECT) ) chat = result.scalar_one_or_none() return chat.id if chat else None @@ -116,7 +117,7 @@ async def _system_message( # 1. Task comment — detailed history task_msg = Message( task_id=task.id, - author_type="system", + author_type=AuthorType.SYSTEM, author_slug=actor_slug, content=task_text, mentions=[], @@ -128,7 +129,7 @@ async def _system_message( if chat_id: chat_msg = Message( chat_id=chat_id, - author_type="system", + author_type=AuthorType.SYSTEM, author_slug=actor_slug, content=chat_text, mentions=[], @@ -142,7 +143,7 @@ async def _system_message( "id": str(task_msg.id), "task_id": str(task.id), "task_key": key, - "author_type": "system", + "author_type": AuthorType.SYSTEM, "author_slug": actor_slug, "content": task_text, "created_at": task_msg.created_at.isoformat() if task_msg.created_at else now.isoformat(), @@ -153,7 +154,7 @@ async def _system_message( await manager.broadcast_task_event(str(task.project_id), "message.new", { "id": str(chat_msg.id), "chat_id": str(chat_id), - "author_type": "system", + "author_type": AuthorType.SYSTEM, "author_slug": actor_slug, "content": chat_text, "created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(), @@ -395,10 +396,10 @@ async def take_task(task_id: str, current_member: Member = Depends(get_current_m task = await _get_task(task_id, db) if task.assignee_slug: raise HTTPException(409, f"Task already assigned to {task.assignee_slug}") - if task.status not in ("backlog", "todo"): + if task.status not in (TaskStatus.BACKLOG, TaskStatus.TODO): raise HTTPException(409, f"Task status is {task.status}, expected backlog or todo") task.assignee_slug = slug - task.status = "in_progress" + task.status = TaskStatus.IN_PROGRESS # Add to watchers if slug not in (task.watchers or []): task.watchers = (task.watchers or []) + [slug] @@ -439,12 +440,12 @@ async def reject_task(task_id: str, req: RejectRequest, current_member: Member = task = await _get_task(task_id, db) old_assignee = task.assignee_slug task.assignee_slug = None - task.status = "backlog" + task.status = TaskStatus.BACKLOG # Add rejection comment if old_assignee: comment = Message( task_id=task.id, - author_type="system", + author_type=AuthorType.SYSTEM, author_slug=old_assignee, content=f"Задача отклонена: {req.reason}", mentions=[], @@ -453,7 +454,7 @@ async def reject_task(task_id: str, req: RejectRequest, current_member: Member = await db.commit() from tracker.ws.manager import manager await manager.broadcast_task_event(str(task.project_id), "task.updated", { - "id": str(task.id), "status": "backlog", "assignee_slug": None, + "id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_slug": None, }) return {"ok": True, "reason": req.reason, "old_assignee": old_assignee} diff --git a/src/tracker/enums.py b/src/tracker/enums.py new file mode 100644 index 0000000..2fbd468 --- /dev/null +++ b/src/tracker/enums.py @@ -0,0 +1,87 @@ +"""Centralized enums — single source of truth for all string constants.""" + +from enum import StrEnum + + +class MemberType(StrEnum): + HUMAN = "human" + AGENT = "agent" + BRIDGE = "bridge" + + +class MemberRole(StrEnum): + OWNER = "owner" + MEMBER = "member" + BRIDGE = "bridge" + + +class MemberStatus(StrEnum): + ONLINE = "online" + OFFLINE = "offline" + + +class AuthMethod(StrEnum): + PASSWORD = "password" + TOKEN = "token" + + +class ChatKind(StrEnum): + LOBBY = "lobby" + PROJECT = "project" + + +class ListenMode(StrEnum): + ALL = "all" + MENTIONS = "mentions" + NONE = "none" + + +class ProjectStatus(StrEnum): + ACTIVE = "active" + ARCHIVED = "archived" + PAUSED = "paused" + + +class TaskStatus(StrEnum): + BACKLOG = "backlog" + TODO = "todo" + IN_PROGRESS = "in_progress" + IN_REVIEW = "in_review" + DONE = "done" + + +class TaskType(StrEnum): + TASK = "task" + BUG = "bug" + FEATURE = "feature" + + +class TaskPriority(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class AuthorType(StrEnum): + HUMAN = "human" + AGENT = "agent" + SYSTEM = "system" + + +class WSEventType(StrEnum): + AUTH = "auth" + AUTH_OK = "auth.ok" + AUTH_ERROR = "auth.error" + HEARTBEAT = "heartbeat" + ACK = "ack" + ERROR = "error" + CHAT_SEND = "chat.send" + PROJECT_SUBSCRIBE = "project.subscribe" + PROJECT_UNSUBSCRIBE = "project.unsubscribe" + MESSAGE_NEW = "message.new" + TASK_CREATED = "task.created" + TASK_UPDATED = "task.updated" + TASK_ASSIGNED = "task.assigned" + TASK_DELETED = "task.deleted" + AGENT_STATUS = "agent.status" diff --git a/src/tracker/init_db.py b/src/tracker/init_db.py index 6d57821..4f32dc0 100644 --- a/src/tracker/init_db.py +++ b/src/tracker/init_db.py @@ -6,6 +6,7 @@ import logging import uuid from tracker.database import engine, async_session +from tracker.enums import AuthMethod, ChatKind, MemberRole, MemberStatus, MemberType, ProjectStatus from tracker.models import Base, Member, Chat, Project, ProjectMember, AgentConfig logger = logging.getLogger("tracker.init_db") @@ -28,11 +29,11 @@ async def init_db(): admin = Member( name="Admin", slug="admin", - type="human", - role="owner", - auth_method="password", + type=MemberType.HUMAN, + role=MemberRole.OWNER, + auth_method=AuthMethod.PASSWORD, password_hash=hash_password("teamboard"), - status="offline", + status=MemberStatus.OFFLINE, ) session.add(admin) @@ -40,11 +41,11 @@ async def init_db(): coder = Member( name="Coder", slug="coder", - type="agent", - role="member", - auth_method="token", + type=MemberType.AGENT, + role=MemberRole.MEMBER, + auth_method=AuthMethod.TOKEN, token="tb-coder-dev-token", - status="offline", + status=MemberStatus.OFFLINE, ) session.add(coder) @@ -52,11 +53,11 @@ async def init_db(): bff_service = Member( name="BFF Service", slug="bff", - type="bridge", - role="bridge", - auth_method="token", + type=MemberType.BRIDGE, + role=MemberRole.BRIDGE, + auth_method=AuthMethod.TOKEN, token="tb-tracker-dev-token", - status="offline", + status=MemberStatus.OFFLINE, ) session.add(bff_service) @@ -68,14 +69,14 @@ async def init_db(): name="Team Board", slug="team-board", description="AI agent collaboration platform", - status="active", + status=ProjectStatus.ACTIVE, ) session.add(project) await session.flush() # Create project chat project_chat = Chat( - kind="project", + kind=ChatKind.PROJECT, project_id=project.id, ) session.add(project_chat) @@ -84,20 +85,20 @@ async def init_db(): admin_member = ProjectMember( project_id=project.id, member_id=admin.id, - role="owner", + role=MemberRole.OWNER, ) session.add(admin_member) coder_member = ProjectMember( project_id=project.id, member_id=coder.id, - role="member", + role=MemberRole.MEMBER, ) session.add(coder_member) # Create lobby chat lobby = Chat( - kind="lobby", + kind=ChatKind.LOBBY, project_id=None, ) session.add(lobby) diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py index 80d044a..86946c4 100644 --- a/src/tracker/models/chat.py +++ b/src/tracker/models/chat.py @@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship +from tracker.enums import ChatKind from tracker.models.base import Base if TYPE_CHECKING: @@ -18,7 +19,7 @@ class Chat(Base): __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="project") # lobby | project + 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( diff --git a/src/tracker/models/member.py b/src/tracker/models/member.py index 8838700..63cd7b7 100644 --- a/src/tracker/models/member.py +++ b/src/tracker/models/member.py @@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey, String, Text from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship +from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType from tracker.models.base import Base if TYPE_CHECKING: @@ -18,12 +19,12 @@ class Member(Base): 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 + type: Mapped[str] = mapped_column(String(20), nullable=False, default=MemberType.HUMAN) # human | agent + role: Mapped[str] = mapped_column(String(20), nullable=False, default=MemberRole.MEMBER) # owner | member | observer | bridge + auth_method: Mapped[str] = mapped_column(String(20), nullable=False, default=AuthMethod.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 + status: Mapped[str] = mapped_column(String(20), default=MemberStatus.OFFLINE) # online | offline | busy avatar_url: Mapped[str | None] = mapped_column(String(500)) # Agent-specific config (nullable for humans) @@ -42,8 +43,8 @@ class AgentConfig(Base): 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 | none - task_listen: Mapped[str] = mapped_column(String(20), default="mentions") # all | mentions | none + chat_listen: Mapped[str] = mapped_column(String(20), default=ListenMode.MENTIONS) # all | mentions | none + task_listen: Mapped[str] = mapped_column(String(20), default=ListenMode.MENTIONS) # all | mentions | none prompt: Mapped[str | None] = mapped_column(Text) model: Mapped[str | None] = mapped_column(String(100)) diff --git a/src/tracker/models/project.py b/src/tracker/models/project.py index aeb4278..6272eff 100644 --- a/src/tracker/models/project.py +++ b/src/tracker/models/project.py @@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship +from tracker.enums import MemberRole, ProjectStatus from tracker.models.base import Base if TYPE_CHECKING: @@ -22,7 +23,7 @@ class Project(Base): slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) description: Mapped[str | None] = mapped_column(Text) repo_urls: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) - status: Mapped[str] = mapped_column(String(20), default="active") # active | archived + status: Mapped[str] = mapped_column(String(20), default=ProjectStatus.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") @@ -39,7 +40,7 @@ class ProjectMember(Base): member_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True ) - role: Mapped[str] = mapped_column(String(20), default="member") # owner | member + role: Mapped[str] = mapped_column(String(20), default=MemberRole.MEMBER) # owner | member project: Mapped["Project"] = relationship(back_populates="members") member: Mapped["Member"] = relationship() diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index 811661e..79a8855 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -7,6 +7,7 @@ from sqlalchemy import Boolean, ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship +from tracker.enums import TaskPriority, TaskStatus, TaskType from tracker.models.base import Base if TYPE_CHECKING: @@ -21,9 +22,9 @@ class Task(Base): 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) - 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 + type: Mapped[str] = mapped_column(String(20), default=TaskType.TASK) # task | bug | epic | story + status: Mapped[str] = mapped_column(String(20), default=TaskStatus.BACKLOG) # backlog | todo | in_progress | in_review | done + priority: Mapped[str] = mapped_column(String(20), default=TaskPriority.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)) diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index 53edb46..c7448da 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -8,6 +8,10 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from tracker.database import async_session +from tracker.enums import ( + AuthMethod, ChatKind, ListenMode, MemberRole, MemberStatus, MemberType, + ProjectStatus, WSEventType, +) from tracker.models import Member, AgentConfig, Chat, Message, Project, ProjectMember from tracker.ws.manager import ConnectedClient, manager @@ -29,8 +33,8 @@ async def websocket_endpoint(ws: WebSocket, token: str = ""): else: # Wait for auth message (backward compatibility with agents) 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"}) + if auth_msg.get("type") != WSEventType.AUTH: + await ws.send_json({"type": WSEventType.AUTH_ERROR, "message": "First message must be auth"}) await ws.close() return @@ -48,23 +52,23 @@ async def websocket_endpoint(ws: WebSocket, token: str = ""): data = await ws.receive_json() msg_type = data.get("type") - if msg_type == "heartbeat": + if msg_type == WSEventType.HEARTBEAT: await _handle_heartbeat(session_id, data) - elif msg_type == "ack": + elif msg_type == WSEventType.ACK: pass - elif msg_type == "chat.send": + elif msg_type == WSEventType.CHAT_SEND: await _handle_chat_send(session_id, data) - elif msg_type == "project.subscribe": + elif msg_type == WSEventType.PROJECT_SUBSCRIBE: await _handle_subscribe(session_id, data) - elif msg_type == "project.unsubscribe": + elif msg_type == WSEventType.PROJECT_UNSUBSCRIBE: await _handle_unsubscribe(session_id, data) else: - await ws.send_json({"type": "error", "message": f"Unknown type: {msg_type}"}) + await ws.send_json({"type": WSEventType.ERROR, "message": f"Unknown type: {msg_type}"}) except WebSocketDisconnect: if session_id: @@ -81,10 +85,10 @@ async def websocket_endpoint(ws: WebSocket, token: str = ""): result = await db.execute(select(Member).where(Member.slug == client.member_slug)) member = result.scalar_one_or_none() if member: - member.status = "offline" + member.status = MemberStatus.OFFLINE await db.commit() await manager.broadcast_all( - {"type": "agent.status", "data": {"slug": client.member_slug, "status": "offline"}}, + {"type": WSEventType.AGENT_STATUS, "data": {"slug": client.member_slug, "status": MemberStatus.OFFLINE}}, exclude_slug=client.member_slug, ) @@ -125,14 +129,14 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No logger.warning("JWT decode failed: %s", e) if not member: - await ws.send_json({"type": "auth.error", "message": "Invalid token"}) + await ws.send_json({"type": WSEventType.AUTH_ERROR, "message": "Invalid token"}) await ws.close() return None # BFF proxy: bridge acts on behalf of user effective_slug = member.slug effective_type = member.type - if on_behalf_of and member.type == "bridge": + if on_behalf_of and member.type == MemberType.BRIDGE: user_result = await db.execute( select(Member).where(Member.slug == on_behalf_of) .options(selectinload(Member.agent_config)) @@ -148,8 +152,8 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No logger.info("Bridge acting on behalf of unknown user → %s", effective_slug) # Listen modes - chat_listen = "all" - task_listen = "all" + chat_listen = ListenMode.ALL + task_listen = ListenMode.ALL if member.agent_config: chat_listen = member.agent_config.chat_listen task_listen = member.agent_config.task_listen @@ -167,23 +171,23 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No await manager.connect(client) # Update status - member.status = "online" + member.status = MemberStatus.ONLINE await db.commit() # Get lobby chat + projects - lobby = await db.execute(select(Chat).where(Chat.kind == "lobby")) + lobby = await db.execute(select(Chat).where(Chat.kind == ChatKind.LOBBY)) lobby_chat = lobby.scalar_one_or_none() # Filter projects by membership (show all for owners) - if member.role == "owner": + if member.role == MemberRole.OWNER: # Owners see all projects - projects = await db.execute(select(Project).where(Project.status == "active")) + projects = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE)) else: # Members see only projects they belong to projects = await db.execute( select(Project) .join(ProjectMember, Project.id == ProjectMember.project_id) - .where(Project.status == "active", ProjectMember.member_id == member.id) + .where(Project.status == ProjectStatus.ACTIVE, ProjectMember.member_id == member.id) ) project_list = [] @@ -204,18 +208,18 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No client.subscribed_projects.add(p["id"]) await ws.send_json({ - "type": "auth.ok", + "type": WSEventType.AUTH_OK, "data": { "slug": effective_slug, "lobby_chat_id": str(lobby_chat.id) if lobby_chat else None, "projects": project_list, - "online": manager.online_slugs, + MemberStatus.ONLINE: manager.online_slugs, }, }) # Notify others await manager.broadcast_all( - {"type": "agent.status", "data": {"slug": effective_slug, "status": "online"}}, + {"type": WSEventType.AGENT_STATUS, "data": {"slug": effective_slug, "status": MemberStatus.ONLINE}}, exclude_slug=effective_slug, ) @@ -230,7 +234,7 @@ async def _handle_heartbeat(session_id: str, data: dict): if not client: return - status = data.get("status", "online") + status = data.get("status", MemberStatus.ONLINE) client.last_heartbeat = datetime.now(timezone.utc) async with async_session() as db: @@ -241,7 +245,7 @@ async def _handle_heartbeat(session_id: str, data: dict): await db.commit() await manager.broadcast_all( - {"type": "agent.status", "data": {"slug": client.member_slug, "status": status}}, + {"type": WSEventType.AGENT_STATUS, "data": {"slug": client.member_slug, "status": status}}, exclude_slug=client.member_slug, ) @@ -298,9 +302,9 @@ async def _handle_chat_send(session_id: str, data: dict): 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": + elif chat and chat.kind == ChatKind.LOBBY: await manager.broadcast_all( - {"type": "message.new", "data": msg_data}, + {"type": WSEventType.MESSAGE_NEW, "data": msg_data}, exclude_slug=slug, ) return @@ -309,7 +313,7 @@ async def _handle_chat_send(session_id: str, data: dict): await manager.broadcast_message(project_id, msg_data, author_slug=slug) else: await manager.broadcast_all( - {"type": "message.new", "data": msg_data}, + {"type": WSEventType.MESSAGE_NEW, "data": msg_data}, exclude_slug=slug, ) diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py index 33cd6fe..b03943d 100644 --- a/src/tracker/ws/manager.py +++ b/src/tracker/ws/manager.py @@ -7,6 +7,8 @@ from datetime import datetime, timezone from fastapi import WebSocket +from tracker.enums import AuthorType, ListenMode, MemberType, WSEventType + logger = logging.getLogger("tracker.ws") @@ -16,8 +18,8 @@ class ConnectedClient: session_id: str # unique per connection member_slug: str member_type: str # human | agent | bridge - chat_listen: str = "all" # all | mentions | none - task_listen: str = "all" # all | mentions | none + chat_listen: str = ListenMode.ALL + task_listen: str = ListenMode.ALL subscribed_projects: set[str] = field(default_factory=set) last_heartbeat: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -89,28 +91,28 @@ class ConnectionManager: mentions = message.get("mentions", []) content = message.get("content", "") author_type = message.get("author_type", "") - payload = {"type": "message.new", "data": message} + payload = {"type": WSEventType.MESSAGE_NEW, "data": message} for session_id, client in list(self.sessions.items()): if client.member_slug == author_slug: continue # Humans/bridges get ALL messages - if client.member_type in ("human", "bridge"): + if client.member_type in (MemberType.HUMAN, MemberType.BRIDGE): await self.send_to_session(session_id, payload) continue # Agents: subscription check if project_id not in client.subscribed_projects: continue - if client.chat_listen == "none": + if client.chat_listen == ListenMode.NONE: continue # System messages: only if agent is mentioned in content - if author_type == "system": + if author_type == AuthorType.SYSTEM: if f"@{client.member_slug}" not in content: continue await self.send_to_session(session_id, payload) continue # Regular messages: chat_listen filter - if client.chat_listen == "mentions" and client.member_slug not in mentions: + if client.chat_listen == ListenMode.MENTIONS and client.member_slug not in mentions: continue await self.send_to_session(session_id, payload) @@ -123,15 +125,15 @@ class ConnectionManager: for session_id, client in list(self.sessions.items()): # Humans/bridges get ALL task events - if client.member_type in ("human", "bridge"): + if client.member_type in (MemberType.HUMAN, MemberType.BRIDGE): await self.send_to_session(session_id, payload) continue # Agents: subscription + task_listen if project_id not in client.subscribed_projects: continue - if client.task_listen == "none": + if client.task_listen == ListenMode.NONE: continue - if client.task_listen == "all": + if client.task_listen == ListenMode.ALL: await self.send_to_session(session_id, payload) continue if client.member_slug in (assignee, reviewer) or client.member_slug in watchers: