Remove HR module; provision agent sessions via /employees
This commit is contained in:
@@ -1,317 +1,16 @@
|
||||
from __future__ import annotations
|
||||
"""HR module removed.
|
||||
|
||||
from datetime import datetime
|
||||
Mission Control now uses the org/people module (employees) for provisioning.
|
||||
"""
|
||||
|
||||
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.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,
|
||||
)
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/hr", tags=["hr"])
|
||||
|
||||
|
||||
@router.get("/headcount", response_model=list[HeadcountRequest])
|
||||
def list_headcount_requests(session: Session = Depends(get_session)):
|
||||
return session.exec(select(HeadcountRequest).order_by(HeadcountRequest.id.desc())).all()
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
req = HeadcountRequest(**payload.model_dump())
|
||||
session.add(req)
|
||||
session.commit()
|
||||
session.refresh(req)
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="headcount_request", entity_id=req.id, verb="submitted")
|
||||
session.commit()
|
||||
return req
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
req = session.get(HeadcountRequest, request_id)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="Request 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(req, k, v)
|
||||
|
||||
session.add(req)
|
||||
session.commit()
|
||||
session.refresh(req)
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="headcount_request", entity_id=req.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return req
|
||||
|
||||
|
||||
@router.get("/actions", response_model=list[EmploymentAction])
|
||||
def list_employment_actions(session: Session = Depends(get_session)):
|
||||
return session.exec(select(EmploymentAction).order_by(EmploymentAction.id.desc())).all()
|
||||
|
||||
|
||||
@router.post("/actions", response_model=EmploymentAction)
|
||||
def create_employment_action(
|
||||
payload: EmploymentActionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
):
|
||||
# Prefer explicit payload key; header can supply one for retry-safety.
|
||||
if payload.idempotency_key is None and idempotency_key is not None:
|
||||
payload = EmploymentActionCreate(**{**payload.model_dump(), "idempotency_key": idempotency_key})
|
||||
|
||||
if payload.idempotency_key:
|
||||
existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
action = EmploymentAction(**payload.model_dump())
|
||||
session.add(action)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employment_action",
|
||||
entity_id=action.id,
|
||||
verb=action.action_type,
|
||||
payload={"employee_id": action.employee_id},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
# 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:
|
||||
return existing
|
||||
raise HTTPException(status_code=409, detail="Employment action violates constraints")
|
||||
|
||||
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),
|
||||
):
|
||||
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},
|
||||
)
|
||||
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),
|
||||
):
|
||||
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)
|
||||
for k, v in data.items():
|
||||
setattr(item, k, v)
|
||||
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="updated", payload=data)
|
||||
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
|
||||
@router.get("/")
|
||||
def hr_removed():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "HR module removed; use /employees endpoints for provisioning",
|
||||
}
|
||||
|
||||
@@ -4,14 +4,114 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity, get_actor_employee_id
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Department, Employee
|
||||
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
|
||||
|
||||
router = APIRouter(tags=["org"])
|
||||
|
||||
|
||||
def _default_agent_prompt(emp: Employee) -> str:
|
||||
"""Generate a conservative default prompt for a newly-created agent employee.
|
||||
|
||||
We keep this short and deterministic; the human can refine later.
|
||||
"""
|
||||
|
||||
title = emp.title or "Agent"
|
||||
dept = str(emp.department_id) if emp.department_id is not None else "(unassigned)"
|
||||
|
||||
return (
|
||||
f"You are {emp.name}, an AI agent employee in Mission Control.\n"
|
||||
f"Title: {title}. Department id: {dept}.\n\n"
|
||||
"Rules:\n"
|
||||
"- Use the Mission Control API only (no UI).\n"
|
||||
"- When notified about tasks/comments, respond with concise, actionable updates.\n"
|
||||
"- Do not invent facts; ask for missing context.\n"
|
||||
)
|
||||
|
||||
|
||||
def _maybe_auto_provision_agent(session: Session, *, emp: Employee, actor_employee_id: int) -> None:
|
||||
"""Auto-provision an OpenClaw session for an agent employee.
|
||||
|
||||
This is intentionally best-effort. If OpenClaw is not configured or the call fails,
|
||||
we leave the employee as-is (openclaw_session_key stays null).
|
||||
"""
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
return
|
||||
if emp.status != "active":
|
||||
return
|
||||
if not emp.notify_enabled:
|
||||
return
|
||||
if emp.openclaw_session_key:
|
||||
return
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
label = f"employee:{emp.id}:{emp.name}"
|
||||
try:
|
||||
resp = client.tools_invoke(
|
||||
"sessions_spawn",
|
||||
{
|
||||
"task": _default_agent_prompt(emp),
|
||||
"label": label,
|
||||
"agentId": "main",
|
||||
"cleanup": "keep",
|
||||
"runTimeoutSeconds": 600,
|
||||
},
|
||||
timeout_s=20.0,
|
||||
)
|
||||
except Exception as e:
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provision_failed",
|
||||
payload={"error": f"{type(e).__name__}: {e}"},
|
||||
)
|
||||
return
|
||||
|
||||
session_key = None
|
||||
if isinstance(resp, dict):
|
||||
session_key = resp.get("sessionKey")
|
||||
if not session_key:
|
||||
result = resp.get("result") or {}
|
||||
if isinstance(result, dict):
|
||||
session_key = result.get("sessionKey") or result.get("childSessionKey")
|
||||
details = (result.get("details") if isinstance(result, dict) else None) or {}
|
||||
if isinstance(details, dict):
|
||||
session_key = session_key or details.get("sessionKey") or details.get("childSessionKey")
|
||||
|
||||
if not session_key:
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provision_incomplete",
|
||||
payload={"label": label},
|
||||
)
|
||||
return
|
||||
|
||||
emp.openclaw_session_key = session_key
|
||||
session.add(emp)
|
||||
session.flush()
|
||||
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="provisioned",
|
||||
payload={"session_key": session_key, "label": label},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/departments", response_model=list[Department])
|
||||
def list_departments(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
||||
@@ -51,9 +151,13 @@ def create_department(
|
||||
return dept
|
||||
|
||||
|
||||
|
||||
@router.patch("/departments/{department_id}", response_model=Department)
|
||||
def update_department(department_id: int, payload: DepartmentUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def update_department(
|
||||
department_id: int,
|
||||
payload: DepartmentUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
dept = session.get(Department, department_id)
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="Department not found")
|
||||
@@ -76,13 +180,28 @@ def list_employees(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/employees", response_model=Employee)
|
||||
def create_employee(payload: EmployeeCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def create_employee(
|
||||
payload: EmployeeCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = Employee(**payload.model_dump())
|
||||
session.add(emp)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="created", payload={"name": emp.name, "type": emp.employee_type})
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="created",
|
||||
payload={"name": emp.name, "type": emp.employee_type},
|
||||
)
|
||||
|
||||
# AUTO-PROVISION: if this is an agent employee, try to create an OpenClaw session.
|
||||
_maybe_auto_provision_agent(session, emp=emp, actor_employee_id=actor_employee_id)
|
||||
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -93,7 +212,12 @@ def create_employee(payload: EmployeeCreate, session: Session = Depends(get_sess
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=Employee)
|
||||
def update_employee(employee_id: int, payload: EmployeeUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def update_employee(
|
||||
employee_id: int,
|
||||
payload: EmployeeUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
@@ -113,3 +237,65 @@ def update_employee(employee_id: int, payload: EmployeeUpdate, session: Session
|
||||
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/provision", response_model=Employee)
|
||||
def provision_employee_agent(
|
||||
employee_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
raise HTTPException(status_code=400, detail="Only agent employees can be provisioned")
|
||||
|
||||
_maybe_auto_provision_agent(session, emp=emp, actor_employee_id=actor_employee_id)
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/deprovision", response_model=Employee)
|
||||
def deprovision_employee_agent(
|
||||
employee_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
raise HTTPException(status_code=400, detail="Only agent employees can be deprovisioned")
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is not None and emp.openclaw_session_key:
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{"sessionKey": emp.openclaw_session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."},
|
||||
timeout_s=5.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
emp.notify_enabled = False
|
||||
emp.openclaw_session_key = None
|
||||
session.add(emp)
|
||||
session.flush()
|
||||
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="deprovisioned",
|
||||
payload={},
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
return Employee.model_validate(emp)
|
||||
|
||||
Reference in New Issue
Block a user