Merge master into jarvis/human-id-1

This commit is contained in:
Jarvis
2026-02-02 16:58:39 +00:00
55 changed files with 2230 additions and 197 deletions

28
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3
files: ^backend/.*\.py$
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
files: ^backend/.*\.py$
- repo: https://github.com/PyCQA/flake8
rev: 7.1.1
hooks:
- id: flake8
files: ^backend/.*\.py$
args: [--config=backend/.flake8]

View File

@@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w
Uses local Postgres: Uses local Postgres:
- user: `postgres` - user: `postgres`
- password: `netbox` - password: `REDACTED`
- db: `openclaw_agency` - db: `openclaw_agency`
## Environment ## Environment

10
backend/.flake8 Normal file
View File

@@ -0,0 +1,10 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503, E501
exclude =
.venv,
backend/.venv,
alembic,
backend/alembic,
**/__pycache__,
**/*.pyc

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from sqlmodel import SQLModel from sqlmodel import SQLModel
from alembic import context
# Import models to register tables in metadata # Import models to register tables in metadata
from app import models # noqa: F401 from app import models # noqa: F401
from app.core.config import settings
config = context.config config = context.config

View File

@@ -8,9 +8,9 @@ Create Date: 2026-02-02
from __future__ import annotations from __future__ import annotations
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "3f2c1b9c8e12" revision = "3f2c1b9c8e12"

View File

@@ -10,6 +10,7 @@ from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View File

@@ -13,7 +13,9 @@ router = APIRouter(prefix="/activities", tags=["activities"])
@router.get("") @router.get("")
def list_activities(limit: int = 50, session: Session = Depends(get_session)): 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() items = session.exec(
select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))
).all()
out = [] out = []
for a in items: for a in items:
out.append( out.append(

View File

@@ -5,21 +5,42 @@ from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.utils import get_actor_employee_id, log_activity 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.db.session import get_session
from app.integrations.openclaw import OpenClawClient from app.integrations.openclaw import OpenClawClient
from app.models.org import Department, Team, Employee from app.models.org import Department, Employee, Team
from app.schemas.org import ( from app.schemas.org import (
DepartmentCreate, DepartmentCreate,
DepartmentUpdate, DepartmentUpdate,
TeamCreate,
TeamUpdate,
EmployeeCreate, EmployeeCreate,
EmployeeUpdate, EmployeeUpdate,
TeamCreate,
TeamUpdate,
) )
router = APIRouter(tags=["org"]) 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: def _default_agent_prompt(emp: Employee) -> str:
"""Generate a conservative default prompt for a newly-created agent employee. """Generate a conservative default prompt for a newly-created agent employee.
@@ -34,9 +55,15 @@ def _default_agent_prompt(emp: Employee) -> str:
f"Your employee_id is {emp.id}.\n" f"Your employee_id is {emp.id}.\n"
f"Title: {title}. Department id: {dept}.\n\n" f"Title: {title}. Department id: {dept}.\n\n"
"Mission Control API access (no UI):\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" f"- Base URL: {public_api_base_url()}\n"
"- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: <your employee_id>\n" "- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: <your_employee_id>\n"
f" For you: X-Actor-Employee-Id: {emp.id}\n\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" "Common endpoints (JSON):\n"
"- GET /tasks, POST /tasks\n" "- GET /tasks, POST /tasks\n"
"- GET /task-comments, POST /task-comments\n" "- GET /task-comments, POST /task-comments\n"
@@ -44,7 +71,11 @@ def _default_agent_prompt(emp: Employee) -> str:
"- OpenAPI schema: GET /openapi.json\n\n" "- OpenAPI schema: GET /openapi.json\n\n"
"Rules:\n" "Rules:\n"
"- Use the Mission Control API only (no UI).\n" "- Use the Mission Control API only (no UI).\n"
"- When notified about tasks/comments, respond with concise, actionable updates.\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" "- Do not invent facts; ask for missing context.\n"
) )
@@ -56,6 +87,11 @@ def _maybe_auto_provision_agent(session: Session, *, emp: Employee, actor_employ
we leave the employee as-is (openclaw_session_key stays null). 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": if emp.employee_type != "agent":
return return
if emp.status != "active": if emp.status != "active":
@@ -159,7 +195,11 @@ def create_team(
entity_type="team", entity_type="team",
entity_id=team.id, entity_id=team.id,
verb="created", verb="created",
payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id}, payload={
"name": team.name,
"department_id": team.department_id,
"lead_employee_id": team.lead_employee_id,
},
) )
session.commit() session.commit()
except IntegrityError: except IntegrityError:
@@ -188,7 +228,14 @@ def update_team(
session.add(team) session.add(team)
try: try:
session.flush() session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data) log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="team",
entity_id=team.id,
verb="updated",
payload=data,
)
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
@@ -226,7 +273,9 @@ def create_department(
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
raise HTTPException(status_code=409, detail="Department already exists or violates constraints") raise HTTPException(
status_code=409, detail="Department already exists or violates constraints"
)
session.refresh(dept) session.refresh(dept)
return dept return dept
@@ -250,7 +299,14 @@ def update_department(
session.add(dept) session.add(dept)
session.commit() session.commit()
session.refresh(dept) session.refresh(dept)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="department", entity_id=dept.id, verb="updated", payload=data) log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="department",
entity_id=dept.id,
verb="updated",
payload=data,
)
session.commit() session.commit()
return dept return dept
@@ -266,6 +322,10 @@ def create_employee(
session: Session = Depends(get_session), session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id), 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()) emp = Employee(**payload.model_dump())
session.add(emp) session.add(emp)
@@ -310,7 +370,14 @@ def update_employee(
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="updated", payload=data) log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employee",
entity_id=emp.id,
verb="updated",
payload=data,
)
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
@@ -357,7 +424,10 @@ def deprovision_employee_agent(
try: try:
client.tools_invoke( client.tools_invoke(
"sessions_send", "sessions_send",
{"sessionKey": emp.openclaw_session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."}, {
"sessionKey": emp.openclaw_session_key,
"message": "You are being deprovisioned. Stop all work and ignore future messages.",
},
timeout_s=5.0, timeout_s=5.0,
) )
except Exception: except Exception:

View File

@@ -4,7 +4,7 @@ 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.models.projects import Project, ProjectMember from app.models.projects import Project, ProjectMember
from app.schemas.projects import ProjectCreate, ProjectUpdate from app.schemas.projects import ProjectCreate, ProjectUpdate
@@ -45,15 +45,21 @@ def create_project(
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
raise HTTPException(status_code=409, detail="Project already exists or violates constraints") raise HTTPException(
status_code=409, detail="Project already exists or violates constraints"
)
session.refresh(proj) session.refresh(proj)
return proj return proj
@router.patch("/{project_id}", response_model=Project) @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)): 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) proj = session.get(Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
@@ -65,7 +71,14 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
session.add(proj) session.add(proj)
session.commit() session.commit()
session.refresh(proj) session.refresh(proj)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="project", entity_id=proj.id, verb="updated", payload=data) log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="project",
entity_id=proj.id,
verb="updated",
payload=data,
)
session.commit() session.commit()
return proj return proj
@@ -73,16 +86,29 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
@router.get("/{project_id}/members", response_model=list[ProjectMember]) @router.get("/{project_id}/members", response_model=list[ProjectMember])
def list_project_members(project_id: int, session: Session = Depends(get_session)): def list_project_members(project_id: int, session: Session = Depends(get_session)):
return session.exec( return session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc()) select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.id.asc())
).all() ).all()
@router.post("/{project_id}/members", response_model=ProjectMember) @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)): def add_project_member(
existing = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id)).first() 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: if existing:
raise HTTPException(status_code=409, detail="Member already added") raise HTTPException(status_code=409, detail="Member already added")
member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role) member = ProjectMember(
project_id=project_id, employee_id=payload.employee_id, role=payload.role
)
session.add(member) session.add(member)
session.commit() session.commit()
session.refresh(member) session.refresh(member)
@@ -99,7 +125,12 @@ def add_project_member(project_id: int, payload: ProjectMember, session: Session
@router.delete("/{project_id}/members/{member_id}") @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)): 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) member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id: if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found") raise HTTPException(status_code=404, detail="Project member not found")
@@ -118,7 +149,13 @@ def remove_project_member(project_id: int, member_id: int, session: Session = De
@router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember) @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)): 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) member = session.get(ProjectMember, member_id)
if not member or member.project_id != project_id: if not member or member.project_id != project_id:
raise HTTPException(status_code=404, detail="Project member not found") raise HTTPException(status_code=404, detail="Project member not found")

View File

@@ -1,23 +1,49 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlmodel import Session, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api.utils import log_activity, get_actor_employee_id from app.api.utils import get_actor_employee_id, log_activity
from app.db.session import get_session from app.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.org import Employee
from app.models.work import Task, TaskComment from app.models.work import Task, TaskComment
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
from app.integrations.notify import NotifyContext, notify_openclaw
logger = logging.getLogger("app.work")
router = APIRouter(tags=["work"]) router = APIRouter(tags=["work"])
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"} 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]) @router.get("/tasks", response_model=list[Task])
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)): def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
stmt = select(Task).order_by(Task.id.asc()) stmt = select(Task).order_by(Task.id.asc())
@@ -27,15 +53,27 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses
@router.post("/tasks", response_model=Task) @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)): def create_task(
payload: TaskCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
if payload.created_by_employee_id is None: if payload.created_by_employee_id is None:
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}) 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). # 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: if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
assignee = session.get(Employee, payload.assignee_employee_id) assignee = session.get(Employee, payload.assignee_employee_id)
if assignee is not None and assignee.manager_id is not None: if assignee is not None and assignee.manager_id is not None:
payload = TaskCreate(**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}) payload = TaskCreate(
**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}
)
task = Task(**payload.model_dump()) task = Task(**payload.model_dump())
if task.status not in ALLOWED_STATUSES: if task.status not in ALLOWED_STATUSES:
@@ -59,23 +97,197 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi
raise HTTPException(status_code=409, detail="Task create violates constraints") raise HTTPException(status_code=409, detail="Task create violates constraints")
session.refresh(task) session.refresh(task)
background.add_task(notify_openclaw, session, NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task=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) # Explicitly return a serializable payload (guards against empty {} responses)
return Task.model_validate(task) return Task.model_validate(task)
@router.patch("/tasks/{task_id}", response_model=Task) @router.post("/tasks/{task_id}/dispatch")
def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): 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) task = session.get(Task, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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} 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 an agent dispatch. This does not mutate the task.
background.add_task(
notify_openclaw,
session,
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,
session,
NotifyContext(
event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c
),
)
# 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) 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: if "status" in data and data["status"] not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status") 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(): for k, v in data.items():
setattr(task, k, v) setattr(task, k, v)
task.updated_at = datetime.utcnow() task.updated_at = datetime.utcnow()
@@ -83,7 +295,14 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
try: try:
session.flush() session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task.id, verb="updated", payload=data) log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task.id,
verb="updated",
payload=data,
)
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
@@ -94,19 +313,51 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
# notify based on meaningful changes # notify based on meaningful changes
changed = {} changed = {}
if before.get("assignee_employee_id") != task.assignee_employee_id: 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} changed["assignee_employee_id"] = {
background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, changed_fields=changed)) "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: if before.get("status") != task.status:
changed["status"] = {"from": before.get("status"), "to": task.status} changed["status"] = {"from": before.get("status"), "to": task.status}
background.add_task(notify_openclaw, session, NotifyContext(event="status.changed", actor_employee_id=actor_employee_id, task=task, changed_fields=changed)) 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: if not changed and data:
background.add_task(notify_openclaw, session, NotifyContext(event="task.updated", actor_employee_id=actor_employee_id, task=task, changed_fields=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) return Task.model_validate(task)
@router.delete("/tasks/{task_id}") @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)): 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) task = session.get(Task, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
@@ -114,7 +365,13 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
session.delete(task) session.delete(task)
try: try:
session.flush() session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task_id, verb="deleted") log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=task_id,
verb="deleted",
)
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
@@ -125,20 +382,35 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
@router.get("/task-comments", response_model=list[TaskComment]) @router.get("/task-comments", response_model=list[TaskComment])
def list_task_comments(task_id: int, session: Session = Depends(get_session)): 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() return session.exec(
select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())
).all()
@router.post("/task-comments", response_model=TaskComment) @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)): def create_task_comment(
payload: TaskCommentCreate,
background: BackgroundTasks,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
):
if payload.author_employee_id is None: if payload.author_employee_id is None:
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id}) payload = TaskCommentCreate(
**{**payload.model_dump(), "author_employee_id": actor_employee_id}
)
c = TaskComment(**payload.model_dump()) c = TaskComment(**payload.model_dump())
session.add(c) session.add(c)
try: try:
session.flush() session.flush()
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=c.task_id, verb="commented") log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="task",
entity_id=c.task_id,
verb="commented",
)
session.commit() session.commit()
except IntegrityError: except IntegrityError:
session.rollback() session.rollback()
@@ -147,5 +419,13 @@ def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks,
session.refresh(c) session.refresh(c)
task = session.get(Task, c.task_id) task = session.get(Task, c.task_id)
if task is not None: if task is not None:
background.add_task(notify_openclaw, session, NotifyContext(event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c)) 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) return TaskComment.model_validate(c)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import logging
import os
import sys
from typing import Any
def _level() -> str:
return (os.environ.get("LOG_LEVEL") or os.environ.get("UVICORN_LOG_LEVEL") or "INFO").upper()
def configure_logging() -> None:
"""Configure app logging to stream to stdout.
Uvicorn already logs requests, but we want our app/integrations logs to be visible
in the same console stream.
"""
level = getattr(logging, _level(), logging.INFO)
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers (e.g., when autoreload imports twice)
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Make common noisy loggers respect our level
for name in [
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"sqlalchemy.engine",
"httpx",
"requests",
]:
logging.getLogger(name).setLevel(level)
def log_kv(logger: logging.Logger, msg: str, **kv: Any) -> None:
# Lightweight key-value logging without requiring JSON logging.
if kv:
suffix = " ".join(f"{k}={v!r}" for k, v in kv.items())
logger.info(f"{msg} | {suffix}")
else:
logger.info(msg)

35
backend/app/core/urls.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
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 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()
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.", "10.", "172.")):
return f"http://{ip}:8000"
except Exception:
pass
return "http://<dev-machine-ip>:8000"

View File

@@ -1,43 +1,51 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
from sqlmodel import Session, select from sqlmodel import Session, select
from app.db.session import engine
from app.integrations.openclaw import OpenClawClient from app.integrations.openclaw import OpenClawClient
from app.models.org import Employee from app.models.org import Employee
from app.models.projects import ProjectMember from app.models.projects import ProjectMember
from app.models.work import Task, TaskComment from app.models.work import Task, TaskComment
logger = logging.getLogger("app.notify")
@dataclass(frozen=True) @dataclass(frozen=True)
class NotifyContext: class NotifyContext:
"""Notification context.
IMPORTANT: this is passed into FastAPI BackgroundTasks.
Do not store live SQLAlchemy/SQLModel objects here; only ids/primitive data.
"""
event: str # task.created | task.updated | task.assigned | comment.created | status.changed event: str # task.created | task.updated | task.assigned | comment.created | status.changed
actor_employee_id: int actor_employee_id: int
task: Task task_id: int
comment: TaskComment | None = None comment_id: int | None = None
changed_fields: dict | None = None changed_fields: dict | None = None
def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]: def _employees_with_session_keys(session: Session, employee_ids: Iterable[int]) -> list[Employee]:
ids = sorted({i for i in employee_ids if i is not None}) ids = sorted({i for i in employee_ids if i is not None})
if not ids: if not ids:
return [] return []
emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all() emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all()
keys: list[str] = [] out: list[Employee] = []
for e in emps: for e in emps:
if not getattr(e, "notify_enabled", True): if not getattr(e, "notify_enabled", True):
continue continue
sk = getattr(e, "openclaw_session_key", None) if getattr(e, "openclaw_session_key", None):
if sk: out.append(e)
keys.append(sk) return out
return sorted(set(keys))
def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]: def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
# Generic, data-driven: PMs are determined by project_members.role.
pms = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all() pms = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all()
pm_ids: set[int] = set() pm_ids: set[int] = set()
for m in pms: for m in pms:
@@ -47,89 +55,221 @@ def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
return pm_ids return pm_ids
def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]: def resolve_recipients(
t = ctx.task session: Session, ctx: NotifyContext, task: Task, comment: TaskComment | None
) -> set[int]:
recipients: set[int] = set() recipients: set[int] = set()
if ctx.event == "task.created": if ctx.event == "task.created":
# notify assignee + PMs if task.assignee_employee_id:
if t.assignee_employee_id: recipients.add(task.assignee_employee_id)
recipients.add(t.assignee_employee_id) recipients |= _project_pm_employee_ids(session, task.project_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
elif ctx.event == "task.assigned": elif ctx.event == "task.assigned":
if t.assignee_employee_id: if task.assignee_employee_id:
recipients.add(t.assignee_employee_id) recipients.add(task.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id) recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "comment.created": elif ctx.event == "comment.created":
# notify assignee + reviewer + PMs, excluding author if task.assignee_employee_id:
if t.assignee_employee_id: recipients.add(task.assignee_employee_id)
recipients.add(t.assignee_employee_id) if task.reviewer_employee_id:
if t.reviewer_employee_id: recipients.add(task.reviewer_employee_id)
recipients.add(t.reviewer_employee_id) recipients |= _project_pm_employee_ids(session, task.project_id)
recipients |= _project_pm_employee_ids(session, t.project_id) if comment and comment.author_employee_id:
if ctx.comment and ctx.comment.author_employee_id: recipients.discard(comment.author_employee_id)
recipients.discard(ctx.comment.author_employee_id)
elif ctx.event == "status.changed": elif ctx.event == "status.changed":
new_status = (getattr(t, "status", None) or "").lower() new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and t.reviewer_employee_id: if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
recipients.add(t.reviewer_employee_id) recipients.add(task.reviewer_employee_id)
recipients |= _project_pm_employee_ids(session, t.project_id) recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "task.updated": elif ctx.event == "task.updated":
# conservative: PMs only recipients |= _project_pm_employee_ids(session, task.project_id)
recipients |= _project_pm_employee_ids(session, t.project_id)
recipients.discard(ctx.actor_employee_id) recipients.discard(ctx.actor_employee_id)
return recipients return recipients
def build_message(ctx: NotifyContext) -> str: def ensure_employee_provisioned(session: Session, employee_id: int) -> None:
t = ctx.task """Best-effort provisioning of a reviewer/manager so notifications can be delivered."""
base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}"
if ctx.event == "task.assigned": emp = session.get(Employee, employee_id)
return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress." if emp is None:
return
if not getattr(emp, "notify_enabled", True):
return
if getattr(emp, "openclaw_session_key", None):
return
client = OpenClawClient.from_env()
if client is None:
logger.warning(
"ensure_employee_provisioned: missing OpenClaw env", extra={"employee_id": employee_id}
)
return
prompt = (
f"You are {emp.name} (employee_id={emp.id}).\n"
"You are a reviewer/manager in Mission Control.\n"
"When you get a review request, open Mission Control and approve or request changes.\n"
)
try:
res = client.tools_invoke(
"sessions_spawn",
{"task": prompt, "label": f"employee:{emp.id}:{emp.name}"},
timeout_s=20.0,
)
details = (res.get("result") or {}).get("details") or {}
sk = details.get("childSessionKey") or details.get("sessionKey")
if sk:
emp.openclaw_session_key = sk
session.add(emp)
session.commit()
logger.info(
"ensure_employee_provisioned: provisioned",
extra={"employee_id": emp.id, "session_key": sk},
)
except Exception:
session.rollback()
logger.exception("ensure_employee_provisioned: failed", extra={"employee_id": employee_id})
def build_message(
*,
ctx: NotifyContext,
task: Task,
comment: TaskComment | None,
recipient: Employee,
base_url: str,
) -> str:
base = f"Task #{task.id}: {task.title}" if task.id is not None else f"Task: {task.title}"
if ctx.event in {"task.created", "task.assigned"} and recipient.employee_type == "agent":
desc = (task.description or "").strip()
if len(desc) > 500:
desc = desc[:497] + "..."
desc_block = f"\n\nDescription:\n{desc}" if desc else ""
return (
f"{base}\n\n"
f"Set BASE={base_url}\n\n"
"You are the assignee. Start NOW (use the exec tool to run these curl commands):\n"
f"1) curl -sS -X PATCH $BASE/tasks/{task.id} -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"status\":\"in_progress\"}}'\n"
f"2) curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"task_id\":{task.id},\"body\":\"Plan: ... Next: ...\"}}'\n"
"3) Do the work\n"
"4) Post progress updates via POST $BASE/task-comments (same headers)\n"
f"5) When complete: set status=review (assignee cannot set done) and wait for manager approval\n"
f"{desc_block}"
)
if ctx.event == "comment.created": if ctx.event == "comment.created":
snippet = "" snippet = ""
if ctx.comment and ctx.comment.body: if comment and comment.body:
snippet = ctx.comment.body.strip().replace("\n", " ") snippet = comment.body.strip().replace("\n", " ")
if len(snippet) > 180: if len(snippet) > 180:
snippet = snippet[:177] + "..." snippet = snippet[:177] + "..."
snippet = f"\nComment: {snippet}" snippet = f"\nComment: {snippet}"
return f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control." return f"New comment on {base}.{snippet}\nPlease review and respond in Mission Control."
if ctx.event == "status.changed": if ctx.event == "status.changed":
return f"Status changed on {base}{t.status}.\nWork ONE task only; update Mission Control with next step." new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"}:
return (
f"Review requested for {base}.\n"
"As the reviewer/manager, you must:\n"
"1) Read the task + latest assignee comments\n"
"2) Decide: approve or request changes\n"
"3) Leave an audit comment explaining your decision (required)\n"
f"4) Submit decision via POST /tasks/{task.id}/review (decision=approve|changes)\n"
"Approve → task becomes done. Changes → task returns to in_progress and assignee is notified."
)
return (
f"Status changed on {base}{task.status}.\n"
"Please review and respond in Mission Control."
)
if ctx.event == "task.created": if ctx.event == "task.created":
return f"New task created: {base}.\nWork ONE task only; add acceptance criteria / next step in Mission Control." return f"New task created: {base}.\nPlease review and respond in Mission Control."
return f"Update on {base}.\nWork ONE task only; update Mission Control." if ctx.event == "task.assigned":
return f"Assigned: {base}.\nPlease review and respond in Mission Control."
return f"Update on {base}.\nPlease review and respond in Mission Control."
def notify_openclaw(session: Session, ctx: NotifyContext) -> None: def notify_openclaw(ctx: NotifyContext) -> None:
"""Send OpenClaw notifications.
Runs in BackgroundTasks; opens its own DB session for safety.
"""
client = OpenClawClient.from_env() client = OpenClawClient.from_env()
logger.info(
"notify_openclaw: start",
extra={"event": ctx.event, "task_id": ctx.task_id, "actor": ctx.actor_employee_id},
)
if client is None: if client is None:
logger.warning("notify_openclaw: skipped (missing OpenClaw env)")
return return
recipient_ids = resolve_recipients(session, ctx) with Session(engine) as session:
session_keys = _employee_session_keys(session, recipient_ids) task = session.get(Task, ctx.task_id)
if not session_keys: if task is None:
logger.warning("notify_openclaw: task not found", extra={"task_id": ctx.task_id})
return return
message = build_message(ctx) comment = session.get(TaskComment, ctx.comment_id) if ctx.comment_id else None
if ctx.event == "status.changed":
new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
ensure_employee_provisioned(session, int(task.reviewer_employee_id))
recipient_ids = resolve_recipients(session, ctx, task, comment)
logger.info(
"notify_openclaw: recipients resolved", extra={"recipient_ids": sorted(recipient_ids)}
)
recipients = _employees_with_session_keys(session, recipient_ids)
if not recipients:
logger.info("notify_openclaw: no recipients with session keys")
return
# base URL used in agent messages
base_url = __import__(
"app.core.urls", fromlist=["public_api_base_url"]
).public_api_base_url()
for e in recipients:
sk = getattr(e, "openclaw_session_key", None)
if not sk:
continue
message = build_message(
ctx=ctx,
task=task,
comment=comment,
recipient=e,
base_url=base_url,
)
for sk in session_keys:
try: try:
client.tools_invoke( client.tools_invoke(
"sessions_send", "sessions_send",
{"sessionKey": sk, "message": message}, {"sessionKey": sk, "message": message},
timeout_s=3.0, timeout_s=30.0,
) )
except Exception: except Exception:
# best-effort; never break Mission Control writes # keep the log, but avoid giant stack spam unless debugging
logger.warning(
"notify_openclaw: sessions_send failed",
extra={
"event": ctx.event,
"task_id": ctx.task_id,
"to_employee_id": getattr(e, "id", None),
"session_key": sk,
},
)
continue continue

View File

@@ -1,9 +1,14 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import time
from typing import Any from typing import Any
import requests import requests
from requests.exceptions import ReadTimeout, RequestException
logger = logging.getLogger("app.openclaw")
class OpenClawClient: class OpenClawClient:
@@ -13,22 +18,71 @@ class OpenClawClient:
@classmethod @classmethod
def from_env(cls) -> "OpenClawClient | None": def from_env(cls) -> "OpenClawClient | None":
# Ensure .env is loaded into os.environ (pydantic Settings reads env_file but
# does not automatically populate os.environ).
try:
from dotenv import load_dotenv
load_dotenv(override=False)
except Exception:
pass
url = os.environ.get("OPENCLAW_GATEWAY_URL") url = os.environ.get("OPENCLAW_GATEWAY_URL")
token = os.environ.get("OPENCLAW_GATEWAY_TOKEN") token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
if not url or not token: if not url or not token:
return None return None
return cls(url, token) return cls(url, token)
def tools_invoke(self, tool: str, args: dict[str, Any], *, session_key: str | None = None, timeout_s: float = 5.0) -> dict[str, Any]: def tools_invoke(
self,
tool: str,
args: dict[str, Any],
*,
session_key: str | None = None,
timeout_s: float = 10.0,
) -> dict[str, Any]:
payload: dict[str, Any] = {"tool": tool, "args": args} payload: dict[str, Any] = {"tool": tool, "args": args}
logger.info(
"openclaw.tools_invoke",
extra={"tool": tool, "has_session_key": bool(session_key), "timeout_s": timeout_s},
)
if session_key is not None: if session_key is not None:
payload["sessionKey"] = session_key payload["sessionKey"] = session_key
last_err: Exception | None = None
# Retry a few times; the gateway can be busy and respond slowly.
for attempt in range(4):
try:
r = requests.post( r = requests.post(
f"{self.base_url}/tools/invoke", f"{self.base_url}/tools/invoke",
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
json=payload, json=payload,
timeout=timeout_s, # connect timeout, read timeout
timeout=(2.0, timeout_s),
) )
r.raise_for_status() r.raise_for_status()
logger.info(
"openclaw.tools_invoke: ok",
extra={"tool": tool, "status": r.status_code, "attempt": attempt + 1},
)
return r.json() return r.json()
except ReadTimeout as e:
last_err = e
logger.warning(
"openclaw.tools_invoke: timeout",
extra={"tool": tool, "attempt": attempt + 1, "timeout_s": timeout_s},
)
time.sleep(0.5 * (2**attempt))
except RequestException as e:
last_err = e
logger.warning(
"openclaw.tools_invoke: request error",
extra={"tool": tool, "attempt": attempt + 1, "error": str(e)},
)
time.sleep(0.5 * (2**attempt))
assert last_err is not None
raise last_err

View File

@@ -8,8 +8,11 @@ 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
from app.core.config import settings from app.core.config import settings
from app.core.logging import configure_logging
from app.db.session import init_db from app.db.session import init_db
configure_logging()
app = FastAPI(title="OpenClaw Agency API", version="0.3.0") app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]

View File

@@ -1,5 +1,5 @@
from app.models.activity import Activity from app.models.activity import Activity
from app.models.org import Department, Team, Employee from app.models.org import Department, Employee, Team
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

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel

View File

@@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel):
author_employee_id: int | None = None author_employee_id: int | None = None
reply_to_comment_id: int | None = None reply_to_comment_id: int | None = None
body: str body: str
class TaskReviewDecision(SQLModel):
decision: str # approve | changes
comment_body: str

9
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[tool.black]
line-length = 100
target-version = ["py312"]
extend-exclude = '(\.venv|alembic/versions)'
[tool.isort]
profile = "black"
line_length = 100
skip = [".venv", "alembic/versions"]

View File

@@ -0,0 +1,4 @@
black==24.10.0
isort==5.13.2
flake8==7.1.1
pre-commit==4.1.0

View File

@@ -2,7 +2,7 @@ fastapi
uvicorn[standard] uvicorn[standard]
sqlmodel sqlmodel
alembic alembic
psycopg2-binary psycopg[binary]
python-dotenv python-dotenv
pydantic-settings pydantic-settings
requests requests

View File

@@ -0,0 +1,37 @@
# DB reset + seed (dev-machine)
This repo uses Alembic migrations as schema source-of-truth.
## Reset to the current seed
```bash
cd backend
./scripts/reset_db.sh
```
Environment variables (optional):
- `DB_NAME` (default `openclaw_agency`)
- `DB_USER` (default `postgres`)
- `DB_HOST` (default `127.0.0.1`)
- `DB_PORT` (default `5432`)
- `DB_PASSWORD` (default `postgres`)
## Updating the seed
The seed is a **data-only** dump (not schema). Regenerate it from the current DB state:
```bash
cd backend
PGPASSWORD=postgres pg_dump \
--data-only \
--column-inserts \
--disable-triggers \
--no-owner \
--no-privileges \
-U postgres -h 127.0.0.1 -d openclaw_agency \
> scripts/seed_data.sql
# IMPORTANT: do not include alembic_version in the seed (migrations already set it)
# (our committed seed already has this removed)
```

9
backend/scripts/lint.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
. .venv/bin/activate
python -m black .
python -m isort .
python -m flake8 .

55
backend/scripts/reset_db.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
DB_NAME=${DB_NAME:-openclaw_agency}
DB_USER=${DB_USER:-postgres}
DB_HOST=${DB_HOST:-127.0.0.1}
DB_PORT=${DB_PORT:-5432}
# Never hardcode passwords in git. Prefer:
# - DB_PASSWORD env var, or
# - infer from backend/.env DATABASE_URL
DB_PASSWORD=${DB_PASSWORD:-}
cd "$(dirname "$0")/.."
if [[ -z "${DB_PASSWORD}" ]] && [[ -f .env ]]; then
DB_PASSWORD=$(python3 - <<'PY'
import os
from pathlib import Path
from urllib.parse import urlparse
def parse_database_url(url: str) -> str:
# supports postgresql+psycopg://user:pass@host:port/db
u = urlparse(url)
return u.password or ""
for line in Path('.env').read_text().splitlines():
if line.startswith('DATABASE_URL='):
print(parse_database_url(line.split('=',1)[1].strip()))
break
PY
)
fi
if [[ -z "${DB_PASSWORD}" ]]; then
echo "ERROR: DB_PASSWORD not set and could not infer it from backend/.env DATABASE_URL" >&2
echo "Set DB_PASSWORD=... or create backend/.env with DATABASE_URL" >&2
exit 2
fi
export PGPASSWORD="$DB_PASSWORD"
# 1) wipe schema
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
# 2) migrate
. .venv/bin/activate
alembic upgrade head
# 3) seed
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-f scripts/seed_data.sql
echo "Reset complete: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"

View File

@@ -0,0 +1,50 @@
-- Mission Control seed data (minimal)
-- Keep this data-only seed small and deterministic.
-- NOTE: Do NOT include alembic_version here; migrations manage it.
SET client_min_messages = warning;
SET row_security = off;
-- Disable triggers to avoid FK ordering issues during seed.
ALTER TABLE public.employees DISABLE TRIGGER ALL;
ALTER TABLE public.departments DISABLE TRIGGER ALL;
ALTER TABLE public.teams DISABLE TRIGGER ALL;
ALTER TABLE public.projects DISABLE TRIGGER ALL;
ALTER TABLE public.tasks DISABLE TRIGGER ALL;
ALTER TABLE public.task_comments DISABLE TRIGGER ALL;
ALTER TABLE public.project_members DISABLE TRIGGER ALL;
ALTER TABLE public.activities DISABLE TRIGGER ALL;
-- Employees (keep only Abhimanyu)
INSERT INTO public.employees (id, name, employee_type, department_id, manager_id, title, status, openclaw_session_key, notify_enabled, team_id)
VALUES
(2, 'Abhimanyu', 'human', NULL, NULL, 'CEO', 'active', NULL, false, NULL)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
employee_type = EXCLUDED.employee_type,
department_id = EXCLUDED.department_id,
manager_id = EXCLUDED.manager_id,
title = EXCLUDED.title,
status = EXCLUDED.status,
openclaw_session_key = EXCLUDED.openclaw_session_key,
notify_enabled = EXCLUDED.notify_enabled,
team_id = EXCLUDED.team_id;
-- Fix sequences (avoid PK reuse after explicit ids)
SELECT setval('employees_id_seq', (SELECT COALESCE(max(id), 1) FROM public.employees));
SELECT setval('departments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.departments));
SELECT setval('teams_id_seq', (SELECT COALESCE(max(id), 1) FROM public.teams));
SELECT setval('projects_id_seq', (SELECT COALESCE(max(id), 1) FROM public.projects));
SELECT setval('tasks_id_seq', (SELECT COALESCE(max(id), 1) FROM public.tasks));
SELECT setval('task_comments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.task_comments));
SELECT setval('project_members_id_seq', (SELECT COALESCE(max(id), 1) FROM public.project_members));
SELECT setval('activities_id_seq', (SELECT COALESCE(max(id), 1) FROM public.activities));
ALTER TABLE public.employees ENABLE TRIGGER ALL;
ALTER TABLE public.departments ENABLE TRIGGER ALL;
ALTER TABLE public.teams ENABLE TRIGGER ALL;
ALTER TABLE public.projects ENABLE TRIGGER ALL;
ALTER TABLE public.tasks ENABLE TRIGGER ALL;
ALTER TABLE public.task_comments ENABLE TRIGGER ALL;
ALTER TABLE public.project_members ENABLE TRIGGER ALL;
ALTER TABLE public.activities ENABLE TRIGGER ALL;

View File

@@ -10,6 +10,7 @@ export interface Employee {
name: string; name: string;
employee_type: string; employee_type: string;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeCreate {
name: string; name: string;
employee_type: string; employee_type: string;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeUpdate {
name?: string | null; name?: string | null;
employee_type?: string | null; employee_type?: string | null;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string | null; status?: string | null;

View File

@@ -23,6 +23,7 @@ export * from "./hTTPValidationError";
export * from "./listActivitiesActivitiesGetParams"; export * from "./listActivitiesActivitiesGetParams";
export * from "./listTaskCommentsTaskCommentsGetParams"; export * from "./listTaskCommentsTaskCommentsGetParams";
export * from "./listTasksTasksGetParams"; export * from "./listTasksTasksGetParams";
export * from "./listTeamsTeamsGetParams";
export * from "./project"; export * from "./project";
export * from "./projectCreate"; export * from "./projectCreate";
export * from "./projectMember"; export * from "./projectMember";
@@ -31,5 +32,9 @@ export * from "./task";
export * from "./taskComment"; export * from "./taskComment";
export * from "./taskCommentCreate"; export * from "./taskCommentCreate";
export * from "./taskCreate"; export * from "./taskCreate";
export * from "./taskReviewDecision";
export * from "./taskUpdate"; export * from "./taskUpdate";
export * from "./team";
export * from "./teamCreate";
export * from "./teamUpdate";
export * from "./validationError"; export * from "./validationError";

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export type ListTeamsTeamsGetParams = {
department_id?: number | null;
};

View File

@@ -9,4 +9,5 @@ export interface Project {
id?: number | null; id?: number | null;
name: string; name: string;
status?: string; status?: string;
team_id?: number | null;
} }

View File

@@ -8,4 +8,5 @@
export interface ProjectCreate { export interface ProjectCreate {
name: string; name: string;
status?: string; status?: string;
team_id?: number | null;
} }

View File

@@ -8,4 +8,5 @@
export interface ProjectUpdate { export interface ProjectUpdate {
name?: string | null; name?: string | null;
status?: string | null; status?: string | null;
team_id?: number | null;
} }

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TaskReviewDecision {
decision: string;
comment_body: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface Team {
id?: number | null;
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamCreate {
name: string;
department_id: number;
lead_employee_id?: number | null;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* OpenClaw Agency API
* OpenAPI spec version: 0.3.0
*/
export interface TeamUpdate {
name?: string | null;
department_id?: number | null;
lead_employee_id?: number | null;
}

