HR: provision/deprovision agent sessions via OpenClaw tools/invoke (no cron)

This commit is contained in:
Abhimanyu Saharan
2026-02-02 16:28:17 +05:30
parent 612e19036c
commit 9118fea9bf

View File

@@ -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