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 ..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 .auth import get_current_member
@ -282,6 +282,26 @@ async def create_task(
db.add(task)
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 db.commit()

View File

@ -169,7 +169,7 @@ app.add_middleware(
)
# 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
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(attachments.router, prefix="/api/v1")
app.include_router(project_files.router, prefix="/api/v1")
app.include_router(labels.router)
app.include_router(ws_router)

View File

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

View File

@ -3,7 +3,7 @@
from .base import Base
from .member import AgentConfig, Member
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 .project_file import ProjectFile

View File

@ -53,6 +53,25 @@ class Step(Base):
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):
"""Jira-style task dependencies/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,
}
# 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:
ac = effective_member.agent_config
auth_data["agent_config"] = {

View File

@ -144,7 +144,7 @@ class ConnectionManager:
continue
# 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:
continue
await self.send_to_session(session_id, payload)
@ -171,7 +171,7 @@ class ConnectionManager:
if client.task_listen == ListenMode.ALL:
await self.send_to_session(session_id, payload)
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 (
client.member_id in (assignee_id, reviewer_id) or
client.member_id in watcher_ids