feat(agents): Add task assignment and comments functionality

This commit is contained in:
Abhimanyu Saharan
2026-02-04 18:13:17 +05:30
parent 8078580996
commit 7892dfad7c
9 changed files with 1882 additions and 6 deletions

View File

@@ -0,0 +1,36 @@
"""add task assigned agent
Revision ID: 8045fbfb157f
Revises: 6df47d330227
Create Date: 2026-02-04 17:28:57.465934
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = '8045fbfb157f'
down_revision = '6df47d330227'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_agent_id UUID"
)
op.execute(
"ALTER TABLE tasks ADD CONSTRAINT IF NOT EXISTS tasks_assigned_agent_id_fkey "
"FOREIGN KEY (assigned_agent_id) REFERENCES agents(id)"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_assigned_agent_id_fkey"
)
op.execute(
"ALTER TABLE tasks DROP COLUMN IF EXISTS assigned_agent_id"
)

View File

@@ -0,0 +1,29 @@
"""add task comments index
Revision ID: b9d22e2a4d50
Revises: 8045fbfb157f
Create Date: 2026-02-04 17:32:06.204331
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b9d22e2a4d50'
down_revision = '8045fbfb157f'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_activity_events_task_comment "
"ON activity_events (task_id, created_at) "
"WHERE event_type = 'task.comment'"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_activity_events_task_comment")

View File

