UI provisioning + restrict task assignment to provisioned agents
This commit is contained in:
@@ -18,6 +18,26 @@ router = APIRouter(tags=["work"])
|
|||||||
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
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])
|
@router.get("/tasks", response_model=list[Task])
|
||||||
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
||||||
stmt = select(Task).order_by(Task.id.asc())
|
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:
|
if payload.created_by_employee_id is None:
|
||||||
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id})
|
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).
|
# 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:
|
if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
|
||||||
assignee = session.get(Employee, payload.assignee_employee_id)
|
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}
|
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)
|
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:
|
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ import {
|
|||||||
useListDepartmentsDepartmentsGet,
|
useListDepartmentsDepartmentsGet,
|
||||||
useListEmployeesEmployeesGet,
|
useListEmployeesEmployeesGet,
|
||||||
useListTeamsTeamsGet,
|
useListTeamsTeamsGet,
|
||||||
|
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
|
||||||
|
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
|
||||||
} from "@/api/generated/org/org";
|
} from "@/api/generated/org/org";
|
||||||
|
|
||||||
export default function PeoplePage() {
|
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 [name, setName] = useState("");
|
||||||
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
|
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
|
||||||
const [title, setTitle] = useState("");
|
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 employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
|
||||||
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
|
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
|
||||||
|
|
||||||
|
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
|
||||||
|
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
|
||||||
|
|
||||||
const createEmployee = useCreateEmployeeEmployeesPost({
|
const createEmployee = useCreateEmployeeEmployeesPost({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: () => {
|
onSuccess: async (res) => {
|
||||||
setName("");
|
setName("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDepartmentId("");
|
setDepartmentId("");
|
||||||
setTeamId("");
|
setTeamId("");
|
||||||
setManagerId("");
|
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();
|
employees.refetch();
|
||||||
|
teams.refetch();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
const employees = useListEmployeesEmployeesGet();
|
const employees = useListEmployeesEmployeesGet();
|
||||||
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
|
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 members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
|
||||||
const memberList = members.data?.status === 200 ? members.data.data : [];
|
const memberList = members.data?.status === 200 ? members.data.data : [];
|
||||||
@@ -154,7 +155,7 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
||||||
<option value="">Assignee</option>
|
<option value="">Assignee</option>
|
||||||
{employeeList.map((e) => (
|
{eligibleAssignees.map((e) => (
|
||||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -191,7 +192,7 @@ export default function ProjectDetailPage() {
|
|||||||
e.currentTarget.value = "";
|
e.currentTarget.value = "";
|
||||||
}}>
|
}}>
|
||||||
<option value="">Add member…</option>
|
<option value="">Add member…</option>
|
||||||
{employeeList.map((e) => (
|
{eligibleAssignees.map((e) => (
|
||||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Reference in New Issue
Block a user