View File

@@ -28,6 +28,10 @@ import type {
EmployeeCreate, EmployeeCreate,
EmployeeUpdate, EmployeeUpdate,
HTTPValidationError, HTTPValidationError,
ListTeamsTeamsGetParams,
Team,
TeamCreate,
TeamUpdate,
} from ".././model"; } from ".././model";
import { customFetch } from "../../mutator"; import { customFetch } from "../../mutator";
@@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = <
queryClient, queryClient,
); );
}; };
/**
* @summary List Teams
*/
export type listTeamsTeamsGetResponse200 = {
data: Team[];
status: 200;
};
export type listTeamsTeamsGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type listTeamsTeamsGetResponseSuccess = listTeamsTeamsGetResponse200 & {
headers: Headers;
};
export type listTeamsTeamsGetResponseError = listTeamsTeamsGetResponse422 & {
headers: Headers;
};
export type listTeamsTeamsGetResponse =
| listTeamsTeamsGetResponseSuccess
| listTeamsTeamsGetResponseError;
export const getListTeamsTeamsGetUrl = (params?: ListTeamsTeamsGetParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/teams?${stringifiedParams}`
: `/teams`;
};
export const listTeamsTeamsGet = async (
params?: ListTeamsTeamsGetParams,
options?: RequestInit,
): Promise<listTeamsTeamsGetResponse> => {
return customFetch<listTeamsTeamsGetResponse>(
getListTeamsTeamsGetUrl(params),
{
...options,
method: "GET",
},
);
};
export const getListTeamsTeamsGetQueryKey = (
params?: ListTeamsTeamsGetParams,
) => {
return [`/teams`, ...(params ? [params] : [])] as const;
};
export const getListTeamsTeamsGetQueryOptions = <
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListTeamsTeamsGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listTeamsTeamsGet>>
> = ({ signal }) => listTeamsTeamsGet(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListTeamsTeamsGetQueryResult = NonNullable<
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>;
export type ListTeamsTeamsGetQueryError = HTTPValidationError;
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params: undefined | ListTeamsTeamsGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
Awaited<ReturnType<typeof listTeamsTeamsGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List Teams
*/
export function useListTeamsTeamsGet<
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError = HTTPValidationError,
>(
params?: ListTeamsTeamsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getListTeamsTeamsGetQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Create Team
*/
export type createTeamTeamsPostResponse200 = {
data: Team;
status: 200;
};
export type createTeamTeamsPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type createTeamTeamsPostResponseSuccess =
createTeamTeamsPostResponse200 & {
headers: Headers;
};
export type createTeamTeamsPostResponseError =
createTeamTeamsPostResponse422 & {
headers: Headers;
};
export type createTeamTeamsPostResponse =
| createTeamTeamsPostResponseSuccess
| createTeamTeamsPostResponseError;
export const getCreateTeamTeamsPostUrl = () => {
return `/teams`;
};
export const createTeamTeamsPost = async (
teamCreate: TeamCreate,
options?: RequestInit,
): Promise<createTeamTeamsPostResponse> => {
return customFetch<createTeamTeamsPostResponse>(getCreateTeamTeamsPostUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(teamCreate),
});
};
export const getCreateTeamTeamsPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
> => {
const mutationKey = ["createTeamTeamsPost"];
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 createTeamTeamsPost>>,
{ data: TeamCreate }
> = (props) => {
const { data } = props ?? {};
return createTeamTeamsPost(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateTeamTeamsPostMutationResult = NonNullable<
Awaited<ReturnType<typeof createTeamTeamsPost>>
>;
export type CreateTeamTeamsPostMutationBody = TeamCreate;
export type CreateTeamTeamsPostMutationError = HTTPValidationError;
/**
* @summary Create Team
*/
export const useCreateTeamTeamsPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof createTeamTeamsPost>>,
TError,
{ data: TeamCreate },
TContext
> => {
return useMutation(
getCreateTeamTeamsPostMutationOptions(options),
queryClient,
);
};
/**
* @summary Update Team
*/
export type updateTeamTeamsTeamIdPatchResponse200 = {
data: Team;
status: 200;
};
export type updateTeamTeamsTeamIdPatchResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type updateTeamTeamsTeamIdPatchResponseSuccess =
updateTeamTeamsTeamIdPatchResponse200 & {
headers: Headers;
};
export type updateTeamTeamsTeamIdPatchResponseError =
updateTeamTeamsTeamIdPatchResponse422 & {
headers: Headers;
};
export type updateTeamTeamsTeamIdPatchResponse =
| updateTeamTeamsTeamIdPatchResponseSuccess
| updateTeamTeamsTeamIdPatchResponseError;
export const getUpdateTeamTeamsTeamIdPatchUrl = (teamId: number) => {
return `/teams/${teamId}`;
};
export const updateTeamTeamsTeamIdPatch = async (
teamId: number,
teamUpdate: TeamUpdate,
options?: RequestInit,
): Promise<updateTeamTeamsTeamIdPatchResponse> => {
return customFetch<updateTeamTeamsTeamIdPatchResponse>(
getUpdateTeamTeamsTeamIdPatchUrl(teamId),
{
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(teamUpdate),
},
);
};
export const getUpdateTeamTeamsTeamIdPatchMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
> => {
const mutationKey = ["updateTeamTeamsTeamIdPatch"];
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 updateTeamTeamsTeamIdPatch>>,
{ teamId: number; data: TeamUpdate }
> = (props) => {
const { teamId, data } = props ?? {};
return updateTeamTeamsTeamIdPatch(teamId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateTeamTeamsTeamIdPatchMutationResult = NonNullable<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>
>;
export type UpdateTeamTeamsTeamIdPatchMutationBody = TeamUpdate;
export type UpdateTeamTeamsTeamIdPatchMutationError = HTTPValidationError;
/**
* @summary Update Team
*/
export const useUpdateTeamTeamsTeamIdPatch = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
TError,
{ teamId: number; data: TeamUpdate },
TContext
> => {
return useMutation(
getUpdateTeamTeamsTeamIdPatchMutationOptions(options),
queryClient,
);
};
/** /**
* @summary Update Department * @summary Update Department
*/ */

View File

@@ -28,6 +28,7 @@ import type {
TaskComment, TaskComment,
TaskCommentCreate, TaskCommentCreate,
TaskCreate, TaskCreate,
TaskReviewDecision,
TaskUpdate, TaskUpdate,
} from ".././model"; } from ".././model";
@@ -351,6 +352,246 @@ export const useCreateTaskTasksPost = <
queryClient, queryClient,
); );
}; };
/**
* @summary Dispatch Task
*/
export type dispatchTaskTasksTaskIdDispatchPostResponse200 = {
data: unknown;
status: 200;
};
export type dispatchTaskTasksTaskIdDispatchPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess =
dispatchTaskTasksTaskIdDispatchPostResponse200 & {
headers: Headers;
};
export type dispatchTaskTasksTaskIdDispatchPostResponseError =
dispatchTaskTasksTaskIdDispatchPostResponse422 & {
headers: Headers;
};
export type dispatchTaskTasksTaskIdDispatchPostResponse =
| dispatchTaskTasksTaskIdDispatchPostResponseSuccess
| dispatchTaskTasksTaskIdDispatchPostResponseError;
export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => {
return `/tasks/${taskId}/dispatch`;
};
export const dispatchTaskTasksTaskIdDispatchPost = async (
taskId: number,
options?: RequestInit,
): Promise<dispatchTaskTasksTaskIdDispatchPostResponse> => {
return customFetch<dispatchTaskTasksTaskIdDispatchPostResponse>(
getDispatchTaskTasksTaskIdDispatchPostUrl(taskId),
{
...options,
method: "POST",
},
);
};
export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
> => {
const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"];
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 dispatchTaskTasksTaskIdDispatchPost>>,
{ taskId: number }
> = (props) => {
const { taskId } = props ?? {};
return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>
>;
export type DispatchTaskTasksTaskIdDispatchPostMutationError =
HTTPValidationError;
/**
* @summary Dispatch Task
*/
export const useDispatchTaskTasksTaskIdDispatchPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
TError,
{ taskId: number },
TContext
> => {
return useMutation(
getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options),
queryClient,
);
};
/**
* Reviewer approves or requests changes.
- Approve => status=done
- Changes => status=in_progress
Always writes a TaskComment by the reviewer for audit.
* @summary Review Task
*/
export type reviewTaskTasksTaskIdReviewPostResponse200 = {
data: Task;
status: 200;
};
export type reviewTaskTasksTaskIdReviewPostResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type reviewTaskTasksTaskIdReviewPostResponseSuccess =
reviewTaskTasksTaskIdReviewPostResponse200 & {
headers: Headers;
};
export type reviewTaskTasksTaskIdReviewPostResponseError =
reviewTaskTasksTaskIdReviewPostResponse422 & {
headers: Headers;
};
export type reviewTaskTasksTaskIdReviewPostResponse =
| reviewTaskTasksTaskIdReviewPostResponseSuccess
| reviewTaskTasksTaskIdReviewPostResponseError;
export const getReviewTaskTasksTaskIdReviewPostUrl = (taskId: number) => {
return `/tasks/${taskId}/review`;
};
export const reviewTaskTasksTaskIdReviewPost = async (
taskId: number,
taskReviewDecision: TaskReviewDecision,
options?: RequestInit,
): Promise<reviewTaskTasksTaskIdReviewPostResponse> => {
return customFetch<reviewTaskTasksTaskIdReviewPostResponse>(
getReviewTaskTasksTaskIdReviewPostUrl(taskId),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(taskReviewDecision),
},
);
};
export const getReviewTaskTasksTaskIdReviewPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
> => {
const mutationKey = ["reviewTaskTasksTaskIdReviewPost"];
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 reviewTaskTasksTaskIdReviewPost>>,
{ taskId: number; data: TaskReviewDecision }
> = (props) => {
const { taskId, data } = props ?? {};
return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>
>;
export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision;
export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError;
/**
* @summary Review Task
*/
export const useReviewTaskTasksTaskIdReviewPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
TError,
{ taskId: number; data: TaskReviewDecision },
TContext
> => {
return useMutation(
getReviewTaskTasksTaskIdReviewPostMutationOptions(options),
queryClient,
);
};
/** /**
* @summary Update Task * @summary Update Task
*/ */

View File

@@ -10,6 +10,7 @@ const NAV = [
{ href: "/projects", label: "Projects" }, { href: "/projects", label: "Projects" },
{ href: "/kanban", label: "Kanban" }, { href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" }, { href: "/departments", label: "Departments" },
{ href: "/teams", label: "Teams" },
{ href: "/people", label: "People" }, { href: "/people", label: "People" },
]; ];

View File

@@ -13,28 +13,58 @@ import {
useCreateEmployeeEmployeesPost, useCreateEmployeeEmployeesPost,
useListDepartmentsDepartmentsGet, useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet, useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
} from "@/api/generated/org/org"; } from "@/api/generated/org/org";
export default function PeoplePage() { export default function PeoplePage() {
const [actorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
const [name, setName] = useState(""); const [name, setName] = useState("");
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [departmentId, setDepartmentId] = useState<string>(""); const [departmentId, setDepartmentId] = useState<string>("");
const [teamId, setTeamId] = useState<string>("");
const [managerId, setManagerId] = useState<string>(""); const [managerId, setManagerId] = useState<string>("");
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet(); const departments = useListDepartmentsDepartmentsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]); const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]); const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
const createEmployee = useCreateEmployeeEmployeesPost({ const createEmployee = useCreateEmployeeEmployeesPost({
mutation: { mutation: {
onSuccess: () => { onSuccess: async (res) => {
setName(""); setName("");
setTitle(""); setTitle("");
setDepartmentId(""); setDepartmentId("");
setTeamId("");
setManagerId(""); setManagerId("");
// If an agent was created but not yet provisioned, provision immediately so it can receive tasks.
try {
const e = (res as any)?.data?.data ?? (res as any)?.data ?? null;
if (e?.employee_type === "agent" && !e.openclaw_session_key) {
await provisionEmployee.mutateAsync({ employeeId: e.id! });
}
} catch {
// ignore; UI will show unprovisioned state
}
employees.refetch(); employees.refetch();
teams.refetch();
}, },
}, },
}); });
@@ -47,6 +77,14 @@ export default function PeoplePage() {
return m; return m;
}, [departmentList]); }, [departmentList]);
const teamNameById = useMemo(() => {
const m = new Map<number, string>();
for (const t of teamList) {
if (t.id != null) m.set(t.id, t.name);
}
return m;
}, [teamList]);
const empNameById = useMemo(() => { const empNameById = useMemo(() => {
const m = new Map<number, string>(); const m = new Map<number, string>();
for (const e of employeeList) { for (const e of employeeList) {
@@ -88,6 +126,14 @@ export default function PeoplePage() {
</option> </option>
))} ))}
</Select> </Select>
<Select value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">(no team)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ""}>
{t.name}
</option>
))}
</Select>
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}> <Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
<option value="">(no manager)</option> <option value="">(no manager)</option>
{employeeList.map((e) => ( {employeeList.map((e) => (
@@ -104,6 +150,7 @@ export default function PeoplePage() {
employee_type: employeeType, employee_type: employeeType,
title: title.trim() ? title : null, title: title.trim() ? title : null,
department_id: departmentId ? Number(departmentId) : null, department_id: departmentId ? Number(departmentId) : null,
team_id: teamId ? Number(teamId) : null,
manager_id: managerId ? Number(managerId) : null, manager_id: managerId ? Number(managerId) : null,
status: "active", status: "active",
}, },
@@ -142,6 +189,7 @@ export default function PeoplePage() {
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-2 text-sm text-muted-foreground">
{e.title ? <span>{e.title} · </span> : null} {e.title ? <span>{e.title} · </span> : null}
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null} {e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null}
{e.team_id ? <span>Team: {teamNameById.get(e.team_id) ?? `Team#${e.team_id}`} · </span> : null}
{e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>} {e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
</div> </div>
</li> </li>

View File

@@ -4,7 +4,13 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select"; import { Select } from "@/components/ui/select";
@@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org"; import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import { import {
useCreateTaskTasksPost, useCreateTaskTasksPost,
useDeleteTaskTasksTaskIdDelete,
useDispatchTaskTasksTaskIdDispatchPost,
useListTaskCommentsTaskCommentsGet,
useListTasksTasksGet, useListTasksTasksGet,
useUpdateTaskTasksTaskIdPatch, useUpdateTaskTasksTaskIdPatch,
useDeleteTaskTasksTaskIdDelete,
useCreateTaskCommentTaskCommentsPost, useCreateTaskCommentTaskCommentsPost,
useListTaskCommentsTaskCommentsGet,
} from "@/api/generated/work/work"; } from "@/api/generated/work/work";
import { import {
useListProjectMembersProjectsProjectIdMembersGet,
useAddProjectMemberProjectsProjectIdMembersPost, useAddProjectMemberProjectsProjectIdMembersPost,
useListProjectMembersProjectsProjectIdMembersGet,
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete, useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch, useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
} from "@/api/generated/projects/projects"; } from "@/api/generated/projects/projects";
@@ -52,6 +59,10 @@ export default function ProjectDetailPage() {
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : []; const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const eligibleAssignees = employeeList.filter(
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
);
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
const memberList = members.data?.status === 200 ? members.data.data : []; const memberList = members.data?.status === 200 ? members.data.data : [];
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({ const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
@@ -75,6 +86,11 @@ export default function ProjectDetailPage() {
const deleteTask = useDeleteTaskTasksTaskIdDelete({ const deleteTask = useDeleteTaskTasksTaskIdDelete({
mutation: { onSuccess: () => tasks.refetch() }, mutation: { onSuccess: () => tasks.refetch() },
}); });
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -109,6 +125,11 @@ export default function ProjectDetailPage() {
return map; return map;
})(); })();
const employeeById = new Map<number, (typeof employeeList)[number]>();
for (const e of employeeList) {
if (e.id != null) employeeById.set(Number(e.id), e);
}
const employeeName = (id: number | null | undefined) => const employeeName = (id: number | null | undefined) =>
employeeList.find((e) => e.id === id)?.name ?? "—"; employeeList.find((e) => e.id === id)?.name ?? "—";
@@ -127,16 +148,40 @@ export default function ProjectDetailPage() {
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? ( {projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
<div className="mb-4 text-sm text-muted-foreground">Loading</div> <div className="mb-4 text-sm text-muted-foreground">Loading</div>
) : null} ) : null}
{projects.error ? <div className="mb-4 text-sm text-destructive">{(projects.error as Error).message}</div> : null} {projects.error ? (
{employees.error ? <div className="mb-4 text-sm text-destructive">{(employees.error as Error).message}</div> : null} <div className="mb-4 text-sm text-destructive">
{members.error ? <div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div> : null} {(projects.error as Error).message}
{tasks.error ? <div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div> : null} </div>
) : null}
{employees.error ? (
<div className="mb-4 text-sm text-destructive">
{(employees.error as Error).message}
</div>
) : null}
{members.error ? (
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
) : null}
{tasks.error ? (
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">{project?.name ?? `Project #${projectId}`}</h1> <h1 className="text-2xl font-semibold tracking-tight">
<p className="mt-1 text-sm text-muted-foreground">Project detail: staffing + tasks.</p> {project?.name ?? `Project #${projectId}`}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Project detail: staffing + tasks.
</p>
</div> </div>
<Button variant="outline" onClick={() => { tasks.refetch(); members.refetch(); }} disabled={tasks.isFetching || members.isFetching}> <Button
variant="outline"
onClick={() => {
tasks.refetch();
members.refetch();
}}
disabled={tasks.isFetching || members.isFetching}
>
Refresh Refresh
</Button> </Button>
</div> </div>
@@ -148,14 +193,31 @@ export default function ProjectDetailPage() {
<CardDescription>Project-scoped tasks</CardDescription> <CardDescription>Project-scoped tasks</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{createTask.error ? <div className="text-sm text-destructive">{(createTask.error as Error).message}</div> : null} {createTask.error ? (
<Input placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} /> <div className="text-sm text-destructive">
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} /> {(createTask.error as Error).message}
</div>
) : null}
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}> <Select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
>
<option value="">Assignee</option> <option value="">Assignee</option>
{employeeList.map((e) => ( {eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option> <option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))} ))}
</Select> </Select>
</div> </div>
@@ -184,28 +246,43 @@ export default function ProjectDetailPage() {
<CardDescription>Project members</CardDescription> <CardDescription>Project members</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<Select onChange={(e) => { <Select
onChange={(e) => {
const empId = e.target.value; const empId = e.target.value;
if (!empId) return; if (!empId) return;
addMember.mutate({ projectId, data: { project_id: projectId, employee_id: Number(empId), role: "member" } }); addMember.mutate({
projectId,
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
});
e.currentTarget.value = ""; e.currentTarget.value = "";
}}> }}
>
<option value="">Add member</option> <option value="">Add member</option>
{employeeList.map((e) => ( {eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option> <option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))} ))}
</Select> </Select>
{addMember.error ? ( {addMember.error ? (
<div className="text-xs text-destructive">{(addMember.error as Error).message}</div> <div className="text-xs text-destructive">
{(addMember.error as Error).message}
</div>
) : null} ) : null}
<ul className="space-y-2"> <ul className="space-y-2">
{projectMembers.map((m) => ( {projectMembers.map((m) => (
<li key={m.id ?? `${m.project_id}-${m.employee_id}`} className="rounded-md border p-2 text-sm"> <li
key={m.id ?? `${m.project_id}-${m.employee_id}`}
className="rounded-md border p-2 text-sm"
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div>{employeeName(m.employee_id)}</div> <div>{employeeName(m.employee_id)}</div>
<Button <Button
variant="outline" variant="outline"
onClick={() => { if (m.id == null) return; removeMember.mutate({ projectId, memberId: Number(m.id) }); }} onClick={() => {
if (m.id == null) return;
removeMember.mutate({ projectId, memberId: Number(m.id) });
}}
> >
Remove Remove
</Button> </Button>
@@ -215,17 +292,25 @@ export default function ProjectDetailPage() {
placeholder="Role (e.g., PM, QA, Dev)" placeholder="Role (e.g., PM, QA, Dev)"
defaultValue={m.role ?? ""} defaultValue={m.role ?? ""}
onBlur={(e) => onBlur={(e) =>
m.id == null ? undefined : updateMember.mutate({ m.id == null
? undefined
: updateMember.mutate({
projectId, projectId,
memberId: Number(m.id), memberId: Number(m.id),
data: { project_id: projectId, employee_id: m.employee_id, role: e.currentTarget.value || null }, data: {
project_id: projectId,
employee_id: m.employee_id,
role: e.currentTarget.value || null,
},
}) })
} }
/> />
</div> </div>
</li> </li>
))} ))}
{projectMembers.length === 0 ? <li className="text-sm text-muted-foreground">No members yet.</li> : null} {projectMembers.length === 0 ? (
<li className="text-sm text-muted-foreground">No members yet.</li>
) : null}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
@@ -236,36 +321,125 @@ export default function ProjectDetailPage() {
{STATUSES.map((s) => ( {STATUSES.map((s) => (
<Card key={s}> <Card key={s}>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">{s.replace("_", " ")}</CardTitle> <CardTitle className="text-sm uppercase tracking-wide">
{s.replace("_", " ")}
</CardTitle>
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription> <CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{(tasksByStatus.get(s) ?? []).map((t) => ( {(tasksByStatus.get(s) ?? []).map((t) => {
const assignee =
t.assignee_employee_id != null
? employeeById.get(Number(t.assignee_employee_id))
: undefined;
const canTrigger = Boolean(
t.id != null &&
assignee &&
assignee.employee_type === "agent" &&
assignee.openclaw_session_key,
);
const actorId = getActorEmployeeId();
const isReviewer = Boolean(actorId && t.reviewer_employee_id && Number(t.reviewer_employee_id) === actorId);
const canReviewActions = Boolean(t.id != null && isReviewer && (t.status ?? "") === "review");
return (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm"> <div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div> <div className="font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground">Assignee: {employeeName(t.assignee_employee_id)}</div> <div className="text-xs text-muted-foreground">
Assignee: {employeeName(t.assignee_employee_id)}
</div>
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{STATUSES.filter((x) => x !== s).map((x) => ( {STATUSES.filter((x) => x !== s).map((x) => (
<Button <Button
key={x} key={x}
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => updateTask.mutate({ taskId: Number(t.id), data: { status: x } })} onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: x },
})
}
> >
{x} {x}
</Button> </Button>
))} ))}
</div> </div>
<div className="mt-2 flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setCommentTaskId(Number(t.id)); setReplyToCommentId(null); }}> <div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
>
Comments Comments
</Button> </Button>
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
<Button
variant="outline"
size="sm"
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
disabled={!canTrigger || dispatchTask.isPending}
title={
canTrigger
? "Send a dispatch message to the assigned agent"
: "Only available when the assignee is a provisioned agent"
}
>
Trigger
</Button>
{canReviewActions ? (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: "done" },
})
}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
title="Leave a comment asking for changes, then move status back to in_progress"
>
Request changes
</Button>
</>
) : null}
<Button
variant="destructive"
size="sm"
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
>
Delete Delete
</Button> </Button>
</div> </div>
{dispatchTask.error ? (
<div className="mt-2 text-xs text-destructive">
{(dispatchTask.error as Error).message}
</div> </div>
))} ) : null}
</div>
);
})}
{(tasksByStatus.get(s) ?? []).length === 0 ? ( {(tasksByStatus.get(s) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div> <div className="text-xs text-muted-foreground">No tasks</div>
) : null} ) : null}
@@ -282,12 +456,20 @@ export default function ProjectDetailPage() {
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription> <CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null} {addComment.error ? (
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
) : null}
{replyToCommentId ? ( {replyToCommentId ? (
<div className="rounded-md border bg-muted/40 p-2 text-sm"> <div className="rounded-md border bg-muted/40 p-2 text-sm">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">Replying to comment #{replyToCommentId}</div> <div className="text-xs text-muted-foreground">
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(null)}> Replying to comment #{replyToCommentId}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(null)}
>
Cancel reply Cancel reply
</Button> </Button>
</div> </div>
@@ -323,17 +505,25 @@ export default function ProjectDetailPage() {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<div className="font-medium">{employeeName(c.author_employee_id)}</div> <div className="font-medium">{employeeName(c.author_employee_id)}</div>
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div> <div className="text-xs text-muted-foreground">
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
</div> </div>
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(Number(c.id))}> </div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(Number(c.id))}
>
Reply Reply
</Button> </Button>
</div> </div>
{(c.reply_to_comment_id ? ( {c.reply_to_comment_id ? (
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs"> <div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
<div className="text-muted-foreground">Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}</div> <div className="text-muted-foreground">
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
</div> </div>
) : null)} </div>
) : null}
<div className="mt-2">{c.body}</div> <div className="mt-2">{c.body}</div>
</li> </li>
))} ))}

