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 sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType
from tracker.models import Member, AgentConfig from tracker.models import Member, AgentConfig
router = APIRouter(tags=["members"]) router = APIRouter(tags=["members"])
@ -19,8 +20,8 @@ router = APIRouter(tags=["members"])
class AgentConfigSchema(BaseModel): class AgentConfigSchema(BaseModel):
capabilities: list[str] = [] capabilities: list[str] = []
chat_listen: str = "mentions" chat_listen: str = ListenMode.MENTIONS
task_listen: str = "mentions" task_listen: str = ListenMode.MENTIONS
prompt: str | None = None prompt: str | None = None
model: str | None = None model: str | None = None
@ -43,8 +44,8 @@ class MemberOut(BaseModel):
class MemberCreate(BaseModel): class MemberCreate(BaseModel):
name: str name: str
slug: str slug: str
type: str = "agent" # human | agent type: str = MemberType.AGENT # human | agent
role: str = "member" role: str = MemberRole.MEMBER
agent_config: AgentConfigSchema | None = None agent_config: AgentConfigSchema | None = None
@ -79,7 +80,7 @@ def _member_to_out(m: Member) -> dict:
"status": m.status, "status": m.status,
"avatar_url": m.avatar_url, "avatar_url": m.avatar_url,
} }
if m.type == "agent": if m.type == MemberType.AGENT:
d["token"] = m.token d["token"] = m.token
if m.agent_config: if m.agent_config:
d["agent_config"] = AgentConfigSchema( 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") raise HTTPException(409, f"Slug '{req.slug}' already taken")
token = None token = None
if req.type in ("agent", "bridge"): if req.type in (MemberType.AGENT, MemberType.BRIDGE):
token = f"tb-{secrets.token_hex(32)}" token = f"tb-{secrets.token_hex(32)}"
member = Member( member = Member(
@ -129,14 +130,14 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
slug=req.slug, slug=req.slug,
type=req.type, type=req.type,
role=req.role, 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, token=token,
status="offline", status=MemberStatus.OFFLINE,
) )
db.add(member) db.add(member)
await db.flush() # get member.id 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( config = AgentConfig(
member_id=member.id, member_id=member.id,
capabilities=req.agent_config.capabilities, 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: if req.avatar_url is not None:
member.avatar_url = req.avatar_url 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: if not member.agent_config:
member.agent_config = AgentConfig(member_id=member.id) member.agent_config = AgentConfig(member_id=member.id)
ac = req.agent_config 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() member = result.scalar_one_or_none()
if not member: if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
if member.type != "agent": if member.type != MemberType.AGENT:
raise HTTPException(400, "Only agent tokens can be regenerated") raise HTTPException(400, "Only agent tokens can be regenerated")
token = f"tb-{secrets.token_hex(32)}" token = f"tb-{secrets.token_hex(32)}"
member.token = token member.token = token
@ -224,7 +225,7 @@ async def revoke_token(slug: str, db: AsyncSession = Depends(get_db)):
member = result.scalar_one_or_none() member = result.scalar_one_or_none()
if not member: if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
if member.type != "agent": if member.type != MemberType.AGENT:
raise HTTPException(400, "Only agent tokens can be revoked") raise HTTPException(400, "Only agent tokens can be revoked")
member.token = None member.token = None
await db.commit() await db.commit()

View File

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.enums import AuthorType, ChatKind
from tracker.models import Message, Chat, Attachment from tracker.models import Message, Chat, Attachment
router = APIRouter(tags=["messages"]) router = APIRouter(tags=["messages"])
@ -42,7 +43,7 @@ class MessageCreate(BaseModel):
chat_id: str | None = None chat_id: str | None = None
task_id: str | None = None task_id: str | None = None
parent_id: str | None = None parent_id: str | None = None
author_type: str = "human" author_type: str = AuthorType.HUMAN
author_slug: str = "admin" author_slug: str = "admin"
content: str content: str
mentions: list[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() chat = chat_result.scalar_one_or_none()
if chat and chat.project_id: if chat and chat.project_id:
project_id = str(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( await manager.broadcast_all(
{"type": "message.new", "data": msg_data}, {"type": "message.new", "data": msg_data},
exclude_slug=msg.author_slug, exclude_slug=msg.author_slug,

View File

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.enums import ChatKind, MemberRole, ProjectStatus
from tracker.models import Project, Chat, Member, ProjectMember from tracker.models import Project, Chat, Member, ProjectMember
router = APIRouter(tags=["projects"]) router = APIRouter(tags=["projects"])
@ -58,7 +59,7 @@ class ProjectMemberOut(BaseModel):
async def _project_out(p: Project, db: AsyncSession) -> dict: async def _project_out(p: Project, db: AsyncSession) -> dict:
# Get project chat # Get project chat
chat_result = await db.execute( 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() chat = chat_result.scalar_one_or_none()
return { return {
@ -75,7 +76,7 @@ async def _project_out(p: Project, db: AsyncSession) -> dict:
@router.get("/projects", response_model=list[ProjectOut]) @router.get("/projects", response_model=list[ProjectOut])
async def list_projects(db: AsyncSession = Depends(get_db)): 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()] 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() await db.flush()
# Create project chat # Create project chat
chat = Chat(project_id=project.id, kind="project") chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
db.add(chat) db.add(chat)
await db.commit() await db.commit()
@ -200,7 +201,7 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
project_member = ProjectMember( project_member = ProjectMember(
project_id=project.id, project_id=project.id,
member_id=member.id, member_id=member.id,
role="member" role=MemberRole.MEMBER
) )
db.add(project_member) db.add(project_member)
await db.commit() 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") raise HTTPException(404, "Member not in project")
# Don't allow removing owner # Don't allow removing owner
if project_member.role == "owner": if project_member.role == MemberRole.OWNER:
raise HTTPException(400, "Cannot remove project owner") raise HTTPException(400, "Cannot remove project owner")
await db.delete(project_member) await db.delete(project_member)

View File

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from tracker.database import get_db 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.models import Task, Step, Project, Member, Message, Chat
from tracker.api.auth import get_current_member from tracker.api.auth import get_current_member
@ -49,9 +50,9 @@ class TaskOut(BaseModel):
class TaskCreate(BaseModel): class TaskCreate(BaseModel):
title: str title: str
description: str | None = None description: str | None = None
type: str = "task" type: str = TaskType.TASK
status: str = "backlog" status: str = TaskStatus.BACKLOG
priority: str = "medium" priority: str = TaskPriority.MEDIUM
labels: list[str] = [] labels: list[str] = []
parent_id: str | None = None parent_id: str | None = None
assignee_slug: 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: async def _get_project_chat_id(db: AsyncSession, project_id) -> str | None:
"""Find project chat UUID.""" """Find project chat UUID."""
result = await db.execute( 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() chat = result.scalar_one_or_none()
return chat.id if chat else None return chat.id if chat else None
@ -116,7 +117,7 @@ async def _system_message(
# 1. Task comment — detailed history # 1. Task comment — detailed history
task_msg = Message( task_msg = Message(
task_id=task.id, task_id=task.id,
author_type="system", author_type=AuthorType.SYSTEM,
author_slug=actor_slug, author_slug=actor_slug,
content=task_text, content=task_text,
mentions=[], mentions=[],
@ -128,7 +129,7 @@ async def _system_message(
if chat_id: if chat_id:
chat_msg = Message( chat_msg = Message(
chat_id=chat_id, chat_id=chat_id,
author_type="system", author_type=AuthorType.SYSTEM,
author_slug=actor_slug, author_slug=actor_slug,
content=chat_text, content=chat_text,
mentions=[], mentions=[],
@ -142,7 +143,7 @@ async def _system_message(
"id": str(task_msg.id), "id": str(task_msg.id),
"task_id": str(task.id), "task_id": str(task.id),
"task_key": key, "task_key": key,
"author_type": "system", "author_type": AuthorType.SYSTEM,
"author_slug": actor_slug, "author_slug": actor_slug,
"content": task_text, "content": task_text,
"created_at": task_msg.created_at.isoformat() if task_msg.created_at else now.isoformat(), "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", { await manager.broadcast_task_event(str(task.project_id), "message.new", {
"id": str(chat_msg.id), "id": str(chat_msg.id),
"chat_id": str(chat_id), "chat_id": str(chat_id),
"author_type": "system", "author_type": AuthorType.SYSTEM,
"author_slug": actor_slug, "author_slug": actor_slug,
"content": chat_text, "content": chat_text,
"created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(), "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) task = await _get_task(task_id, db)
if task.assignee_slug: if task.assignee_slug:
raise HTTPException(409, f"Task already assigned to {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") raise HTTPException(409, f"Task status is {task.status}, expected backlog or todo")
task.assignee_slug = slug task.assignee_slug = slug
task.status = "in_progress" task.status = TaskStatus.IN_PROGRESS
# Add to watchers # Add to watchers
if slug not in (task.watchers or []): if slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [slug] 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) task = await _get_task(task_id, db)
old_assignee = task.assignee_slug old_assignee = task.assignee_slug
task.assignee_slug = None task.assignee_slug = None
task.status = "backlog" task.status = TaskStatus.BACKLOG
# Add rejection comment # Add rejection comment
if old_assignee: if old_assignee:
comment = Message( comment = Message(
task_id=task.id, task_id=task.id,
author_type="system", author_type=AuthorType.SYSTEM,
author_slug=old_assignee, author_slug=old_assignee,
content=f"Задача отклонена: {req.reason}", content=f"Задача отклонена: {req.reason}",
mentions=[], mentions=[],
@ -453,7 +454,7 @@ async def reject_task(task_id: str, req: RejectRequest, current_member: Member =
await db.commit() await db.commit()
from tracker.ws.manager import manager from tracker.ws.manager import manager
await manager.broadcast_task_event(str(task.project_id), "task.updated", { 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} 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 import uuid
from tracker.database import engine, async_session 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 from tracker.models import Base, Member, Chat, Project, ProjectMember, AgentConfig
logger = logging.getLogger("tracker.init_db") logger = logging.getLogger("tracker.init_db")
@ -28,11 +29,11 @@ async def init_db():
admin = Member( admin = Member(
name="Admin", name="Admin",
slug="admin", slug="admin",
type="human", type=MemberType.HUMAN,
role="owner", role=MemberRole.OWNER,
auth_method="password", auth_method=AuthMethod.PASSWORD,
password_hash=hash_password("teamboard"), password_hash=hash_password("teamboard"),
status="offline", status=MemberStatus.OFFLINE,
) )
session.add(admin) session.add(admin)
@ -40,11 +41,11 @@ async def init_db():
coder = Member( coder = Member(
name="Coder", name="Coder",
slug="coder", slug="coder",
type="agent", type=MemberType.AGENT,
role="member", role=MemberRole.MEMBER,
auth_method="token", auth_method=AuthMethod.TOKEN,
token="tb-coder-dev-token", token="tb-coder-dev-token",
status="offline", status=MemberStatus.OFFLINE,
) )
session.add(coder) session.add(coder)
@ -52,11 +53,11 @@ async def init_db():
bff_service = Member( bff_service = Member(
name="BFF Service", name="BFF Service",
slug="bff", slug="bff",
type="bridge", type=MemberType.BRIDGE,
role="bridge", role=MemberRole.BRIDGE,
auth_method="token", auth_method=AuthMethod.TOKEN,
token="tb-tracker-dev-token", token="tb-tracker-dev-token",
status="offline", status=MemberStatus.OFFLINE,
) )
session.add(bff_service) session.add(bff_service)
@ -68,14 +69,14 @@ async def init_db():
name="Team Board", name="Team Board",
slug="team-board", slug="team-board",
description="AI agent collaboration platform", description="AI agent collaboration platform",
status="active", status=ProjectStatus.ACTIVE,
) )
session.add(project) session.add(project)
await session.flush() await session.flush()
# Create project chat # Create project chat
project_chat = Chat( project_chat = Chat(
kind="project", kind=ChatKind.PROJECT,
project_id=project.id, project_id=project.id,
) )
session.add(project_chat) session.add(project_chat)
@ -84,20 +85,20 @@ async def init_db():
admin_member = ProjectMember( admin_member = ProjectMember(
project_id=project.id, project_id=project.id,
member_id=admin.id, member_id=admin.id,
role="owner", role=MemberRole.OWNER,
) )
session.add(admin_member) session.add(admin_member)
coder_member = ProjectMember( coder_member = ProjectMember(
project_id=project.id, project_id=project.id,
member_id=coder.id, member_id=coder.id,
role="member", role=MemberRole.MEMBER,
) )
session.add(coder_member) session.add(coder_member)
# Create lobby chat # Create lobby chat
lobby = Chat( lobby = Chat(
kind="lobby", kind=ChatKind.LOBBY,
project_id=None, project_id=None,
) )
session.add(lobby) 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.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.enums import ChatKind
from tracker.models.base import Base from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
@ -18,7 +19,7 @@ class Chat(Base):
__tablename__ = "chats" __tablename__ = "chats"
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id")) 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") project: Mapped["Project | None"] = relationship(back_populates="chats")
messages: Mapped[list["Message"]] = relationship( 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.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType
from tracker.models.base import Base from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
@ -18,12 +19,12 @@ class Member(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, 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 type: Mapped[str] = mapped_column(String(20), nullable=False, default=MemberType.HUMAN) # human | agent
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") # owner | member | observer | bridge 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="password") # password | oauth | token 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)) password_hash: Mapped[str | None] = mapped_column(String(255))
token: Mapped[str | None] = mapped_column(String(255), unique=True) # for agents/bridges 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)) avatar_url: Mapped[str | None] = mapped_column(String(500))
# Agent-specific config (nullable for humans) # Agent-specific config (nullable for humans)
@ -42,8 +43,8 @@ class AgentConfig(Base):
UUID(as_uuid=True), ForeignKey("members.id"), nullable=False, unique=True UUID(as_uuid=True), ForeignKey("members.id"), nullable=False, unique=True
) )
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
chat_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="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) prompt: Mapped[str | None] = mapped_column(Text)
model: Mapped[str | None] = mapped_column(String(100)) 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.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.enums import MemberRole, ProjectStatus
from tracker.models.base import Base from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
@ -22,7 +23,7 @@ class Project(Base):
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
repo_urls: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) 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 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") 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( member_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True 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") project: Mapped["Project"] = relationship(back_populates="members")
member: Mapped["Member"] = relationship() 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.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.enums import TaskPriority, TaskStatus, TaskType
from tracker.models.base import Base from tracker.models.base import Base
if TYPE_CHECKING: 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 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) title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
type: Mapped[str] = mapped_column(String(20), default="task") # task | bug | epic | story type: Mapped[str] = mapped_column(String(20), default=TaskType.TASK) # task | bug | epic | story
status: Mapped[str] = mapped_column(String(20), default="backlog") # backlog | todo | in_progress | in_review | done 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="medium") # critical | high | medium | low priority: Mapped[str] = mapped_column(String(20), default=TaskPriority.MEDIUM) # critical | high | medium | low
labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
assignee_slug: Mapped[str | None] = mapped_column(String(255)) assignee_slug: Mapped[str | None] = mapped_column(String(255))
reviewer_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 sqlalchemy.orm import selectinload
from tracker.database import async_session 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.models import Member, AgentConfig, Chat, Message, Project, ProjectMember
from tracker.ws.manager import ConnectedClient, manager from tracker.ws.manager import ConnectedClient, manager
@ -29,8 +33,8 @@ async def websocket_endpoint(ws: WebSocket, token: str = ""):
else: else:
# Wait for auth message (backward compatibility with agents) # Wait for auth message (backward compatibility with agents)
auth_msg = await ws.receive_json() auth_msg = await ws.receive_json()
if auth_msg.get("type") != "auth": if auth_msg.get("type") != WSEventType.AUTH:
await ws.send_json({"type": "auth.error", "message": "First message must be auth"}) await ws.send_json({"type": WSEventType.AUTH_ERROR, "message": "First message must be auth"})
await ws.close() await ws.close()
return return
@ -48,23 +52,23 @@ async def websocket_endpoint(ws: WebSocket, token: str = ""):
data = await ws.receive_json() data = await ws.receive_json()
msg_type = data.get("type") msg_type = data.get("type")
if msg_type == "heartbeat": if msg_type == WSEventType.HEARTBEAT:
await _handle_heartbeat(session_id, data) await _handle_heartbeat(session_id, data)
elif msg_type == "ack": elif msg_type == WSEventType.ACK:
pass pass
elif msg_type == "chat.send": elif msg_type == WSEventType.CHAT_SEND:
await _handle_chat_send(session_id, data) await _handle_chat_send(session_id, data)
elif msg_type == "project.subscribe": elif msg_type == WSEventType.PROJECT_SUBSCRIBE:
await _handle_subscribe(session_id, data) await _handle_subscribe(session_id, data)
elif msg_type == "project.unsubscribe": elif msg_type == WSEventType.PROJECT_UNSUBSCRIBE:
await _handle_unsubscribe(session_id, data) await _handle_unsubscribe(session_id, data)
else: 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: except WebSocketDisconnect:
if session_id: 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)) result = await db.execute(select(Member).where(Member.slug == client.member_slug))
member = result.scalar_one_or_none() member = result.scalar_one_or_none()
if member: if member:
member.status = "offline" member.status = MemberStatus.OFFLINE
await db.commit() await db.commit()
await manager.broadcast_all( 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, 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) logger.warning("JWT decode failed: %s", e)
if not member: 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() await ws.close()
return None return None
# BFF proxy: bridge acts on behalf of user # BFF proxy: bridge acts on behalf of user
effective_slug = member.slug effective_slug = member.slug
effective_type = member.type 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( user_result = await db.execute(
select(Member).where(Member.slug == on_behalf_of) select(Member).where(Member.slug == on_behalf_of)
.options(selectinload(Member.agent_config)) .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) logger.info("Bridge acting on behalf of unknown user → %s", effective_slug)
# Listen modes # Listen modes
chat_listen = "all" chat_listen = ListenMode.ALL
task_listen = "all" task_listen = ListenMode.ALL
if member.agent_config: if member.agent_config:
chat_listen = member.agent_config.chat_listen chat_listen = member.agent_config.chat_listen
task_listen = member.agent_config.task_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) await manager.connect(client)
# Update status # Update status
member.status = "online" member.status = MemberStatus.ONLINE
await db.commit() await db.commit()
# Get lobby chat + projects # 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() lobby_chat = lobby.scalar_one_or_none()
# Filter projects by membership (show all for owners) # Filter projects by membership (show all for owners)
if member.role == "owner": if member.role == MemberRole.OWNER:
# Owners see all projects # 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: else:
# Members see only projects they belong to # Members see only projects they belong to
projects = await db.execute( projects = await db.execute(
select(Project) select(Project)
.join(ProjectMember, Project.id == ProjectMember.project_id) .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 = [] 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"]) client.subscribed_projects.add(p["id"])
await ws.send_json({ await ws.send_json({
"type": "auth.ok", "type": WSEventType.AUTH_OK,
"data": { "data": {
"slug": effective_slug, "slug": effective_slug,
"lobby_chat_id": str(lobby_chat.id) if lobby_chat else None, "lobby_chat_id": str(lobby_chat.id) if lobby_chat else None,
"projects": project_list, "projects": project_list,
"online": manager.online_slugs, MemberStatus.ONLINE: manager.online_slugs,
}, },
}) })
# Notify others # Notify others
await manager.broadcast_all( 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, exclude_slug=effective_slug,
) )
@ -230,7 +234,7 @@ async def _handle_heartbeat(session_id: str, data: dict):
if not client: if not client:
return return
status = data.get("status", "online") status = data.get("status", MemberStatus.ONLINE)
client.last_heartbeat = datetime.now(timezone.utc) client.last_heartbeat = datetime.now(timezone.utc)
async with async_session() as db: async with async_session() as db:
@ -241,7 +245,7 @@ async def _handle_heartbeat(session_id: str, data: dict):
await db.commit() await db.commit()
await manager.broadcast_all( 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, 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() chat = chat_result.scalar_one_or_none()
if chat and chat.project_id: if chat and chat.project_id:
project_id = str(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( await manager.broadcast_all(
{"type": "message.new", "data": msg_data}, {"type": WSEventType.MESSAGE_NEW, "data": msg_data},
exclude_slug=slug, exclude_slug=slug,
) )
return 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) await manager.broadcast_message(project_id, msg_data, author_slug=slug)
else: else:
await manager.broadcast_all( await manager.broadcast_all(
{"type": "message.new", "data": msg_data}, {"type": WSEventType.MESSAGE_NEW, "data": msg_data},
exclude_slug=slug, exclude_slug=slug,
) )

View File

@ -7,6 +7,8 @@ from datetime import datetime, timezone
from fastapi import WebSocket from fastapi import WebSocket
from tracker.enums import AuthorType, ListenMode, MemberType, WSEventType
logger = logging.getLogger("tracker.ws") logger = logging.getLogger("tracker.ws")
@ -16,8 +18,8 @@ class ConnectedClient:
session_id: str # unique per connection session_id: str # unique per connection
member_slug: str member_slug: str
member_type: str # human | agent | bridge member_type: str # human | agent | bridge
chat_listen: str = "all" # all | mentions | none chat_listen: str = ListenMode.ALL
task_listen: str = "all" # all | mentions | none task_listen: str = ListenMode.ALL
subscribed_projects: set[str] = field(default_factory=set) subscribed_projects: set[str] = field(default_factory=set)
last_heartbeat: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) last_heartbeat: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@ -89,28 +91,28 @@ class ConnectionManager:
mentions = message.get("mentions", []) mentions = message.get("mentions", [])
content = message.get("content", "") content = message.get("content", "")
author_type = message.get("author_type", "") 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()): for session_id, client in list(self.sessions.items()):
if client.member_slug == author_slug: if client.member_slug == author_slug:
continue continue
# Humans/bridges get ALL messages # 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) await self.send_to_session(session_id, payload)
continue continue
# Agents: subscription check # Agents: subscription check
if project_id not in client.subscribed_projects: if project_id not in client.subscribed_projects:
continue continue
if client.chat_listen == "none": if client.chat_listen == ListenMode.NONE:
continue continue
# System messages: only if agent is mentioned in content # 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: if f"@{client.member_slug}" not in content:
continue continue
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
continue continue
# Regular messages: chat_listen filter # 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 continue
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
@ -123,15 +125,15 @@ class ConnectionManager:
for session_id, client in list(self.sessions.items()): for session_id, client in list(self.sessions.items()):
# Humans/bridges get ALL task events # 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) await self.send_to_session(session_id, payload)
continue continue
# Agents: subscription + task_listen # Agents: subscription + task_listen
if project_id not in client.subscribed_projects: if project_id not in client.subscribed_projects:
continue continue
if client.task_listen == "none": if client.task_listen == ListenMode.NONE:
continue continue
if client.task_listen == "all": if client.task_listen == ListenMode.ALL:
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
continue continue
if client.member_slug in (assignee, reviewer) or client.member_slug in watchers: if client.member_slug in (assignee, reviewer) or client.member_slug in watchers: