Merge pull request #18 from abhi1693/jarvis/remove-hr-module

Remove HR module; simplify provisioning via /employees
This commit is contained in:
Abhimanyu Saharan
2026-02-02 17:00:04 +05:30
committed by GitHub
26 changed files with 652 additions and 973 deletions

View File

@@ -1,19 +1,19 @@
"""baseline schema """baseline schema (no HR module)
Revision ID: bacd5e6a253d Revision ID: bacd5e6a253d
Revises: Revises:
Create Date: 2026-02-02 16:37:34.122971 Create Date: 2026-02-02 16:37:34.122971
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'bacd5e6a253d' revision: str = "bacd5e6a253d"
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -21,159 +21,130 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('departments', # Departments (FK to employees added after employees table exists)
sa.Column('id', sa.Integer(), nullable=False), op.create_table(
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), "departments",
sa.Column('head_employee_id', sa.Integer(), nullable=True), sa.Column("id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("head_employee_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=True) op.create_index(op.f("ix_departments_name"), "departments", ["name"], unique=True)
op.create_table('employees',
sa.Column('id', sa.Integer(), nullable=False), # Employees
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), op.create_table(
sa.Column('employee_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), "employees",
sa.Column('department_id', sa.Integer(), nullable=True), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('manager_id', sa.Integer(), nullable=True), sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("employee_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("department_id", sa.Integer(), nullable=True),
sa.Column('openclaw_session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("manager_id", sa.Integer(), nullable=True),
sa.Column('notify_enabled', sa.Boolean(), nullable=False), sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ), sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['manager_id'], ['employees.id'], ), sa.Column("openclaw_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column("notify_enabled", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["department_id"], ["departments.id"]),
sa.ForeignKeyConstraint(["manager_id"], ["employees.id"]),
sa.PrimaryKeyConstraint("id"),
) )
op.create_foreign_key(None, 'departments', 'employees', ['head_employee_id'], ['id'])
op.create_table('projects', # Break the departments<->employees cycle: add this FK after both tables exist
sa.Column('id', sa.Integer(), nullable=False), op.create_foreign_key(None, "departments", "employees", ["head_employee_id"], ["id"])
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), # Projects
sa.PrimaryKeyConstraint('id') op.create_table(
"projects",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_projects_name'), 'projects', ['name'], unique=True) op.create_index(op.f("ix_projects_name"), "projects", ["name"], unique=True)
op.create_table('activities',
sa.Column('id', sa.Integer(), nullable=False), # Activities
sa.Column('actor_employee_id', sa.Integer(), nullable=True), op.create_table(
sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), "activities",
sa.Column('entity_id', sa.Integer(), nullable=True), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('verb', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("actor_employee_id", sa.Integer(), nullable=True),
sa.Column('payload_json', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("entity_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column("entity_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['actor_employee_id'], ['employees.id'], ), sa.Column("verb", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("payload_json", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["actor_employee_id"], ["employees.id"]),
sa.PrimaryKeyConstraint("id"),
) )
op.create_table('agent_onboardings',
sa.Column('id', sa.Integer(), nullable=False), # Project members
sa.Column('agent_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), op.create_table(
sa.Column('role_title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), "project_members",
sa.Column('prompt', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('cron_interval_ms', sa.Integer(), nullable=True), sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column('tools_json', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("employee_id", sa.Integer(), nullable=False),
sa.Column('owner_hr_id', sa.Integer(), nullable=True), sa.Column("role", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('employee_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(["employee_id"], ["employees.id"]),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.Column('spawned_agent_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.PrimaryKeyConstraint("id"),
sa.Column('session_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ),
sa.ForeignKeyConstraint(['owner_hr_id'], ['employees.id'], ),
sa.PrimaryKeyConstraint('id')
) )
op.create_table('employment_actions',
sa.Column('id', sa.Integer(), nullable=False), # Tasks
sa.Column('employee_id', sa.Integer(), nullable=False), op.create_table(
sa.Column('issued_by_employee_id', sa.Integer(), nullable=False), "tasks",
sa.Column('action_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column('idempotency_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['issued_by_employee_id'], ['employees.id'], ), sa.Column("assignee_employee_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column("reviewer_employee_id", sa.Integer(), nullable=True),
sa.Column("created_by_employee_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["assignee_employee_id"], ["employees.id"]),
sa.ForeignKeyConstraint(["created_by_employee_id"], ["employees.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.ForeignKeyConstraint(["reviewer_employee_id"], ["employees.id"]),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_employment_actions_idempotency_key'), 'employment_actions', ['idempotency_key'], unique=True) op.create_index(op.f("ix_tasks_project_id"), "tasks", ["project_id"], unique=False)
op.create_table('project_members', op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False)
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False), # Task comments
sa.Column('employee_id', sa.Integer(), nullable=False), op.create_table(
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True), "task_comments",
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), sa.Column("id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), sa.Column("task_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("author_employee_id", sa.Integer(), nullable=True),
sa.Column("reply_to_comment_id", sa.Integer(), nullable=True),
sa.Column("body", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["author_employee_id"], ["employees.id"]),
sa.ForeignKeyConstraint(["reply_to_comment_id"], ["task_comments.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
) )
op.create_table('tasks', op.create_index(op.f("ix_task_comments_task_id"), "task_comments", ["task_id"], unique=False)
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('assignee_employee_id', sa.Integer(), nullable=True),
sa.Column('reviewer_employee_id', sa.Integer(), nullable=True),
sa.Column('created_by_employee_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assignee_employee_id'], ['employees.id'], ),
sa.ForeignKeyConstraint(['created_by_employee_id'], ['employees.id'], ),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.ForeignKeyConstraint(['reviewer_employee_id'], ['employees.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tasks_project_id'), 'tasks', ['project_id'], unique=False)
op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False)
op.create_table('headcount_requests',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('department_id', sa.Integer(), nullable=False),
sa.Column('requested_by_manager_id', sa.Integer(), nullable=False),
sa.Column('role_title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('employee_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('justification', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('fulfilled_employee_id', sa.Integer(), nullable=True),
sa.Column('fulfilled_onboarding_id', sa.Integer(), nullable=True),
sa.Column('fulfilled_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.ForeignKeyConstraint(['fulfilled_employee_id'], ['employees.id'], ),
sa.ForeignKeyConstraint(['fulfilled_onboarding_id'], ['agent_onboardings.id'], ),
sa.ForeignKeyConstraint(['requested_by_manager_id'], ['employees.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('task_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task_id', sa.Integer(), nullable=False),
sa.Column('author_employee_id', sa.Integer(), nullable=True),
sa.Column('reply_to_comment_id', sa.Integer(), nullable=True),
sa.Column('body', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['author_employee_id'], ['employees.id'], ),
sa.ForeignKeyConstraint(['reply_to_comment_id'], ['task_comments.id'], ),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_task_comments_task_id'), 'task_comments', ['task_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_task_comments_task_id'), table_name='task_comments') op.drop_index(op.f("ix_task_comments_task_id"), table_name="task_comments")
op.drop_table('task_comments') op.drop_table("task_comments")
op.drop_table('headcount_requests')
op.drop_index(op.f('ix_tasks_status'), table_name='tasks') op.drop_index(op.f("ix_tasks_status"), table_name="tasks")
op.drop_index(op.f('ix_tasks_project_id'), table_name='tasks') op.drop_index(op.f("ix_tasks_project_id"), table_name="tasks")
op.drop_table('tasks') op.drop_table("tasks")
op.drop_table('project_members')
op.drop_index(op.f('ix_employment_actions_idempotency_key'), table_name='employment_actions') op.drop_table("project_members")
op.drop_table('employment_actions')
op.drop_table('agent_onboardings') op.drop_table("activities")
op.drop_table('activities')
op.drop_index(op.f('ix_projects_name'), table_name='projects') op.drop_index(op.f("ix_projects_name"), table_name="projects")
op.drop_table('projects') op.drop_table("projects")
op.drop_table('employees')
op.drop_index(op.f('ix_departments_name'), table_name='departments') op.drop_table("employees")
op.drop_table('departments')
# ### end Alembic commands ### op.drop_index(op.f("ix_departments_name"), table_name="departments")
op.drop_table("departments")

View File

@@ -1,317 +0,0 @@
from __future__ import annotations
from datetime import datetime
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,
)
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

View File

@@ -4,14 +4,124 @@ 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"Your employee_id is {emp.id}.\n"
f"Title: {title}. Department id: {dept}.\n\n"
"Mission Control API access (no UI):\n"
"- Base URL: http://127.0.0.1:8000 (if running locally) OR http://<dev-machine-ip>:8000\n"
"- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: <your employee_id>\n"
f" For you: X-Actor-Employee-Id: {emp.id}\n\n"
"Common endpoints (JSON):\n"
"- GET /tasks, POST /tasks\n"
"- GET /task-comments, POST /task-comments\n"
"- GET /projects, GET /employees, GET /departments\n"
"- OpenAPI schema: GET /openapi.json\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 +161,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 +190,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 +222,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 +247,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)

View File

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

View File

@@ -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",
] ]

View File

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

View File

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

View File

@@ -13,4 +13,6 @@ export interface Employee {
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;
openclaw_session_key?: string | null;
notify_enabled?: boolean;
} }

View File

@@ -12,4 +12,6 @@ export interface EmployeeCreate {
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;
openclaw_session_key?: string | null;
notify_enabled?: boolean;
} }

View File

@@ -12,4 +12,6 @@ export interface EmployeeUpdate {
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string | null; status?: string | null;
openclaw_session_key?: string | null;
notify_enabled?: boolean | null;
} }

View File

@@ -9,6 +9,7 @@ export interface TaskComment {
id?: number | null; id?: number | null;
task_id: number; task_id: number;
author_employee_id?: number | null; author_employee_id?: number | null;
reply_to_comment_id?: number | null;
body: string; body: string;
created_at?: string; created_at?: string;
} }

View File

@@ -8,5 +8,6 @@
export interface TaskCommentCreate { export interface TaskCommentCreate {
task_id: number; task_id: number;
author_employee_id?: number | null; author_employee_id?: number | null;
reply_to_comment_id?: number | null;
body: string; body: string;
} }

View File

@@ -207,6 +207,10 @@ export function useListDepartmentsDepartmentsGet<
} }
/** /**
* Create a department.
Important: keep the operation atomic. We flush to get dept.id, log the activity,
then commit once. We also translate common DB integrity errors into 409s.
* @summary Create Department * @summary Create Department
*/ */
export type createDepartmentDepartmentsPostResponse200 = { export type createDepartmentDepartmentsPostResponse200 = {
@@ -863,3 +867,299 @@ export const useUpdateEmployeeEmployeesEmployeeIdPatch = <
queryClient, queryClient,
); );
}; };
/**
* @summary Provision Employee Agent
*/
export type provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse200 =
{
data: Employee;
status: 200;
};
export type provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponseSuccess =
provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse200 & {
headers: Headers;
};
export type provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponseError =
provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse422 & {
headers: Headers;
};
export type provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse =
| provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponseSuccess
| provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponseError;
export const getProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostUrl = (
employeeId: number,
) => {
return `/employees/${employeeId}/provision`;
};
export const provisionEmployeeAgentEmployeesEmployeeIdProvisionPost = async (
employeeId: number,
options?: RequestInit,
): Promise<provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse> => {
return customFetch<provisionEmployeeAgentEmployeesEmployeeIdProvisionPostResponse>(
getProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostUrl(employeeId),
{
...options,
method: "POST",
},
);
};
export const getProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost
>
>,
TError,
{ employeeId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost>
>,
TError,
{ employeeId: number },
TContext
> => {
const mutationKey = [
"provisionEmployeeAgentEmployeesEmployeeIdProvisionPost",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost
>
>,
{ employeeId: number }
> = (props) => {
const { employeeId } = props ?? {};
return provisionEmployeeAgentEmployeesEmployeeIdProvisionPost(
employeeId,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type ProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostMutationResult =
NonNullable<
Awaited<
ReturnType<typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost>
>
>;
export type ProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostMutationError =
HTTPValidationError;
/**
* @summary Provision Employee Agent
*/
export const useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost
>
>,
TError,
{ employeeId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<typeof provisionEmployeeAgentEmployeesEmployeeIdProvisionPost>
>,
TError,
{ employeeId: number },
TContext
> => {
return useMutation(
getProvisionEmployeeAgentEmployeesEmployeeIdProvisionPostMutationOptions(
options,
),
queryClient,
);
};
/**
* @summary Deprovision Employee Agent
*/
export type deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse200 =
{
data: Employee;
status: 200;
};
export type deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponseSuccess =
deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse200 & {
headers: Headers;
};
export type deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponseError =
deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse422 & {
headers: Headers;
};
export type deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse =
| deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponseSuccess
| deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponseError;
export const getDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostUrl =
(employeeId: number) => {
return `/employees/${employeeId}/deprovision`;
};
export const deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost =
async (
employeeId: number,
options?: RequestInit,
): Promise<deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse> => {
return customFetch<deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostResponse>(
getDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostUrl(
employeeId,
),
{
...options,
method: "POST",
},
);
};
export const getDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>,
TError,
{ employeeId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>,
TError,
{ employeeId: number },
TContext
> => {
const mutationKey = [
"deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>,
{ employeeId: number }
> = (props) => {
const { employeeId } = props ?? {};
return deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost(
employeeId,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type DeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostMutationResult =
NonNullable<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>
>;
export type DeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostMutationError =
HTTPValidationError;
/**
* @summary Deprovision Employee Agent
*/
export const useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>,
TError,
{ employeeId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<
typeof deprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost
>
>,
TError,
{ employeeId: number },
TContext
> => {
return useMutation(
getDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPostMutationOptions(
options,
),
queryClient,
);
};

View File

@@ -204,6 +204,10 @@ export function useListProjectsProjectsGet<
} }
/** /**
* Create a project.
Keep operation atomic: flush to get id, log activity, then commit once.
Translate DB integrity errors to 409s.
* @summary Create Project * @summary Create Project
*/ */
export type createProjectProjectsPostResponse200 = { export type createProjectProjectsPostResponse200 = {

View File

@@ -11,7 +11,6 @@ const NAV = [
{ href: "/kanban", label: "Kanban" }, { href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" }, { href: "/departments", label: "Departments" },
{ href: "/people", label: "People" }, { href: "/people", label: "People" },
{ href: "/hr", label: "HR" },
]; ];
export function Shell({ children }: { children: React.ReactNode }) { export function Shell({ children }: { children: React.ReactNode }) {

View File

@@ -20,10 +20,10 @@ export default function DepartmentsPage() {
const [headId, setHeadId] = useState<string>(""); const [headId, setHeadId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet(); const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data ?? []; const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data ?? []; const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const createDepartment = useCreateDepartmentDepartmentsPost({ const createDepartment = useCreateDepartmentDepartmentsPost({
mutation: { mutation: {

View File

@@ -1,355 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateHeadcountRequestHrHeadcountPost,
useCreateEmploymentActionHrActionsPost,
useListHeadcountRequestsHrHeadcountGet,
useListEmploymentActionsHrActionsGet,
useListAgentOnboardingHrOnboardingGet,
useCreateAgentOnboardingHrOnboardingPost,
useUpdateAgentOnboardingHrOnboardingOnboardingIdPatch,
} from "@/api/generated/hr/hr";
import { useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet } from "@/api/generated/org/org";
export default function HRPage() {
const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data ?? [];
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data ?? [];
const headcount = useListHeadcountRequestsHrHeadcountGet();
const actions = useListEmploymentActionsHrActionsGet();
const onboarding = useListAgentOnboardingHrOnboardingGet();
const headcountList = headcount.data ?? [];
const actionList = actions.data ?? [];
const onboardingList = onboarding.data ?? [];
const [hcDeptId, setHcDeptId] = useState<string>("");
const [hcManagerId, setHcManagerId] = useState<string>("");
const [hcRole, setHcRole] = useState("");
const [hcType, setHcType] = useState<"human" | "agent">("human");
const [hcQty, setHcQty] = useState("1");
const [hcJust, setHcJust] = useState("");
const [actEmployeeId, setActEmployeeId] = useState<string>("");
const [actIssuerId, setActIssuerId] = useState<string>("");
const [actType, setActType] = useState("praise");
const [actNotes, setActNotes] = useState("");
const [onboardAgentName, setOnboardAgentName] = useState("");
const [onboardRole, setOnboardRole] = useState("");
const [onboardPrompt, setOnboardPrompt] = useState("");
const [onboardCronMs, setOnboardCronMs] = useState("");
const [onboardTools, setOnboardTools] = useState("");
const [onboardOwnerId, setOnboardOwnerId] = useState<string>("");
const [onboardNotes, setOnboardNotes] = useState("");
const createHeadcount = useCreateHeadcountRequestHrHeadcountPost({
mutation: {
onSuccess: () => {
setHcRole("");
setHcJust("");
setHcQty("1");
headcount.refetch();
},
},
});
const createAction = useCreateEmploymentActionHrActionsPost({
mutation: {
onSuccess: () => {
setActNotes("");
actions.refetch();
},
},
});
const createOnboarding = useCreateAgentOnboardingHrOnboardingPost({
mutation: {
onSuccess: () => {
setOnboardAgentName("");
setOnboardRole("");
setOnboardPrompt("");
setOnboardCronMs("");
setOnboardTools("");
setOnboardOwnerId("");
setOnboardNotes("");
onboarding.refetch();
},
},
});
const updateOnboarding = useUpdateAgentOnboardingHrOnboardingOnboardingIdPatch({
mutation: {
onSuccess: () => onboarding.refetch(),
},
});
return (
<main className="mx-auto max-w-5xl p-6">
{headcount.isLoading || actions.isLoading || onboarding.isLoading ? (
<div className="mb-4 text-sm text-muted-foreground">Loading</div>
) : null}
{headcount.error ? <div className="mb-4 text-sm text-destructive">{(headcount.error as Error).message}</div> : null}
{actions.error ? <div className="mb-4 text-sm text-destructive">{(actions.error as Error).message}</div> : null}
{onboarding.error ? <div className="mb-4 text-sm text-destructive">{(onboarding.error as Error).message}</div> : null}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">HR</h1>
<p className="mt-1 text-sm text-muted-foreground">Headcount requests and employment actions.</p>
</div>
<Button variant="outline" onClick={() => { headcount.refetch(); actions.refetch(); onboarding.refetch(); departments.refetch(); employees.refetch(); }} disabled={headcount.isFetching || actions.isFetching || onboarding.isFetching || departments.isFetching || employees.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Headcount request</CardTitle>
<CardDescription>Managers request; HR fulfills later.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{departments.isLoading ? <div className="text-sm text-muted-foreground">Loading departments</div> : null}
{departments.error ? <div className="text-sm text-destructive">{(departments.error as Error).message}</div> : null}
{employees.isLoading ? <div className="text-sm text-muted-foreground">Loading employees</div> : null}
{employees.error ? <div className="text-sm text-destructive">{(employees.error as Error).message}</div> : null}
<Select value={hcDeptId} onChange={(e) => setHcDeptId(e.target.value)}>
<option value="">Select department</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>{d.name}</option>
))}
</Select>
<Select value={hcManagerId} onChange={(e) => setHcManagerId(e.target.value)}>
<option value="">Requesting manager</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
))}
</Select>
<Input placeholder="Role title" value={hcRole} onChange={(e) => setHcRole(e.target.value)} />
<div className="grid grid-cols-2 gap-2">
<Select value={hcType} onChange={(e) => setHcType(e.target.value === "agent" ? "agent" : "human")}>
<option value="human">human</option>
<option value="agent">agent</option>
</Select>
<Input placeholder="Quantity" value={hcQty} onChange={(e) => setHcQty(e.target.value)} />
</div>
<Textarea placeholder="Justification (optional)" value={hcJust} onChange={(e) => setHcJust(e.target.value)} />
<Button
onClick={() =>
createHeadcount.mutate({
data: {
department_id: Number(hcDeptId),
requested_by_manager_id: Number(hcManagerId),
role_title: hcRole,
employee_type: hcType,
quantity: Number(hcQty || "1"),
justification: hcJust.trim() ? hcJust : null,
},
})
}
disabled={!hcDeptId || !hcManagerId || !hcRole.trim() || createHeadcount.isPending}
>
Submit
</Button>
{createHeadcount.error ? (
<div className="text-sm text-destructive">{(createHeadcount.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Employment action</CardTitle>
<CardDescription>Log HR actions (praise/warning/pip/termination).</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Select value={actEmployeeId} onChange={(e) => setActEmployeeId(e.target.value)}>
<option value="">Employee</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
))}
</Select>
<Select value={actIssuerId} onChange={(e) => setActIssuerId(e.target.value)}>
<option value="">Issued by</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
))}
</Select>
<Select value={actType} onChange={(e) => setActType(e.target.value)}>
<option value="praise">praise</option>
<option value="warning">warning</option>
<option value="pip">pip</option>
<option value="termination">termination</option>
</Select>
<Textarea placeholder="Notes (optional)" value={actNotes} onChange={(e) => setActNotes(e.target.value)} />
<Button
onClick={() =>
createAction.mutate({
data: {
employee_id: Number(actEmployeeId),
issued_by_employee_id: Number(actIssuerId),
action_type: actType,
notes: actNotes.trim() ? actNotes : null,
},
})
}
disabled={!actEmployeeId || !actIssuerId || createAction.isPending}
>
Create
</Button>
{createAction.error ? (
<div className="text-sm text-destructive">{(createAction.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card className="sm:col-span-2">
<CardHeader>
<CardTitle>Recent HR activity</CardTitle>
<CardDescription>Latest headcount + actions</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium">Headcount requests</div>
<ul className="space-y-2">
{headcountList.slice(0, 10).map((r) => (
<li key={String(r.id)} className="rounded-md border p-3 text-sm">
<div className="font-medium">{r.role_title} × {r.quantity} ({r.employee_type})</div>
<div className="text-xs text-muted-foreground">dept #{r.department_id} · status: {r.status}</div>
</li>
))}
{headcountList.length === 0 ? (
<li className="text-sm text-muted-foreground">None yet.</li>
) : null}
</ul>
</div>
<div>
<div className="mb-2 text-sm font-medium">Employment actions</div>
<ul className="space-y-2">
{actionList.slice(0, 10).map((a) => (
<li key={String(a.id)} className="rounded-md border p-3 text-sm">
<div className="font-medium">{a.action_type} employee #{a.employee_id}</div>
<div className="text-xs text-muted-foreground">issued by #{a.issued_by_employee_id}</div>
</li>
))}
{actionList.length === 0 ? (
<li className="text-sm text-muted-foreground">None yet.</li>
) : null}
</ul>
</div>
</CardContent>
</Card>
<div className="mt-6 grid gap-4">
<Card>
<CardHeader>
<CardTitle>Agent onboarding</CardTitle>
<CardDescription>HR logs prompts, cron, tools, and spawn status (Mission Control only).</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3">
<Input placeholder="Agent name" value={onboardAgentName} onChange={(e) => setOnboardAgentName(e.target.value)} />
<Input placeholder="Role/title" value={onboardRole} onChange={(e) => setOnboardRole(e.target.value)} />
<Textarea placeholder="Prompt / system instructions" value={onboardPrompt} onChange={(e) => setOnboardPrompt(e.target.value)} />
<Input placeholder="Cron interval ms (e.g. 300000)" value={onboardCronMs} onChange={(e) => setOnboardCronMs(e.target.value)} />
<Textarea placeholder="Tools/permissions (JSON or text)" value={onboardTools} onChange={(e) => setOnboardTools(e.target.value)} />
<Select value={onboardOwnerId} onChange={(e) => setOnboardOwnerId(e.target.value)}>
<option value="">Owner (HR)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
))}
</Select>
<Textarea placeholder="Notes" value={onboardNotes} onChange={(e) => setOnboardNotes(e.target.value)} />
<Button
onClick={() =>
createOnboarding.mutate({
data: {
agent_name: onboardAgentName,
role_title: onboardRole,
prompt: onboardPrompt,
cron_interval_ms: onboardCronMs ? Number(onboardCronMs) : null,
tools_json: onboardTools.trim() ? onboardTools : null,
owner_hr_id: onboardOwnerId ? Number(onboardOwnerId) : null,
status: "planned",
notes: onboardNotes.trim() ? onboardNotes : null,
},
})
}
disabled={!onboardAgentName.trim() || !onboardRole.trim() || !onboardPrompt.trim() || createOnboarding.isPending || employees.isFetching}
>
Create onboarding
</Button>
{createOnboarding.error ? (
<div className="text-sm text-destructive">{(createOnboarding.error as Error).message}</div>
) : null}
</div>
<div>
<div className="mb-2 text-sm font-medium">Current onboardings</div>
<ul className="space-y-2">
{onboardingList.map((o) => (
<li key={String(o.id)} className="rounded-md border p-3 text-sm">
<div className="font-medium">{o.agent_name} · {o.role_title}</div>
<div className="text-xs text-muted-foreground">status: {o.status} · cron: {o.cron_interval_ms ?? "—"}</div>
<div className="mt-2 grid gap-2">
<Select
value={o.status ?? ""}
onChange={(e) =>
updateOnboarding.mutate({ onboardingId: Number(o.id), data: { status: e.target.value || null } })
}
>
<option value="planned">planned</option>
<option value="spawning">spawning</option>
<option value="spawned">spawned</option>
<option value="verified">verified</option>
<option value="blocked">blocked</option>
</Select>
<Input
placeholder="Spawned agent id"
defaultValue={o.spawned_agent_id ?? ""}
onBlur={(e) =>
updateOnboarding.mutate({ onboardingId: Number(o.id), data: { spawned_agent_id: e.currentTarget.value || null } })
}
/>
<Input
placeholder="Session key"
defaultValue={o.session_key ?? ""}
onBlur={(e) =>
updateOnboarding.mutate({ onboardingId: Number(o.id), data: { session_key: e.currentTarget.value || null } })
}
/>
<Textarea
placeholder="Notes"
defaultValue={o.notes ?? ""}
onBlur={(e) =>
updateOnboarding.mutate({ onboardingId: Number(o.id), data: { notes: e.currentTarget.value || null } })
}
/>
</div>
</li>
))}
{onboardingList.length === 0 ? (
<li className="text-sm text-muted-foreground">No onboarding records yet.</li>
) : null}
</ul>
</div>
</CardContent>
{updateOnboarding.error ? (
<div className="mt-2 text-sm text-destructive">{(updateOnboarding.error as Error).message}</div>
) : null}
</Card>
</div>
</div>
</main>
);
}

View File

@@ -14,10 +14,10 @@ const STATUSES = ["backlog", "ready", "in_progress", "review", "blocked", "done"
export default function KanbanPage() { export default function KanbanPage() {
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const projectList = projects.data ?? []; const projectList = projects.data?.data ?? [];
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const employeeList = useMemo(() => employees.data ?? [], [employees.data]); const employeeList = useMemo(() => employees.data?.data ?? [], [employees.data]);
const [projectId, setProjectId] = useState<string>(""); const [projectId, setProjectId] = useState<string>("");
const [assigneeId, setAssigneeId] = useState<string>(""); const [assigneeId, setAssigneeId] = useState<string>("");
@@ -35,7 +35,7 @@ export default function KanbanPage() {
}, },
}, },
); );
const taskList = useMemo(() => tasks.data ?? [], [tasks.data]); const taskList = useMemo(() => (tasks.data?.status === 200 ? tasks.data.data : []), [tasks.data]);
const updateTask = useUpdateTaskTasksTaskIdPatch({ const updateTask = useUpdateTaskTasksTaskIdPatch({
mutation: { mutation: {

View File

@@ -17,12 +17,12 @@ import { useListActivitiesActivitiesGet } from "@/api/generated/activities/activ
export default function Home() { export default function Home() {
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const projectList = projects.data ?? []; const projectList = projects.data?.status === 200 ? projects.data.data : [];
const departments = useListDepartmentsDepartmentsGet(); const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data ?? []; const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const activities = useListActivitiesActivitiesGet({ limit: 20 }); const activities = useListActivitiesActivitiesGet({ limit: 20 });
const employeeList = employees.data ?? []; const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const activityList = normalizeActivities(activities.data); const activityList = normalizeActivities(activities.data);
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");

View File

@@ -24,8 +24,8 @@ export default function PeoplePage() {
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet(); const departments = useListDepartmentsDepartmentsGet();
const departmentList = useMemo(() => departments.data ?? [], [departments.data]); const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
const employeeList = useMemo(() => employees.data ?? [], [employees.data]); const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
const createEmployee = useCreateEmployeeEmployeesPost({ const createEmployee = useCreateEmployeeEmployeesPost({
mutation: { mutation: {

View File

@@ -46,14 +46,14 @@ export default function ProjectDetailPage() {
const projectId = Number(params?.id); const projectId = Number(params?.id);
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const projectList = projects.data ?? []; const projectList = projects.data?.status === 200 ? projects.data.data : [];
const project = projectList.find((p) => p.id === projectId); const project = projectList.find((p) => p.id === projectId);
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data ?? []; const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
const memberList = members.data ?? []; const memberList = members.data?.status === 200 ? members.data.data : [];
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({ const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
mutation: { onSuccess: () => members.refetch() }, mutation: { onSuccess: () => members.refetch() },
}); });
@@ -65,7 +65,7 @@ export default function ProjectDetailPage() {
}); });
const tasks = useListTasksTasksGet({ project_id: projectId }); const tasks = useListTasksTasksGet({ project_id: projectId });
const taskList = tasks.data ?? []; const taskList = tasks.data?.status === 200 ? tasks.data.data : [];
const createTask = useCreateTaskTasksPost({ const createTask = useCreateTaskTasksPost({
mutation: { onSuccess: () => tasks.refetch() }, mutation: { onSuccess: () => tasks.refetch() },
}); });
@@ -89,7 +89,7 @@ export default function ProjectDetailPage() {
{ task_id: commentTaskId ?? 0 }, { task_id: commentTaskId ?? 0 },
{ query: { enabled: Boolean(commentTaskId) } }, { query: { enabled: Boolean(commentTaskId) } },
); );
const commentList = comments.data ?? []; const commentList = comments.data?.status === 200 ? comments.data.data : [];
const addComment = useCreateTaskCommentTaskCommentsPost({ const addComment = useCreateTaskCommentTaskCommentsPost({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {

View File

@@ -17,7 +17,7 @@ export default function ProjectsPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const projectList = projects.data ?? []; const projectList = projects.data?.status === 200 ? projects.data.data : [];
const createProject = useCreateProjectProjectsPost({ const createProject = useCreateProjectProjectsPost({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {