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 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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
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
|
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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user