From 8429c02458810b548a4340fadfa91fde5a80527f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:18:34 +0530 Subject: [PATCH] UI provisioning + restrict task assignment to provisioned agents --- backend/app/api/work.py | 25 +++++++++++++++++++++++ frontend/src/app/people/page.tsx | 27 ++++++++++++++++++++++++- frontend/src/app/projects/[id]/page.tsx | 5 +++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/backend/app/api/work.py b/backend/app/api/work.py index d1f87aa3..dc584696 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -18,6 +18,26 @@ router = APIRouter(tags=["work"]) ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"} +def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None: + """Enforce that only provisioned agents can be assigned tasks. + + Humans can be assigned regardless. + Agents must be active, notify_enabled, and have openclaw_session_key. + """ + + emp = session.get(Employee, assignee_employee_id) + if emp is None: + raise HTTPException(status_code=400, detail="Assignee employee not found") + + if emp.employee_type == "agent": + if emp.status != "active": + raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent") + if not emp.notify_enabled: + raise HTTPException(status_code=400, detail="Cannot assign task to agent with notifications disabled") + if not emp.openclaw_session_key: + raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent") + + @router.get("/tasks", response_model=list[Task]) def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)): stmt = select(Task).order_by(Task.id.asc()) @@ -31,6 +51,9 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi if payload.created_by_employee_id is None: payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}) + if payload.assignee_employee_id is not None: + _validate_task_assignee(session, payload.assignee_employee_id) + # Default reviewer to the manager of the assignee (if not explicitly provided). if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None: assignee = session.get(Employee, payload.assignee_employee_id) @@ -73,6 +96,8 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, before = {"assignee_employee_id": task.assignee_employee_id, "reviewer_employee_id": task.reviewer_employee_id, "status": task.status} data = payload.model_dump(exclude_unset=True) + if "assignee_employee_id" in data and data["assignee_employee_id"] is not None: + _validate_task_assignee(session, data["assignee_employee_id"]) if "status" in data and data["status"] not in ALLOWED_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx index 07613527..b5cbc77c 100644 --- a/frontend/src/app/people/page.tsx +++ b/frontend/src/app/people/page.tsx @@ -14,9 +14,19 @@ import { useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet, useListTeamsTeamsGet, + useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost, + useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost, } from "@/api/generated/org/org"; export default function PeoplePage() { + const [actorId] = useState(() => { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem("actor_employee_id") ?? ""; + } catch { + return ""; + } + }); const [name, setName] = useState(""); const [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [title, setTitle] = useState(""); @@ -31,15 +41,30 @@ export default function PeoplePage() { const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]); const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]); + const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost(); + const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost(); + const createEmployee = useCreateEmployeeEmployeesPost({ mutation: { - onSuccess: () => { + onSuccess: async (res) => { setName(""); setTitle(""); setDepartmentId(""); setTeamId(""); setManagerId(""); + + // If an agent was created but not yet provisioned, provision immediately so it can receive tasks. + try { + const e = (res as any)?.data?.data ?? (res as any)?.data ?? null; + if (e?.employee_type === "agent" && !e.openclaw_session_key) { + await provisionEmployee.mutateAsync({ employeeId: e.id! }); + } + } catch { + // ignore; UI will show unprovisioned state + } + employees.refetch(); + teams.refetch(); }, }, }); diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index e4aacdcb..c1fe7dff 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -51,6 +51,7 @@ export default function ProjectDetailPage() { const employees = useListEmployeesEmployeesGet(); const employeeList = employees.data?.status === 200 ? employees.data.data : []; + const eligibleAssignees = employeeList.filter((e) => e.employee_type !== "agent" || !!e.openclaw_session_key); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const memberList = members.data?.status === 200 ? members.data.data : []; @@ -154,7 +155,7 @@ export default function ProjectDetailPage() {
@@ -191,7 +192,7 @@ export default function ProjectDetailPage() { e.currentTarget.value = ""; }}> - {employeeList.map((e) => ( + {eligibleAssignees.map((e) => ( ))}