Labels, auto-assign, subscription modes, init handshake
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s

- Label + TaskLabel models (project-scoped labels with colors)
- Labels API: CRUD for labels + task-label associations
- TaskLinkType.ASSIGNED subscription mode
- Auto-assign: task labels matched against agent capabilities
- auth.ok: includes assigned_tasks for agents
- ListenMode.ASSIGNED added to enums
This commit is contained in:
markov 2026-02-27 23:45:43 +01:00
parent a65987a724
commit 93cdd38b96
8 changed files with 181 additions and 6 deletions

121
src/tracker/api/labels.py Normal file
View File

@ -0,0 +1,121 @@
"""Labels API — CRUD for project-scoped labels."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import Label, TaskLabel, Member
from .auth import get_current_member
router = APIRouter(prefix="/api/v1", tags=["labels"])
class LabelCreate(BaseModel):
name: str
color: str = "#6366f1"
class LabelUpdate(BaseModel):
name: str | None = None
color: str | None = None
class LabelOut(BaseModel):
id: str
project_id: str
name: str
color: str
@router.get("/projects/{project_id}/labels", response_model=list[LabelOut])
async def list_labels(project_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Label).where(Label.project_id == uuid.UUID(project_id)).order_by(Label.name)
)
return [LabelOut(id=str(l.id), project_id=str(l.project_id), name=l.name, color=l.color) for l in result.scalars()]
@router.post("/projects/{project_id}/labels", response_model=LabelOut)
async def create_label(
project_id: str,
req: LabelCreate,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
label = Label(project_id=uuid.UUID(project_id), name=req.name, color=req.color)
db.add(label)
await db.commit()
await db.refresh(label)
return LabelOut(id=str(label.id), project_id=str(label.project_id), name=label.name, color=label.color)
@router.patch("/labels/{label_id}", response_model=LabelOut)
async def update_label(
label_id: str,
req: LabelUpdate,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Label).where(Label.id == uuid.UUID(label_id)))
label = result.scalar_one_or_none()
if not label:
raise HTTPException(404, "Label not found")
if req.name is not None:
label.name = req.name
if req.color is not None:
label.color = req.color
await db.commit()
await db.refresh(label)
return LabelOut(id=str(label.id), project_id=str(label.project_id), name=label.name, color=label.color)
@router.delete("/labels/{label_id}")
async def delete_label(
label_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Label).where(Label.id == uuid.UUID(label_id)))
label = result.scalar_one_or_none()
if not label:
raise HTTPException(404, "Label not found")
# Remove task associations
await db.execute(select(TaskLabel).where(TaskLabel.label_id == label.id))
await db.delete(label)
await db.commit()
return {"ok": True}
# Task-label associations
@router.post("/tasks/{task_id}/labels/{label_id}")
async def add_task_label(
task_id: str,
label_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
tl = TaskLabel(task_id=uuid.UUID(task_id), label_id=uuid.UUID(label_id))
db.add(tl)
await db.commit()
return {"ok": True}
@router.delete("/tasks/{task_id}/labels/{label_id}")
async def remove_task_label(
task_id: str,
label_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(TaskLabel).where(TaskLabel.task_id == uuid.UUID(task_id), TaskLabel.label_id == uuid.UUID(label_id))
)
tl = result.scalar_one_or_none()
if tl:
await db.delete(tl)
await db.commit()
return {"ok": True}

View File

@ -12,7 +12,7 @@ from sqlalchemy.orm import selectinload, joinedload
from ..database import get_db from ..database import get_db
from ..enums import ( from ..enums import (
AuthorType, ChatKind, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType, AuthorType, ChatKind, MemberType, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType,
) )
from ..models import Task, Step, Project, Member, Message, Chat, TaskAction, TaskLink from ..models import Task, Step, Project, Member, Message, Chat, TaskAction, TaskLink
from .auth import get_current_member from .auth import get_current_member
@ -282,6 +282,26 @@ async def create_task(
db.add(task) db.add(task)
await db.flush() await db.flush()
# Auto-assign by capabilities if no assignee set
if not assignee_id and req.labels:
from ..models import AgentConfig, ProjectMember
agents_q = await db.execute(
select(Member, AgentConfig)
.join(AgentConfig, AgentConfig.member_id == Member.id)
.join(ProjectMember, ProjectMember.member_id == Member.id)
.where(
ProjectMember.project_id == project.id,
Member.type == MemberType.AGENT,
Member.is_active == True,
)
)
for agent, config in agents_q:
caps = config.capabilities or []
if any(label in caps for label in req.labels):
task.assignee_id = agent.id
task.watcher_ids = [agent.id]
break
await _record_action(db, task, current_member, TaskActionType.CREATED) await _record_action(db, task, current_member, TaskActionType.CREATED)
await db.commit() await db.commit()

View File

@ -169,7 +169,7 @@ app.add_middleware(
) )
# Routers # Routers
from .api import auth, members, projects, tasks, messages, steps, attachments, project_files # noqa: E402 from .api import auth, members, projects, tasks, messages, steps, attachments, project_files, labels # noqa: E402
from .ws.handler import router as ws_router # noqa: E402 from .ws.handler import router as ws_router # noqa: E402
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
@ -180,6 +180,7 @@ app.include_router(messages.router, prefix="/api/v1")
app.include_router(steps.router, prefix="/api/v1") app.include_router(steps.router, prefix="/api/v1")
app.include_router(attachments.router, prefix="/api/v1") app.include_router(attachments.router, prefix="/api/v1")
app.include_router(project_files.router, prefix="/api/v1") app.include_router(project_files.router, prefix="/api/v1")
app.include_router(labels.router)
app.include_router(ws_router) app.include_router(ws_router)

View File

@ -40,6 +40,7 @@ class ChatKind(StrEnum):
class ListenMode(StrEnum): class ListenMode(StrEnum):
ALL = "all" ALL = "all"
MENTIONS = "mentions" MENTIONS = "mentions"
ASSIGNED = "assigned" # assigned tasks + mentions
NONE = "none" NONE = "none"

View File

@ -3,7 +3,7 @@
from .base import Base from .base import Base
from .member import AgentConfig, Member from .member import AgentConfig, Member
from .project import Project, ProjectMember from .project import Project, ProjectMember
from .task import Step, Task, TaskAction, TaskLink from .task import Label, Step, Task, TaskAction, TaskLabel, TaskLink
from .chat import Attachment, Chat, Message from .chat import Attachment, Chat, Message
from .project_file import ProjectFile from .project_file import ProjectFile

View File

@ -53,6 +53,25 @@ class Step(Base):
task: Mapped["Task"] = relationship(back_populates="steps") task: Mapped["Task"] = relationship(back_populates="steps")
class Label(Base):
"""Project-scoped labels (like Jira)."""
__tablename__ = "labels"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), default="#6366f1") # hex color
project: Mapped["Project"] = relationship()
class TaskLabel(Base):
"""Many-to-many: tasks ↔ labels."""
__tablename__ = "task_labels"
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
label_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), nullable=False)
class TaskLink(Base): class TaskLink(Base):
"""Jira-style task dependencies/links.""" """Jira-style task dependencies/links."""
__tablename__ = "task_links" __tablename__ = "task_links"

View File

@ -241,7 +241,20 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No
"online": online_list, "online": online_list,
} }
# For agents: include full config from DB # For agents: include assigned tasks + config
if effective_member.type == MemberType.AGENT:
from ..models import Task as TaskModel
assigned_q = await db.execute(
select(TaskModel).where(
TaskModel.assignee_id == effective_member.id,
TaskModel.status.in_(["todo", "in_progress", "in_review"]),
)
)
auth_data["assigned_tasks"] = [
{"id": str(t.id), "title": t.title, "status": t.status, "project_id": str(t.project_id)}
for t in assigned_q.scalars()
]
if effective_member.agent_config: if effective_member.agent_config:
ac = effective_member.agent_config ac = effective_member.agent_config
auth_data["agent_config"] = { auth_data["agent_config"] = {

View File

@ -144,7 +144,7 @@ class ConnectionManager:
continue continue
# Regular messages: chat_listen filter # Regular messages: chat_listen filter
if client.chat_listen == ListenMode.MENTIONS: if client.chat_listen in (ListenMode.MENTIONS, ListenMode.ASSIGNED):
if client.member_id not in mention_ids and client.member_slug not in mention_slugs: if client.member_id not in mention_ids and client.member_slug not in mention_slugs:
continue continue
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
@ -171,7 +171,7 @@ class ConnectionManager:
if client.task_listen == ListenMode.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
# For "mentions" mode: check if agent is assignee/reviewer/watcher # For "mentions"/"assigned" mode: check if agent is assignee/reviewer/watcher
if client.member_id and ( if client.member_id and (
client.member_id in (assignee_id, reviewer_id) or client.member_id in (assignee_id, reviewer_id) or
client.member_id in watcher_ids client.member_id in watcher_ids