View File

@@ -13,15 +13,21 @@ import {
useListProjectsProjectsGet, useListProjectsProjectsGet,
} from "@/api/generated/projects/projects"; } from "@/api/generated/projects/projects";
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
export default function ProjectsPage() { export default function ProjectsPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [teamId, setTeamId] = useState<string>("");
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const projectList = projects.data?.status === 200 ? projects.data.data : []; const projectList = projects.data?.status === 200 ? projects.data.data : [];
const teamList = teams.data?.status === 200 ? teams.data.data : [];
const createProject = useCreateProjectProjectsPost({ const createProject = useCreateProjectProjectsPost({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
setName(""); setName("");
setTeamId("");
projects.refetch(); projects.refetch();
}, },
}, },
@@ -48,8 +54,17 @@ export default function ProjectsPage() {
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null} {projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
<div className={styles.list}> <div className={styles.list}>
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus /> <Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Owning team</span>
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid #333', background: 'transparent' }}>
<option value="">(none)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ''}>{t.name}</option>
))}
</select>
</div>
<Button <Button
onClick={() => createProject.mutate({ data: { name, status: "active" } })} onClick={() => createProject.mutate({ data: { name, status: "active", team_id: teamId ? Number(teamId) : null } })}
disabled={!name.trim() || createProject.isPending || projects.isFetching} disabled={!name.trim() || createProject.isPending || projects.isFetching}
> >
Create Create

