diff --git a/backend/app/api/hr.py b/backend/app/api/hr.py index 158c8d88..a67b26c9 100644 --- a/backend/app/api/hr.py +++ b/backend/app/api/hr.py @@ -1,13 +1,23 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Header -from sqlmodel import Session, select -from sqlalchemy.exc import IntegrityError +from datetime import datetime -from app.api.utils import log_activity, get_actor_employee_id +from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from app.api.utils import get_actor_employee_id, log_activity from app.db.session import get_session -from app.models.hr import EmploymentAction, HeadcountRequest, AgentOnboarding -from app.schemas.hr import EmploymentActionCreate, HeadcountRequestCreate, HeadcountRequestUpdate, AgentOnboardingCreate, AgentOnboardingUpdate +from app.integrations.openclaw import OpenClawClient +from app.models.hr import AgentOnboarding, EmploymentAction, HeadcountRequest +from app.models.org import Employee +from app.schemas.hr import ( + AgentOnboardingCreate, + AgentOnboardingUpdate, + EmploymentActionCreate, + HeadcountRequestCreate, + HeadcountRequestUpdate, +) router = APIRouter(prefix="/hr", tags=["hr"]) @@ -18,7 +28,11 @@ def list_headcount_requests(session: Session = Depends(get_session)): @router.post("/headcount", response_model=HeadcountRequest) -def create_headcount_request(payload: HeadcountRequestCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def create_headcount_request( + payload: HeadcountRequestCreate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): req = HeadcountRequest(**payload.model_dump()) session.add(req) session.commit() @@ -29,7 +43,12 @@ def create_headcount_request(payload: HeadcountRequestCreate, session: Session = @router.patch("/headcount/{request_id}", response_model=HeadcountRequest) -def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_headcount_request( + request_id: int, + payload: HeadcountRequestUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): req = session.get(HeadcountRequest, request_id) if not req: raise HTTPException(status_code=404, detail="Request not found") @@ -37,6 +56,7 @@ def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, s data = payload.model_dump(exclude_unset=True) if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None: req.fulfilled_at = datetime.utcnow() + for k, v in data.items(): setattr(req, k, v) @@ -85,7 +105,7 @@ def create_employment_action( session.commit() except IntegrityError: session.rollback() - # if unique constraint on idempotency_key raced + # If unique constraint on idempotency_key raced if payload.idempotency_key: existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first() if existing: @@ -95,34 +115,48 @@ def create_employment_action( session.refresh(action) return EmploymentAction.model_validate(action) + @router.get("/onboarding", response_model=list[AgentOnboarding]) def list_agent_onboarding(session: Session = Depends(get_session)): return session.exec(select(AgentOnboarding).order_by(AgentOnboarding.id.desc())).all() @router.post("/onboarding", response_model=AgentOnboarding) -def create_agent_onboarding(payload: AgentOnboardingCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def create_agent_onboarding( + payload: AgentOnboardingCreate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): item = AgentOnboarding(**payload.model_dump()) session.add(item) session.commit() session.refresh(item) - log_activity(session, actor_employee_id=actor_employee_id, entity_type="agent_onboarding", entity_id=item.id, verb="created", payload={"agent_name": item.agent_name, "status": item.status}) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="agent_onboarding", + entity_id=item.id, + verb="created", + payload={"agent_name": item.agent_name, "status": item.status}, + ) session.commit() return item @router.patch("/onboarding/{onboarding_id}", response_model=AgentOnboarding) -def update_agent_onboarding(onboarding_id: int, payload: AgentOnboardingUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_agent_onboarding( + onboarding_id: int, + payload: AgentOnboardingUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): item = session.get(AgentOnboarding, onboarding_id) if not item: raise HTTPException(status_code=404, detail="Onboarding record not found") data = payload.model_dump(exclude_unset=True) - if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None: - req.fulfilled_at = datetime.utcnow() for k, v in data.items(): setattr(item, k, v) - from datetime import datetime item.updated_at = datetime.utcnow() session.add(item) @@ -132,3 +166,152 @@ def update_agent_onboarding(onboarding_id: int, payload: AgentOnboardingUpdate, session.commit() return item + +@router.post("/onboarding/{onboarding_id}/provision", response_model=AgentOnboarding) +def provision_agent_onboarding( + onboarding_id: int, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + """Provision an agent *session* via OpenClaw and wire it back into Mission Control. + + This removes the need for cron-based HR provisioning. + """ + + item = session.get(AgentOnboarding, onboarding_id) + if not item: + raise HTTPException(status_code=404, detail="Onboarding record not found") + + if item.employee_id is None: + raise HTTPException(status_code=400, detail="Onboarding must be linked to an employee_id before provisioning") + + client = OpenClawClient.from_env() + if client is None: + raise HTTPException(status_code=503, detail="OPENCLAW_GATEWAY_URL/TOKEN not configured") + + # Mark as spawning + item.status = "spawning" + item.updated_at = datetime.utcnow() + session.add(item) + session.commit() + session.refresh(item) + + label = f"onboarding:{item.id}:{item.agent_name}" + + try: + resp = client.tools_invoke( + "sessions_spawn", + { + "task": item.prompt, + "label": label, + "agentId": "main", + "cleanup": "keep", + "runTimeoutSeconds": 600, + }, + timeout_s=20.0, + ) + except Exception as e: + item.status = "blocked" + item.notes = (item.notes or "") + f"\nProvision failed: {type(e).__name__}: {e}" + item.updated_at = datetime.utcnow() + session.add(item) + session.commit() + session.refresh(item) + return item + + session_key = None + if isinstance(resp, dict): + session_key = resp.get("sessionKey") or (resp.get("result") or {}).get("sessionKey") + + if not session_key: + item.status = "spawned" + item.notes = (item.notes or "") + "\nProvisioned via OpenClaw, but session_key was not returned; follow up required." + item.updated_at = datetime.utcnow() + session.add(item) + session.commit() + session.refresh(item) + return item + + # Write linkage + item.session_key = session_key + item.spawned_agent_id = item.agent_name + item.status = "verified" + item.updated_at = datetime.utcnow() + session.add(item) + + emp = session.get(Employee, item.employee_id) + if emp is not None: + emp.openclaw_session_key = session_key + emp.notify_enabled = True + session.add(emp) + + session.commit() + session.refresh(item) + + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="agent_onboarding", + entity_id=item.id, + verb="provisioned", + payload={"session_key": session_key, "label": label}, + ) + session.commit() + + return item + + +@router.post("/onboarding/{onboarding_id}/deprovision", response_model=AgentOnboarding) +def deprovision_agent_onboarding( + onboarding_id: int, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + """Best-effort deprovision: disable notifications and ask the agent session to stop. + + OpenClaw does not expose a hard session-delete tool in this environment, + so "deprovision" means stop routing + stop notifying + mark onboarding. + """ + + item = session.get(AgentOnboarding, onboarding_id) + if not item: + raise HTTPException(status_code=404, detail="Onboarding record not found") + + client = OpenClawClient.from_env() + + # Disable employee notifications regardless of OpenClaw availability + if item.employee_id is not None: + emp = session.get(Employee, item.employee_id) + if emp is not None: + emp.notify_enabled = False + session.add(emp) + + # Ask the agent session to stop (best-effort) + if client is not None and item.session_key: + try: + client.tools_invoke( + "sessions_send", + {"sessionKey": item.session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."}, + timeout_s=5.0, + ) + except Exception: + pass + + item.status = "blocked" + item.notes = (item.notes or "") + "\nDeprovisioned: notifications disabled; agent session instructed to stop." + item.updated_at = datetime.utcnow() + session.add(item) + session.commit() + session.refresh(item) + + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="agent_onboarding", + entity_id=item.id, + verb="deprovisioned", + payload={"session_key": item.session_key}, + ) + session.commit() + + return item