From 1bbc65c983c0619a9cf642037e834889a47fb1ef Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 16:48:17 +0530 Subject: [PATCH] Remove HR module; provision agent sessions via /employees --- .../versions/4dffc5312eb8_remove_hr_module.py | 35 ++ backend/app/api/hr.py | 321 +----------------- backend/app/api/org.py | 198 ++++++++++- backend/app/main.py | 2 - backend/app/models/__init__.py | 5 +- backend/app/models/hr.py | 66 ---- backend/app/schemas/hr.py | 56 --- 7 files changed, 238 insertions(+), 445 deletions(-) create mode 100644 backend/alembic/versions/4dffc5312eb8_remove_hr_module.py delete mode 100644 backend/app/models/hr.py delete mode 100644 backend/app/schemas/hr.py diff --git a/backend/alembic/versions/4dffc5312eb8_remove_hr_module.py b/backend/alembic/versions/4dffc5312eb8_remove_hr_module.py new file mode 100644 index 00000000..44a83b22 --- /dev/null +++ b/backend/alembic/versions/4dffc5312eb8_remove_hr_module.py @@ -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 diff --git a/backend/app/api/hr.py b/backend/app/api/hr.py index a67b26c9..ff307acd 100644 --- a/backend/app/api/hr.py +++ b/backend/app/api/hr.py @@ -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", + } diff --git a/backend/app/api/org.py b/backend/app/api/org.py index 092e44b9..df8acc4d 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index abae2369..f383bdf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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.projects import router as projects_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(projects_router) app.include_router(work_router) -app.include_router(hr_router) app.include_router(activities_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0266e58c..ed48d888 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,8 +1,7 @@ +from app.models.activity import Activity from app.models.org import Department, Employee from app.models.projects import Project, ProjectMember from app.models.work import Task, TaskComment -from app.models.hr import HeadcountRequest, EmploymentAction -from app.models.activity import Activity __all__ = [ "Department", @@ -11,7 +10,5 @@ __all__ = [ "ProjectMember", "Task", "TaskComment", - "HeadcountRequest", - "EmploymentAction", "Activity", ] diff --git a/backend/app/models/hr.py b/backend/app/models/hr.py deleted file mode 100644 index 54d0193b..00000000 --- a/backend/app/models/hr.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/hr.py b/backend/app/schemas/hr.py deleted file mode 100644 index 6b92288e..00000000 --- a/backend/app/schemas/hr.py +++ /dev/null @@ -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 -