View File

@@ -0,0 +1,150 @@
"use client";
import { useMemo, 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 {
useCreateTeamTeamsPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
} from "@/api/generated/org/org";
export default function TeamsPage() {
const [name, setName] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [leadEmployeeId, setLeadEmployeeId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const employees = useListEmployeesEmployeesGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(
() => (departments.data?.status === 200 ? departments.data.data : []),
[departments.data],
);
const employeeList = useMemo(
() => (employees.data?.status === 200 ? employees.data.data : []),
[employees.data],
);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const createTeam = useCreateTeamTeamsPost({
mutation: {
onSuccess: () => {
setName("");
setDepartmentId("");
setLeadEmployeeId("");
teams.refetch();
},
},
});
const sorted = teamList
.slice()
.sort((a, b) => `${deptNameById.get(a.department_id) ?? ""}::${a.name}`.localeCompare(`${deptNameById.get(b.department_id) ?? ""}::${b.name}`));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">Teams live under departments. Projects are owned by teams.</p>
</div>
<Button variant="outline" onClick={() => teams.refetch()} disabled={teams.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>Define a team and attach it to a department.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(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={leadEmployeeId} onChange={(e) => setLeadEmployeeId(e.target.value)}>
<option value="">(no lead)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createTeam.mutate({
data: {
name: name.trim(),
department_id: Number(departmentId),
lead_employee_id: leadEmployeeId ? Number(leadEmployeeId) : null,
},
})
}
disabled={!name.trim() || !departmentId || createTeam.isPending}
>
Create
</Button>
{createTeam.error ? <div className="text-sm text-destructive">{(createTeam.error as Error).message}</div> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All teams</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
{teams.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{teams.error ? <div className="text-sm text-destructive">{(teams.error as Error).message}</div> : null}
{!teams.isLoading && !teams.error ? (
<ul className="space-y-2">
{sorted.map((t) => (
<li key={t.id ?? `${t.department_id}:${t.name}`} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{t.name}</div>
<div className="text-sm text-muted-foreground">{deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{t.lead_employee_id ? <span>Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`}</span> : <span>No lead</span>}
</div>
</li>
))}
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No teams yet.</li> : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}