@@ -3,7 +3,8 @@ from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from sqlalchemy import asc
from sqlmodel import Session, col, select
from app.api.deps import (
ActorContext,
@@ -14,9 +15,17 @@ from app.api.deps import (
)
from app.core.auth import AuthContext
from app.db.session import get_session
from app.models.agents import Agent
from app.models.activity_events import ActivityEvent
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
from app.schemas.tasks import (
TaskCommentCreate,
TaskCommentRead,
TaskCreate,
TaskRead,
TaskUpdate,
)
from app.services.activity_log import record_activity
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
@@ -66,9 +75,25 @@ def update_task(
previous_status = task.status
updates = payload.model_dump(exclude_unset=True)
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_fields = {"status"}
if not set(updates).issubset(allowed_fields):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates:
if updates["status"] == "inbox":
task.assigned_agent_id = None
else:
task.assigned_agent_id = actor.agent.id if actor.agent else None
elif "status" in updates and updates["status"] == "inbox":
task.assigned_agent_id = None
if "assigned_agent_id" in updates and updates["assigned_agent_id"]:
agent = session.get(Agent, updates["assigned_agent_id"])
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if agent.board_id and task.board_id and agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
for key, value in updates.items():
setattr(task, key, value)
task.updated_at = datetime.utcnow()
@@ -103,3 +128,45 @@ def delete_task(
session.delete(task)
session.commit()
return {"ok": True}
@router.get("/{task_id}/comments", response_model=list[TaskCommentRead])
def list_task_comments(
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[ActivityEvent]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(ActivityEvent)
.where(col(ActivityEvent.task_id) == task.id)
.where(col(ActivityEvent.event_type) == "task.comment")
.order_by(asc(col(ActivityEvent.created_at)))
)
return list(session.exec(statement))
@router.post("/{task_id}/comments", response_model=TaskCommentRead)
def create_task_comment(
payload: TaskCommentCreate,
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip():
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
event = ActivityEvent(
event_type="task.comment",
message=payload.message.strip(),
task_id=task.id,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.add(event)
session.commit()
session.refresh(event)
return event

View File

@@ -21,6 +21,7 @@ class Task(TenantScoped, table=True):
due_at: datetime | None = None
created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -12,6 +12,7 @@ class TaskBase(SQLModel):
status: str = "inbox"
priority: str = "medium"
due_at: datetime | None = None
assigned_agent_id: UUID | None = None
class TaskCreate(TaskBase):
@@ -24,6 +25,7 @@ class TaskUpdate(SQLModel):
status: str | None = None
priority: str | None = None
due_at: datetime | None = None
assigned_agent_id: UUID | None = None
class TaskRead(TaskBase):
@@ -32,3 +34,15 @@ class TaskRead(TaskBase):
created_by_user_id: UUID | None
created_at: datetime
updated_at: datetime
class TaskCommentCreate(SQLModel):
message: str
class TaskCommentRead(SQLModel):
id: UUID
message: str | None
agent_id: UUID | None
task_id: UUID | None
created_at: datetime

1549
docs/openclaw_gateway_ws.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,21 @@ type Task = {
status: string;
priority: string;
due_at?: string | null;
assigned_agent_id?: string | null;
};
type Agent = {
id: string;
name: string;
board_id?: string | null;
};
type TaskComment = {
id: string;
message?: string | null;
agent_id?: string | null;
task_id?: string | null;
created_at: string;
};
const apiBase =
@@ -61,8 +76,14 @@ export default function BoardDetailPage() {
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]);
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [isCommentsOpen, setIsCommentsOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState("");
@@ -82,7 +103,7 @@ export default function BoardDetailPage() {
setError(null);
try {
const token = await getToken();
const [boardResponse, tasksResponse] = await Promise.all([
const [boardResponse, tasksResponse, agentsResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
@@ -93,6 +114,11 @@ export default function BoardDetailPage() {
Authorization: token ? `Bearer ${token}` : "",
},
}),
fetch(`${apiBase}/api/v1/agents`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
]);
if (!boardResponse.ok) {
@@ -101,11 +127,16 @@ export default function BoardDetailPage() {
if (!tasksResponse.ok) {
throw new Error("Unable to load tasks.");
}
if (!agentsResponse.ok) {
throw new Error("Unable to load agents.");
}
const boardData = (await boardResponse.json()) as Board;
const taskData = (await tasksResponse.json()) as Task[];
const agentData = (await agentsResponse.json()) as Agent[];
setBoard(boardData);
setTasks(taskData);
setAgents(agentData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -165,6 +196,74 @@ export default function BoardDetailPage() {
}
};
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
agents
.filter((agent) => !boardId || agent.board_id === boardId)
.forEach((agent) => {
map.set(agent.id, agent.name);
});
return map;
}, [agents, boardId]);
const displayTasks = useMemo(
() =>
tasks.map((task) => ({
...task,
assignee: task.assigned_agent_id
? assigneeById.get(task.assigned_agent_id)
: undefined,
})),
[tasks, assigneeById],
);
const loadComments = async (taskId: string) => {
if (!isSignedIn || !boardId) return;
setIsCommentsLoading(true);
setCommentsError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}/comments`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
},
);
if (!response.ok) {
throw new Error("Unable to load comments.");
}
const data = (await response.json()) as TaskComment[];
setComments(data);
} catch (err) {
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCommentsLoading(false);
}
};
const openComments = (task: Task) => {
setSelectedTask(task);
setIsCommentsOpen(true);
void loadComments(task.id);
};
const closeComments = () => {
setIsCommentsOpen(false);
setSelectedTask(null);
setComments([]);
setCommentsError(null);
};
const formatCommentTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<DashboardShell>
@@ -215,14 +314,72 @@ export default function BoardDetailPage() {
</div>
) : (
<TaskBoard
tasks={tasks}
tasks={displayTasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
onTaskSelect={openComments}
/>
)}
</div>
</SignedIn>
<Dialog open={isCommentsOpen} onOpenChange={(open) => {
if (!open) {
closeComments();
}
}}>
<DialogContent aria-label="Task comments">
<DialogHeader>
<DialogTitle>{selectedTask?.title ?? "Task"}</DialogTitle>
<DialogDescription>
{selectedTask?.description || "Task details and discussion."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Comments
</div>
{isCommentsLoading ? (
<p className="text-sm text-muted">Loading comments</p>
) : commentsError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{commentsError}
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted">No comments yet.</p>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3"
>
<div className="flex items-center justify-between text-xs text-muted">
<span>
{comment.agent_id
? assigneeById.get(comment.agent_id) ?? "Agent"
: "Admin"}
</span>
<span>{formatCommentTimestamp(comment.created_at)}</span>
</div>
<p className="mt-2 text-sm text-strong">
{comment.message || "—"}
</p>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeComments}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {

View File

@@ -8,11 +8,29 @@ interface TaskCardProps {
status: string;
assignee?: string;
due?: string;
onClick?: () => void;
}
export function TaskCard({ title, status, assignee, due }: TaskCardProps) {
export function TaskCard({
title,
status,
assignee,
due,
onClick,
}: TaskCardProps) {
return (
<Card className="border border-[color:var(--border)] bg-[color:var(--surface)]">
<Card
className="cursor-pointer border border-[color:var(--border)] bg-[color:var(--surface)] transition hover:border-[color:var(--border-strong)]"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick?.();
}
}}
>
<CardContent className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">

View File

@@ -10,12 +10,14 @@ type Task = {
title: string;
status: string;
due_at?: string | null;
assignee?: string;
};
type TaskBoardProps = {
tasks: Task[];
onCreateTask: () => void;
isCreateDisabled?: boolean;
onTaskSelect?: (task: Task) => void;
};
const columns = [
@@ -41,6 +43,7 @@ export function TaskBoard({
tasks,
onCreateTask,
isCreateDisabled = false,
onTaskSelect,
}: TaskBoardProps) {
const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {};
@@ -85,7 +88,9 @@ export function TaskBoard({
key={task.id}
title={task.title}
status={column.status}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)}
/>
))}
</div>