refactor: заменены хардкод строки на enums
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
This commit is contained in:
parent
41d98cad88
commit
daf3f06dcb
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
87
src/tracker/enums.py
Normal 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"
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user