feat: add boards and tasks management endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-04 02:28:51 +05:30
parent 23faa0865b
commit 1abc8f68f3
170 changed files with 6860 additions and 10706 deletions

View File

View File

@@ -1,32 +0,0 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.db.session import get_session
from app.models.activity import Activity
router = APIRouter(prefix="/activities", tags=["activities"])
@router.get("")
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
items = session.exec(
select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))
).all()
out = []
for a in items:
out.append(
{
"id": a.id,
"actor_employee_id": a.actor_employee_id,
"entity_type": a.entity_type,
"entity_id": a.entity_id,
"verb": a.verb,
"payload": json.loads(a.payload_json) if a.payload_json else None,
"created_at": a.created_at,
}
)
return out

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.core.auth import get_auth_context
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.schemas.activity_events import ActivityEventRead
from app.services.admin_access import require_admin
router = APIRouter(prefix="/activity", tags=["activity"])
@router.get("", response_model=list[ActivityEventRead])
def list_activity(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> list[ActivityEvent]:
require_admin(auth)
statement = (
select(ActivityEvent).order_by(ActivityEvent.created_at.desc()).offset(offset).limit(limit)
)
return list(session.exec(statement))

205
backend/app/api/agents.py Normal file
View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.auth import get_auth_context
from app.db.session import get_session
from app.integrations.openclaw_gateway import OpenClawGatewayError, openclaw_call
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
AgentHeartbeatCreate,
AgentRead,
AgentUpdate,
)
from app.services.admin_access import require_admin
router = APIRouter(prefix="/agents", tags=["agents"])
OFFLINE_AFTER = timedelta(minutes=10)
DEFAULT_GATEWAY_CHANNEL = "openclaw-agency"
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _build_session_label(agent_name: str) -> str:
return f"{DEFAULT_GATEWAY_CHANNEL}-{_slugify(agent_name)}"
async def _create_gateway_session(agent_name: str) -> str:
label = _build_session_label(agent_name)
try:
await openclaw_call("sessions.patch", {"key": label, "label": agent_name})
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return label
def _with_computed_status(agent: Agent) -> Agent:
now = datetime.utcnow()
if agent.last_seen_at and now - agent.last_seen_at > OFFLINE_AFTER:
agent.status = "offline"
return agent
def _record_heartbeat(session: Session, agent: Agent) -> None:
event = ActivityEvent(
event_type="agent.heartbeat",
message=f"Heartbeat received from {agent.name}.",
agent_id=agent.id,
)
session.add(event)
@router.get("", response_model=list[AgentRead])
def list_agents(
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> list[Agent]:
require_admin(auth)
agents = list(session.exec(select(Agent)))
return [_with_computed_status(agent) for agent in agents]
@router.post("", response_model=AgentRead)
async def create_agent(
payload: AgentCreate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Agent:
require_admin(auth)
agent = Agent.model_validate(payload)
agent.openclaw_session_id = await _create_gateway_session(agent.name)
session.add(agent)
session.commit()
session.refresh(agent)
session.add(
ActivityEvent(
event_type="agent.session.created",
message=f"Session created for {agent.name}.",
agent_id=agent.id,
)
)
session.commit()
return agent
@router.get("/{agent_id}", response_model=AgentRead)
def get_agent(
agent_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Agent:
require_admin(auth)
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return _with_computed_status(agent)
@router.patch("/{agent_id}", response_model=AgentRead)
def update_agent(
agent_id: str,
payload: AgentUpdate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Agent:
require_admin(auth)
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(agent, key, value)
agent.updated_at = datetime.utcnow()
session.add(agent)
session.commit()
session.refresh(agent)
return _with_computed_status(agent)
@router.post("/{agent_id}/heartbeat", response_model=AgentRead)
def heartbeat_agent(
agent_id: str,
payload: AgentHeartbeat,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Agent:
require_admin(auth)
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if payload.status:
agent.status = payload.status
agent.last_seen_at = datetime.utcnow()
agent.updated_at = datetime.utcnow()
_record_heartbeat(session, agent)
session.add(agent)
session.commit()
session.refresh(agent)
return _with_computed_status(agent)
@router.post("/heartbeat", response_model=AgentRead)
async def heartbeat_or_create_agent(
payload: AgentHeartbeatCreate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Agent:
require_admin(auth)
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first()
if agent is None:
agent = Agent(name=payload.name, status=payload.status or "online")
agent.openclaw_session_id = await _create_gateway_session(agent.name)
session.add(agent)
session.commit()
session.refresh(agent)
session.add(
ActivityEvent(
event_type="agent.session.created",
message=f"Session created for {agent.name}.",
agent_id=agent.id,
)
)
elif not agent.openclaw_session_id:
agent.openclaw_session_id = await _create_gateway_session(agent.name)
session.add(
ActivityEvent(
event_type="agent.session.created",
message=f"Session created for {agent.name}.",
agent_id=agent.id,
)
)
if payload.status:
agent.status = payload.status
agent.last_seen_at = datetime.utcnow()
agent.updated_at = datetime.utcnow()
_record_heartbeat(session, agent)
session.add(agent)
session.commit()
session.refresh(agent)
return _with_computed_status(agent)
@router.delete("/{agent_id}")
def delete_agent(
agent_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> dict[str, bool]:
require_admin(auth)
agent = session.get(Agent, agent_id)
if agent:
session.delete(agent)
session.commit()
return {"ok": True}

15
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import get_auth_context
from app.schemas.users import UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/bootstrap", response_model=UserRead)
async def bootstrap_user(auth=Depends(get_auth_context)) -> UserRead:
if auth.actor_type != "user" or auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return auth.user

82
backend/app/api/boards.py Normal file
View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.auth import get_auth_context
from app.db.session import get_session
from app.models.boards import Board
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.services.admin_access import require_admin
router = APIRouter(prefix="/boards", tags=["boards"])
@router.get("", response_model=list[BoardRead])
def list_boards(
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> list[Board]:
require_admin(auth)
return list(session.exec(select(Board)))
@router.post("", response_model=BoardRead)
def create_board(
payload: BoardCreate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Board:
require_admin(auth)
board = Board.model_validate(payload)
session.add(board)
session.commit()
session.refresh(board)
return board
@router.get("/{board_id}", response_model=BoardRead)
def get_board(
board_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Board:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return board
@router.patch("/{board_id}", response_model=BoardRead)
def update_board(
board_id: str,
payload: BoardUpdate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Board:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(board, key, value)
session.add(board)
session.commit()
session.refresh(board)
return board
@router.delete("/{board_id}")
def delete_board(
board_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> dict[str, bool]:
require_admin(auth)
board = session.get(Board, board_id)
if board:
session.delete(board)
session.commit()
return {"ok": True}

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, status
from app.core.auth import get_auth_context
from app.core.config import settings
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
get_chat_history,
openclaw_call,
send_message,
)
from app.services.admin_access import require_admin
router = APIRouter(prefix="/gateway", tags=["gateway"])
@router.get("/status")
async def gateway_status(auth=Depends(get_auth_context)) -> dict[str, object]:
require_admin(auth)
gateway_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789"
try:
sessions = await openclaw_call("sessions.list")
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
return {
"connected": True,
"gateway_url": gateway_url,
"sessions_count": len(sessions_list),
"sessions": sessions_list,
}
except OpenClawGatewayError as exc:
return {
"connected": False,
"gateway_url": gateway_url,
"error": str(exc),
}
@router.get("/sessions")
async def list_sessions(auth=Depends(get_auth_context)) -> dict[str, object]:
require_admin(auth)
try:
sessions = await openclaw_call("sessions.list")
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
return {"sessions": list(sessions.get("sessions") or [])}
return {"sessions": list(sessions or [])}
@router.get("/sessions/{session_id}")
async def get_session(session_id: str, auth=Depends(get_auth_context)) -> dict[str, object]:
require_admin(auth)
try:
sessions = await openclaw_call("sessions.list")
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
session = next((item for item in sessions_list if item.get("key") == session_id), None)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return {"session": session}
@router.get("/sessions/{session_id}/history")
async def get_session_history(session_id: str, auth=Depends(get_auth_context)) -> dict[str, object]:
require_admin(auth)
try:
history = await get_chat_history(session_id)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(history, dict) and isinstance(history.get("messages"), list):
return {"history": history["messages"]}
return {"history": list(history or [])}
@router.post("/sessions/{session_id}/message")
async def send_session_message(
session_id: str,
payload: dict = Body(...),
auth=Depends(get_auth_context),
) -> dict[str, bool]:
require_admin(auth)
content = payload.get("content")
if not content:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
)
try:
await send_message(content, session_key=session_id)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return {"ok": True}

View File

@@ -1,474 +0,0 @@
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.core.urls import public_api_base_url
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 _enforce_employee_create_policy(
session: Session, *, actor_employee_id: int, target_employee_type: str
) -> None:
"""Enforce: agents can only create/provision agents; humans can create humans + agents."""
actor = session.get(Employee, actor_employee_id)
if actor is None:
# Actor header is required; if it points to nothing, treat as invalid.
raise HTTPException(status_code=400, detail="Actor employee not found")
target = (target_employee_type or "").lower()
actor_type = (actor.employee_type or "").lower()
if actor_type == "agent" and target != "agent":
raise HTTPException(
status_code=403,
detail="Agent employees may only create/provision agent employees",
)
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"
"How to execute writes from an OpenClaw agent (IMPORTANT):\n"
"- Use the exec tool to run curl against the Base URL above.\n"
"- Example: start a task\n"
" curl -sS -X PATCH $BASE/tasks/<TASK_ID> -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"status\":\"in_progress\"}'\n"
"- Example: add a progress comment\n"
" curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"task_id\":<TASK_ID>,\"body\":\"...\"}'\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).
"""
# Enforce: agent actors may only provision agents (humans can provision agents).
_enforce_employee_create_policy(
session, actor_employee_id=actor_employee_id, target_employee_type=emp.employee_type
)
if emp.employee_type != "agent":
return
if emp.status != "active":
return
if emp.openclaw_session_key:
return
client = OpenClawClient.from_env()
if client is None:
return
# FULL IMPLEMENTATION: ensure a dedicated OpenClaw agent profile exists per employee.
try:
from app.integrations.openclaw_agents import ensure_full_agent_profile
info = ensure_full_agent_profile(
client=client,
employee_id=int(emp.id),
employee_name=emp.name,
)
emp.openclaw_agent_id = info["agent_id"]
session.add(emp)
session.flush()
except Exception as e:
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employee",
entity_id=emp.id,
verb="agent_profile_failed",
payload={"error": f"{type(e).__name__}: {e}"},
)
# Do not block employee creation on provisioning.
return
label = f"employee:{emp.id}:{emp.name}"
try:
resp = client.tools_invoke(
"sessions_spawn",
{
"task": _default_agent_prompt(emp),
"label": label,
"agentId": emp.openclaw_agent_id,
"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),
):
_enforce_employee_create_policy(
session, actor_employee_id=actor_employee_id, target_employee_type=payload.employee_type
)
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)

View File

@@ -1,178 +0,0 @@
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.models.projects import Project, ProjectMember
from app.schemas.projects import ProjectCreate, ProjectUpdate
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=list[Project])
def list_projects(session: Session = Depends(get_session)):
return session.exec(select(Project).order_by(Project.name.asc())).all()
@router.post("", response_model=Project)
def create_project(
payload: ProjectCreate,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
"""Create a project.
Keep operation atomic: flush to get id, log activity, then commit once.
Translate DB integrity errors to 409s.
"""
proj = Project(**payload.model_dump())
session.add(proj)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project",
entity_id=proj.id,
verb="created",
payload={"name": proj.name},
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(
status_code=409, detail="Project already exists or violates constraints"
)
session.refresh(proj)
return proj
@router.patch("/{project_id}", response_model=Project)
def update_project(
project_id: int,
payload: ProjectUpdate,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
proj = session.get(Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(proj, k, v)
session.add(proj)
session.commit()
session.refresh(proj)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project",
entity_id=proj.id,
verb="updated",
payload=data,
)
session.commit()
return proj
@router.get("/{project_id}/members", response_model=list[ProjectMember])
def list_project_members(project_id: int, session: Session = Depends(get_session)):
return session.exec(
select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.id.asc())
).all()
@router.post("/{project_id}/members", response_model=ProjectMember)
def add_project_member(
project_id: int,
payload: ProjectMember,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
existing = session.exec(
select(ProjectMember).where(
ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id
)
).first()
if existing:
raise HTTPException(status_code=409, detail="Member already added")
member = ProjectMember(
project_id=project_id, employee_id=payload.employee_id, role=payload.role
)
session.add(member)
session.commit()
session.refresh(member)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project_member",
entity_id=member.id,
verb="added",
payload={"project_id": project_id, "employee_id": member.employee_id, "role": member.role},
)
session.commit()
return member
@router.delete("/{project_id}/members/{member_id}")
def remove_project_member(
project_id: int,
member_id: int,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found")
session.delete(member)
session.commit()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project_member",
entity_id=member_id,
verb="removed",
payload={"project_id": project_id},
)
session.commit()
return {"ok": True}
@router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember)
def update_project_member(
project_id: int,
member_id: int,
payload: ProjectMember,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found")
if payload.role is not None:
member.role = payload.role
session.add(member)
session.commit()
session.refresh(member)
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project_member",
entity_id=member.id,
verb="updated",
payload={"project_id": project_id, "role": member.role},
)
session.commit()
return member

115
backend/app/api/tasks.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.auth import get_auth_context
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
from app.services.admin_access import require_admin
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
@router.get("", response_model=list[TaskRead])
def list_tasks(
board_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> list[Task]:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return list(session.exec(select(Task).where(Task.board_id == board.id)))
@router.post("", response_model=TaskRead)
def create_task(
board_id: str,
payload: TaskCreate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Task:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
task = Task.model_validate(payload)
task.board_id = board.id
if task.created_by_user_id is None and auth.user is not None:
task.created_by_user_id = auth.user.id
session.add(task)
session.commit()
session.refresh(task)
event = ActivityEvent(
event_type="task.created",
task_id=task.id,
message=f"Task created: {task.title}.",
)
session.add(event)
session.commit()
return task
@router.patch("/{task_id}", response_model=TaskRead)
def update_task(
board_id: str,
task_id: str,
payload: TaskUpdate,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> Task:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
task = session.get(Task, task_id)
if task is None or task.board_id != board.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
previous_status = task.status
updates = payload.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(task, key, value)
task.updated_at = datetime.utcnow()
session.add(task)
session.commit()
session.refresh(task)
if "status" in updates and task.status != previous_status:
event_type = "task.status_changed"
message = f"Task moved to {task.status}: {task.title}."
else:
event_type = "task.updated"
message = f"Task updated: {task.title}."
event = ActivityEvent(event_type=event_type, task_id=task.id, message=message)
session.add(event)
session.commit()
return task
@router.delete("/{task_id}")
def delete_task(
board_id: str,
task_id: str,
session: Session = Depends(get_session),
auth=Depends(get_auth_context),
) -> dict[str, bool]:
require_admin(auth)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
task = session.get(Task, task_id)
if task and task.board_id == board.id:
session.delete(task)
session.commit()
return {"ok": True}

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import Header, HTTPException
from sqlmodel import Session
from app.models.activity import Activity
def log_activity(
session: Session,
*,
actor_employee_id: int | None,
entity_type: str,
entity_id: int | None,
verb: str,
payload: dict[str, Any] | None = None,
) -> None:
session.add(
Activity(
actor_employee_id=actor_employee_id,
entity_type=entity_type,
entity_id=entity_id,
verb=verb,
payload_json=json.dumps(payload) if payload is not None else None,
)
)
def get_actor_employee_id(
x_actor_employee_id: int | None = Header(default=None, alias="X-Actor-Employee-Id"),
) -> int:
if x_actor_employee_id is None:
raise HTTPException(status_code=400, detail="X-Actor-Employee-Id required")
return x_actor_employee_id

View File

@@ -1,443 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, 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.notify import NotifyContext, notify_openclaw
from app.integrations.openclaw import OpenClawClient
from app.models.org import Employee
from app.models.work import Task, TaskComment
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
logger = logging.getLogger("app.work")
router = APIRouter(tags=["work"])
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None:
"""Enforce that only provisioned agents can be assigned tasks.
Humans can be assigned regardless.
Agents must be active, notify_enabled, and have openclaw_session_key.
"""
emp = session.get(Employee, assignee_employee_id)
if emp is None:
raise HTTPException(status_code=400, detail="Assignee employee not found")
if emp.employee_type == "agent":
if emp.status != "active":
raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent")
if not emp.notify_enabled:
raise HTTPException(
status_code=400, detail="Cannot assign task to agent with notifications disabled"
)
if not emp.openclaw_session_key:
raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent")
@router.get("/tasks", response_model=list[Task])
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
stmt = select(Task).order_by(Task.id.asc())
if project_id is not None:
stmt = stmt.where(Task.project_id == project_id)
return session.exec(stmt).all()
@router.post("/tasks", response_model=Task)
def create_task(
payload: TaskCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
# SECURITY / AUDIT: never allow spoofing task creator.
# The creator is always the actor making the request.
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id})
if payload.assignee_employee_id is not None:
_validate_task_assignee(session, payload.assignee_employee_id)
# Default reviewer to the manager of the assignee (if not explicitly provided).
if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
assignee = session.get(Employee, payload.assignee_employee_id)
if assignee is not None and assignee.manager_id is not None:
payload = TaskCreate(
**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}
)
task = Task(**payload.model_dump())
if task.status not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status")
task.updated_at = datetime.utcnow()
session.add(task)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="created",
payload={"project_id": task.project_id, "title": task.title},
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Task create violates constraints")
session.refresh(task)
background.add_task(
notify_openclaw,
NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task_id=task.id),
)
# Explicitly return a serializable payload (guards against empty {} responses)
return Task.model_validate(task)
@router.post("/tasks/{task_id}/dispatch")
def dispatch_task(
task_id: int,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
logger.info(
"dispatch_task: loaded",
extra={
"task_id": getattr(task, "id", None),
"assignee_employee_id": getattr(task, "assignee_employee_id", None),
},
)
if task.assignee_employee_id is None:
raise HTTPException(status_code=400, detail="Task has no assignee")
_validate_task_assignee(session, task.assignee_employee_id)
client = OpenClawClient.from_env()
if client is None:
logger.warning("dispatch_task: missing OpenClaw env")
raise HTTPException(
status_code=503,
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
)
# Best-effort: enqueue a dispatch notification.
# IMPORTANT: if a task is already in review, the reviewer (not the assignee) should be notified.
status = (getattr(task, "status", None) or "").lower()
if status in {"review", "ready_for_review"}:
background.add_task(
notify_openclaw,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields={"status": {"to": task.status}},
),
)
else:
background.add_task(
notify_openclaw,
NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task_id=task.id),
)
return {"ok": True}
def _require_reviewer_comment(body: str | None) -> str:
if body is None or not body.strip():
raise HTTPException(status_code=400, detail="Reviewer must provide a comment for audit")
return body.strip()
@router.post("/tasks/{task_id}/review", response_model=Task)
def review_task(
task_id: int,
payload: TaskReviewDecision,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
"""Reviewer approves or requests changes.
- Approve => status=done
- Changes => status=in_progress
Always writes a TaskComment by the reviewer for audit.
"""
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.reviewer_employee_id is None:
raise HTTPException(status_code=400, detail="Task has no reviewer")
if actor_employee_id != task.reviewer_employee_id:
raise HTTPException(status_code=403, detail="Only the reviewer can approve/request changes")
decision = (payload.decision or "").strip().lower()
if decision not in {"approve", "changes"}:
raise HTTPException(status_code=400, detail="Invalid decision")
comment_body = _require_reviewer_comment(payload.comment_body)
new_status = "done" if decision == "approve" else "in_progress"
before_status = task.status
task.status = new_status
task.updated_at = datetime.utcnow()
session.add(task)
c = TaskComment(task_id=task.id, author_employee_id=actor_employee_id, body=comment_body)
session.add(c)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="reviewed",
payload={"decision": decision, "status": new_status},
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Review action violates constraints")
session.refresh(task)
session.refresh(c)
# Notify assignee (comment.created will exclude author)
background.add_task(
notify_openclaw,
NotifyContext(
event="comment.created",
actor_employee_id=actor_employee_id,
task_id=task.id,
comment_id=c.id,
),
)
# Notify reviewer/PMs about status change
if before_status != task.status:
background.add_task(
notify_openclaw,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields={"status": {"from": before_status, "to": task.status}},
),
)
return Task.model_validate(task)
@router.patch("/tasks/{task_id}", response_model=Task)
def update_task(
task_id: int,
payload: TaskUpdate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
before = {
"assignee_employee_id": task.assignee_employee_id,
"reviewer_employee_id": task.reviewer_employee_id,
"status": task.status,
}
data = payload.model_dump(exclude_unset=True)
if "assignee_employee_id" in data and data["assignee_employee_id"] is not None:
_validate_task_assignee(session, data["assignee_employee_id"])
if "status" in data and data["status"] not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status")
# Enforce review workflow: agent assignees cannot mark tasks done directly.
if data.get("status") == "done":
assignee = (
session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None
)
if assignee is not None and assignee.employee_type == "agent":
if actor_employee_id == task.assignee_employee_id:
raise HTTPException(
status_code=403,
detail="Assignee agents cannot mark tasks done; set status=review for manager approval",
)
if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id:
raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done")
# If a task is sent to review and no reviewer is set, default reviewer to assignee's manager.
if (
data.get("status") in {"review", "ready_for_review"}
and data.get("reviewer_employee_id") is None
):
assignee_id = data.get("assignee_employee_id", task.assignee_employee_id)
if assignee_id is not None:
assignee = session.get(Employee, assignee_id)
if assignee is not None and assignee.manager_id is not None:
data["reviewer_employee_id"] = assignee.manager_id
for k, v in data.items():
setattr(task, k, v)
task.updated_at = datetime.utcnow()
session.add(task)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="updated",
payload=data,
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Task update violates constraints")
session.refresh(task)
# notify based on meaningful changes
changed = {}
if before.get("assignee_employee_id") != task.assignee_employee_id:
changed["assignee_employee_id"] = {
"from": before.get("assignee_employee_id"),
"to": task.assignee_employee_id,
}
background.add_task(
notify_openclaw,
NotifyContext(
event="task.assigned",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=changed,
),
)
if before.get("status") != task.status:
changed["status"] = {"from": before.get("status"), "to": task.status}
background.add_task(
notify_openclaw,
NotifyContext(
event="status.changed",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=changed,
),
)
if not changed and data:
background.add_task(
notify_openclaw,
NotifyContext(
event="task.updated",
actor_employee_id=actor_employee_id,
task_id=task.id,
changed_fields=data,
),
)
return Task.model_validate(task)
@router.delete("/tasks/{task_id}")
def delete_task(
task_id: int,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
session.delete(task)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task_id,
verb="deleted",
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Task delete violates constraints")
return {"ok": True}
@router.get("/task-comments", response_model=list[TaskComment])
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
return session.exec(
select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())
).all()
@router.post("/task-comments", response_model=TaskComment)
def create_task_comment(
payload: TaskCommentCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
# SECURITY / AUDIT: never allow spoofing comment authorship.
# The author is always the actor making the request.
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
c = TaskComment(**payload.model_dump())
session.add(c)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=c.task_id,
verb="commented",
)
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Comment create violates constraints")
session.refresh(c)
task = session.get(Task, c.task_id)
if task is not None:
background.add_task(
notify_openclaw,
NotifyContext(
event="comment.created",
actor_employee_id=actor_employee_id,
task_id=task.id,
comment_id=c.id,
),
)
return TaskComment.model_validate(c)