Files
openclaw-mission-control/backend/app/api/org.py
2026-02-02 22:04:21 +05:30

459 lines
14 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, 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.org import Department, Employee, Team
from app.schemas.org import (
DepartmentCreate,
DepartmentUpdate,
EmployeeCreate,
EmployeeUpdate,
TeamCreate,
TeamUpdate,
)
router = APIRouter(tags=["org"])
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."""
import os
import re
import subprocess
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
if (
ip.startswith("192.168.")
or ip.startswith("10.")
or ip.startswith("172.16.")
or ip.startswith("172.")
):
return f"http://{ip}:8000"
except Exception:
pass
# Fallback placeholder (should be overridden by env var)
return "http://<dev-machine-ip>:8000"
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"
f"- Base URL: {_public_api_base_url()}\n"
"- 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"
"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"
"- 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"
"- 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()
@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",
payload={
"name": team.name,
"department_id": team.department_id,
"lead_employee_id": team.lead_employee_id,
},
)
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()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="team",
entity_id=team.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Team update violates constraints")
session.refresh(team)
return team
@router.post("/departments", response_model=Department)
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 409s.
"""
dept = Department(name=payload.name, head_employee_id=payload.head_employee_id)
session.add(dept)
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()
raise HTTPException(
status_code=409, detail="Department already exists or violates constraints"
)
session.refresh(dept)
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),
):
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)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="department",
entity_id=dept.id,
verb="updated",
payload=data,
)
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)
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},
)
# 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()
raise HTTPException(status_code=409, detail="Employee create violates constraints")
session.refresh(emp)
return Employee.model_validate(emp)
@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),
):
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)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employee",
entity_id=emp.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Employee update violates constraints")
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)