refactor: заменены хардкод строки на enums
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

This commit is contained in:
markov 2026-02-24 23:53:43 +01:00
parent 41d98cad88
commit daf3f06dcb
12 changed files with 200 additions and 98 deletions

View File

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

View File

@ -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,

View File

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

View File

@ -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}

87
src/tracker/enums.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: