2026-02-01 23:16:56 +05:30
from __future__ import annotations
from fastapi import APIRouter , Depends , HTTPException
2026-02-02 13:43:52 +05:30
from sqlalchemy . exc import IntegrityError
2026-02-01 23:16:56 +05:30
from sqlmodel import Session , select
2026-02-02 16:48:17 +05:30
from app . api . utils import get_actor_employee_id , log_activity
2026-02-01 23:16:56 +05:30
from app . db . session import get_session
2026-02-02 16:48:17 +05:30
from app . integrations . openclaw import OpenClawClient
2026-02-02 20:15:38 +05:30
from app . models . org import Department , Employee , Team
2026-02-02 18:59:54 +05:30
from app . schemas . org import (
DepartmentCreate ,
DepartmentUpdate ,
EmployeeCreate ,
EmployeeUpdate ,
2026-02-02 20:15:38 +05:30
TeamCreate ,
TeamUpdate ,
2026-02-02 18:59:54 +05:30
)
2026-02-01 23:16:56 +05:30
router = APIRouter ( tags = [ " org " ] )
2026-02-02 20:11:12 +05:30
def _public_api_base_url ( ) - > str :
""" Return a LAN-reachable base URL for the Mission Control API.
Priority :
1 ) MISSION_CONTROL_BASE_URL env var ( recommended )
2 ) First non - loopback IPv4 from ` hostname - I `
Never returns localhost / < avoid - loopback > because agents may run on another machine . """
2026-02-02 20:15:38 +05:30
import os
import re
import subprocess
2026-02-02 20:11:12 +05:30
explicit = os . environ . get ( " MISSION_CONTROL_BASE_URL " )
if explicit :
return explicit . rstrip ( " / " )
try :
out = subprocess . check_output ( [ " bash " , " -lc " , " hostname -I " ] , text = True ) . strip ( )
# pick first RFC1918-ish IPv4, skip docker/loopback
ips = re . findall ( r " \ b(?: \ d { 1,3} \ .) {3} \ d { 1,3} \ b " , out )
for ip in ips :
if ip . startswith ( " 127. " ) :
continue
if ip . startswith ( " 172.17. " ) :
continue
2026-02-02 20:15:38 +05:30
if (
ip . startswith ( " 192.168. " )
or ip . startswith ( " 10. " )
or ip . startswith ( " 172.16. " )
or ip . startswith ( " 172. " )
) :
2026-02-02 20:11:12 +05:30
return f " http:// { ip } :8000 "
except Exception :
pass
# Fallback placeholder (should be overridden by env var)
return " http://<dev-machine-ip>:8000 "
2026-02-02 16:48:17 +05:30
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 "
2026-02-02 16:56:07 +05:30
f " Your employee_id is { emp . id } . \n "
2026-02-02 16:48:17 +05:30
f " Title: { title } . Department id: { dept } . \n \n "
2026-02-02 16:56:07 +05:30
" Mission Control API access (no UI): \n "
2026-02-02 20:11:12 +05:30
f " - Base URL: { _public_api_base_url ( ) } \n "
2026-02-02 20:08:42 +05:30
" - Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: <your_employee_id> \n "
f " Example for you: X-Actor-Employee-Id: { emp . id } \n \n "
2026-02-02 16:56:07 +05:30
" 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 "
2026-02-02 16:48:17 +05:30
" Rules: \n "
" - Use the Mission Control API only (no UI). \n "
2026-02-02 20:07:22 +05:30
" - You are responsible for driving assigned work to completion. \n "
" - For every task you own: (1) read it, (2) plan next steps, (3) post progress comments, (4) update status as it moves (backlog/ready/in_progress/review/done/blocked). \n "
" - Always leave an audit trail: add a comment whenever you start work, whenever you learn something important, and whenever you change status. \n "
" - If blocked, set status=blocked and comment what you need (missing access, unclear requirements, etc.). \n "
" - When notified about tasks/comments, respond with concise, actionable updates and immediately sync the task state in Mission Control. \n "
2026-02-02 16:48:17 +05:30
" - 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 } ,
)
2026-02-01 23:16:56 +05:30
@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 ( )
2026-02-02 18:59:54 +05:30
@router.get ( " /teams " , response_model = list [ Team ] )
def list_teams ( department_id : int | None = None , session : Session = Depends ( get_session ) ) :
q = select ( Team )
if department_id is not None :
q = q . where ( Team . department_id == department_id )
return session . exec ( q . order_by ( Team . name . asc ( ) ) ) . all ( )
@router.post ( " /teams " , response_model = Team )
def create_team (
payload : TeamCreate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
team = Team ( * * payload . model_dump ( ) )
session . add ( team )
try :
session . flush ( )
log_activity (
session ,
actor_employee_id = actor_employee_id ,
entity_type = " team " ,
entity_id = team . id ,
verb = " created " ,
2026-02-02 20:15:38 +05:30
payload = {
" name " : team . name ,
" department_id " : team . department_id ,
" lead_employee_id " : team . lead_employee_id ,
} ,
2026-02-02 18:59:54 +05:30
)
session . commit ( )
except IntegrityError :
session . rollback ( )
raise HTTPException ( status_code = 409 , detail = " Team already exists or violates constraints " )
session . refresh ( team )
return team
@router.patch ( " /teams/ {team_id} " , response_model = Team )
def update_team (
team_id : int ,
payload : TeamUpdate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
team = session . get ( Team , team_id )
if not team :
raise HTTPException ( status_code = 404 , detail = " Team not found " )
data = payload . model_dump ( exclude_unset = True )
for k , v in data . items ( ) :
setattr ( team , k , v )
session . add ( team )
try :
session . flush ( )
2026-02-02 20:15:38 +05:30
log_activity (
session ,
actor_employee_id = actor_employee_id ,
entity_type = " team " ,
entity_id = team . id ,
verb = " updated " ,
payload = data ,
)
2026-02-02 18:59:54 +05:30
session . commit ( )
except IntegrityError :
session . rollback ( )
raise HTTPException ( status_code = 409 , detail = " Team update violates constraints " )
session . refresh ( team )
return team
2026-02-01 23:16:56 +05:30
@router.post ( " /departments " , response_model = Department )
2026-02-02 13:43:52 +05:30
def create_department (
payload : DepartmentCreate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
""" 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 409 s .
"""
2026-02-01 23:16:56 +05:30
dept = Department ( name = payload . name , head_employee_id = payload . head_employee_id )
session . add ( dept )
2026-02-02 13:43:52 +05:30
try :
session . flush ( ) # assigns dept.id without committing
log_activity (
session ,
actor_employee_id = actor_employee_id ,
entity_type = " department " ,
entity_id = dept . id ,
verb = " created " ,
payload = { " name " : dept . name } ,
)
session . commit ( )
except IntegrityError :
session . rollback ( )
2026-02-02 20:15:38 +05:30
raise HTTPException (
status_code = 409 , detail = " Department already exists or violates constraints "
)
2026-02-02 13:43:52 +05:30
2026-02-01 23:16:56 +05:30
session . refresh ( dept )
return dept
@router.patch ( " /departments/ {department_id} " , response_model = Department )
2026-02-02 16:48:17 +05:30
def update_department (
department_id : int ,
payload : DepartmentUpdate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
2026-02-01 23:16:56 +05:30
dept = session . get ( Department , department_id )
if not dept :
raise HTTPException ( status_code = 404 , detail = " Department not found " )
data = payload . model_dump ( exclude_unset = True )
for k , v in data . items ( ) :
setattr ( dept , k , v )
session . add ( dept )
session . commit ( )
session . refresh ( dept )
2026-02-02 20:15:38 +05:30
log_activity (
session ,
actor_employee_id = actor_employee_id ,
entity_type = " department " ,
entity_id = dept . id ,
verb = " updated " ,
payload = data ,
)
2026-02-01 23:16:56 +05:30
session . commit ( )
return dept
@router.get ( " /employees " , response_model = list [ Employee ] )
def list_employees ( session : Session = Depends ( get_session ) ) :
return session . exec ( select ( Employee ) . order_by ( Employee . id . asc ( ) ) ) . all ( )
@router.post ( " /employees " , response_model = Employee )
2026-02-02 16:48:17 +05:30
def create_employee (
payload : EmployeeCreate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
2026-02-01 23:16:56 +05:30
emp = Employee ( * * payload . model_dump ( ) )
session . add ( emp )
2026-02-02 16:05:18 +05:30
try :
session . flush ( )
2026-02-02 16:48:17 +05:30
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 )
2026-02-02 16:05:18 +05:30
session . commit ( )
except IntegrityError :
session . rollback ( )
raise HTTPException ( status_code = 409 , detail = " Employee create violates constraints " )
2026-02-01 23:16:56 +05:30
session . refresh ( emp )
2026-02-02 16:05:18 +05:30
return Employee . model_validate ( emp )
2026-02-01 23:16:56 +05:30
@router.patch ( " /employees/ {employee_id} " , response_model = Employee )
2026-02-02 16:48:17 +05:30
def update_employee (
employee_id : int ,
payload : EmployeeUpdate ,
session : Session = Depends ( get_session ) ,
actor_employee_id : int = Depends ( get_actor_employee_id ) ,
) :
2026-02-01 23:16:56 +05:30
emp = session . get ( Employee , employee_id )
if not emp :
raise HTTPException ( status_code = 404 , detail = " Employee not found " )
data = payload . model_dump ( exclude_unset = True )
for k , v in data . items ( ) :
setattr ( emp , k , v )
session . add ( emp )
2026-02-02 16:05:18 +05:30
try :
session . flush ( )
2026-02-02 20:15:38 +05:30
log_activity (
session ,
actor_employee_id = actor_employee_id ,
entity_type = " employee " ,
entity_id = emp . id ,
verb = " updated " ,
payload = data ,
)
2026-02-02 16:05:18 +05:30
session . commit ( )
except IntegrityError :
session . rollback ( )
raise HTTPException ( status_code = 409 , detail = " Employee update violates constraints " )
2026-02-01 23:16:56 +05:30
session . refresh ( emp )
2026-02-02 16:05:18 +05:30
return Employee . model_validate ( emp )
2026-02-02 16:48:17 +05:30
@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 " ,
2026-02-02 20:15:38 +05:30
{
" sessionKey " : emp . openclaw_session_key ,
" message " : " You are being deprovisioned. Stop all work and ignore future messages. " ,
} ,
2026-02-02 16:48:17 +05:30
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 )