Remove HR module; provision agent sessions via /employees
This commit is contained in:
35
backend/alembic/versions/4dffc5312eb8_remove_hr_module.py
Normal file
35
backend/alembic/versions/4dffc5312eb8_remove_hr_module.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""remove hr module
|
||||||
|
|
||||||
|
Revision ID: 4dffc5312eb8
|
||||||
|
Revises: bacd5e6a253d
|
||||||
|
Create Date: 2026-02-02 16:46:47.579836
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "4dffc5312eb8"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "bacd5e6a253d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
|
||||||
|
# Drop HR tables. If they don't exist (fresh db + baseline changed later), be safe.
|
||||||
|
op.execute("DROP TABLE IF EXISTS headcount_requests CASCADE")
|
||||||
|
op.execute("DROP TABLE IF EXISTS employment_actions CASCADE")
|
||||||
|
op.execute("DROP TABLE IF EXISTS agent_onboardings CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
|
||||||
|
# We intentionally do not recreate the HR tables; HR module is removed.
|
||||||
|
# If ever needed, re-introduce with a new migration.
|
||||||
|
pass
|
||||||
@@ -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 fastapi import APIRouter
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/hr", tags=["hr"])
|
router = APIRouter(prefix="/hr", tags=["hr"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/headcount", response_model=list[HeadcountRequest])
|
@router.get("/")
|
||||||
def list_headcount_requests(session: Session = Depends(get_session)):
|
def hr_removed():
|
||||||
return session.exec(select(HeadcountRequest).order_by(HeadcountRequest.id.desc())).all()
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "HR module removed; use /employees endpoints for provisioning",
|
||||||
@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
|
|
||||||
|
|||||||
@@ -4,14 +4,114 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import Session, select
|
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.db.session import get_session
|
||||||
|
from app.integrations.openclaw import OpenClawClient
|
||||||
from app.models.org import Department, Employee
|
from app.models.org import Department, Employee
|
||||||
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
|
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
|
||||||
|
|
||||||
router = APIRouter(tags=["org"])
|
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])
|
@router.get("/departments", response_model=list[Department])
|
||||||
def list_departments(session: Session = Depends(get_session)):
|
def list_departments(session: Session = Depends(get_session)):
|
||||||
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
||||||
@@ -51,9 +151,13 @@ def create_department(
|
|||||||
return dept
|
return dept
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/departments/{department_id}", response_model=Department)
|
@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)
|
dept = session.get(Department, department_id)
|
||||||
if not dept:
|
if not dept:
|
||||||
raise HTTPException(status_code=404, detail="Department not found")
|
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)
|
@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())
|
emp = Employee(**payload.model_dump())
|
||||||
session.add(emp)
|
session.add(emp)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session.flush()
|
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()
|
session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
@@ -93,7 +212,12 @@ def create_employee(payload: EmployeeCreate, session: Session = Depends(get_sess
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/employees/{employee_id}", response_model=Employee)
|
@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)
|
emp = session.get(Employee, employee_id)
|
||||||
if not emp:
|
if not emp:
|
||||||
raise HTTPException(status_code=404, detail="Employee not found")
|
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)
|
session.refresh(emp)
|
||||||
return Employee.model_validate(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)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.activities import router as activities_router
|
from app.api.activities import router as activities_router
|
||||||
from app.api.hr import router as hr_router
|
|
||||||
from app.api.org import router as org_router
|
from app.api.org import router as org_router
|
||||||
from app.api.projects import router as projects_router
|
from app.api.projects import router as projects_router
|
||||||
from app.api.work import router as work_router
|
from app.api.work import router as work_router
|
||||||
@@ -32,7 +31,6 @@ def on_startup() -> None:
|
|||||||
app.include_router(org_router)
|
app.include_router(org_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(work_router)
|
app.include_router(work_router)
|
||||||
app.include_router(hr_router)
|
|
||||||
app.include_router(activities_router)
|
app.include_router(activities_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
from app.models.activity import Activity
|
||||||
from app.models.org import Department, Employee
|
from app.models.org import Department, Employee
|
||||||
from app.models.projects import Project, ProjectMember
|
from app.models.projects import Project, ProjectMember
|
||||||
from app.models.work import Task, TaskComment
|
from app.models.work import Task, TaskComment
|
||||||
from app.models.hr import HeadcountRequest, EmploymentAction
|
|
||||||
from app.models.activity import Activity
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Department",
|
"Department",
|
||||||
@@ -11,7 +10,5 @@ __all__ = [
|
|||||||
"ProjectMember",
|
"ProjectMember",
|
||||||
"Task",
|
"Task",
|
||||||
"TaskComment",
|
"TaskComment",
|
||||||
"HeadcountRequest",
|
|
||||||
"EmploymentAction",
|
|
||||||
"Activity",
|
"Activity",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class HeadcountRequest(SQLModel, table=True):
|
|
||||||
__tablename__ = "headcount_requests"
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
department_id: int = Field(foreign_key="departments.id")
|
|
||||||
requested_by_manager_id: int = Field(foreign_key="employees.id")
|
|
||||||
|
|
||||||
role_title: str
|
|
||||||
employee_type: str # human | agent
|
|
||||||
quantity: int = Field(default=1)
|
|
||||||
|
|
||||||
justification: str | None = None
|
|
||||||
status: str = Field(default="submitted")
|
|
||||||
|
|
||||||
# fulfillment linkage (optional)
|
|
||||||
fulfilled_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
|
||||||
fulfilled_onboarding_id: int | None = Field(default=None, foreign_key="agent_onboardings.id")
|
|
||||||
fulfilled_at: datetime | None = None
|
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class EmploymentAction(SQLModel, table=True):
|
|
||||||
__tablename__ = "employment_actions"
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
employee_id: int = Field(foreign_key="employees.id")
|
|
||||||
issued_by_employee_id: int = Field(foreign_key="employees.id")
|
|
||||||
|
|
||||||
action_type: str # praise|warning|pip|termination
|
|
||||||
notes: str | None = None
|
|
||||||
|
|
||||||
# Optional idempotency key to prevent duplicates on retries
|
|
||||||
idempotency_key: str | None = Field(default=None, index=True, unique=True)
|
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentOnboarding(SQLModel, table=True):
|
|
||||||
__tablename__ = "agent_onboardings"
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
agent_name: str
|
|
||||||
role_title: str
|
|
||||||
prompt: str
|
|
||||||
cron_interval_ms: int | None = None
|
|
||||||
tools_json: str | None = None
|
|
||||||
owner_hr_id: int | None = Field(default=None, foreign_key="employees.id")
|
|
||||||
|
|
||||||
# Link to the employee record once created
|
|
||||||
employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
|
||||||
|
|
||||||
status: str = Field(default="planned") # planned|spawning|spawned|verified|blocked
|
|
||||||
spawned_agent_id: str | None = None
|
|
||||||
session_key: str | None = None
|
|
||||||
|
|
||||||
notes: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class HeadcountRequestCreate(SQLModel):
|
|
||||||
department_id: int
|
|
||||||
requested_by_manager_id: int
|
|
||||||
role_title: str
|
|
||||||
employee_type: str
|
|
||||||
quantity: int = 1
|
|
||||||
justification: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class HeadcountRequestUpdate(SQLModel):
|
|
||||||
status: str | None = None
|
|
||||||
justification: str | None = None
|
|
||||||
fulfilled_employee_id: int | None = None
|
|
||||||
fulfilled_onboarding_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class EmploymentActionCreate(SQLModel):
|
|
||||||
employee_id: int
|
|
||||||
issued_by_employee_id: int
|
|
||||||
action_type: str
|
|
||||||
notes: str | None = None
|
|
||||||
idempotency_key: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentOnboardingCreate(SQLModel):
|
|
||||||
agent_name: str
|
|
||||||
role_title: str
|
|
||||||
prompt: str
|
|
||||||
cron_interval_ms: int | None = None
|
|
||||||
tools_json: str | None = None
|
|
||||||
owner_hr_id: int | None = None
|
|
||||||
employee_id: int | None = None
|
|
||||||
status: str = "planned"
|
|
||||||
spawned_agent_id: str | None = None
|
|
||||||
session_key: str | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentOnboardingUpdate(SQLModel):
|
|
||||||
agent_name: str | None = None
|
|
||||||
role_title: str | None = None
|
|
||||||
prompt: str | None = None
|
|
||||||
cron_interval_ms: int | None = None
|
|
||||||
tools_json: str | None = None
|
|
||||||
owner_hr_id: int | None = None
|
|
||||||
employee_id: int | None = None
|
|
||||||
status: str | None = None
|
|
||||||
spawned_agent_id: str | None = None
|
|
||||||
session_key: str | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user