diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1c99266 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index 0260903..a8c59fd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w Uses local Postgres: - user: `postgres` -- password: `netbox` +- password: `REDACTED` - db: `openclaw_agency` ## Environment diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..e2203bb --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503, E501 +exclude = + .venv, + backend/.venv, + alembic, + backend/alembic, + **/__pycache__, + **/*.pyc diff --git a/backend/alembic.ini b/backend/alembic.ini index e8b4e3c..f0da7e5 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -86,7 +86,7 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = +sqlalchemy.url = [post_write_hooks] diff --git a/backend/alembic/README b/backend/alembic/README index 98e4f9c..2500aa1 100644 --- a/backend/alembic/README +++ b/backend/alembic/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/backend/alembic/__pycache__/env.cpython-312.pyc b/backend/alembic/__pycache__/env.cpython-312.pyc deleted file mode 100644 index ddf714a..0000000 Binary files a/backend/alembic/__pycache__/env.cpython-312.pyc and /dev/null differ diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3a5cd09..cdb40c2 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -2,14 +2,14 @@ from __future__ import annotations from logging.config import fileConfig -from alembic import context from sqlalchemy import engine_from_config, pool - -from app.core.config import settings from sqlmodel import SQLModel +from alembic import context + # Import models to register tables in metadata from app import models # noqa: F401 +from app.core.config import settings config = context.config diff --git a/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py index 0898fad..cdeba32 100644 --- a/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py +++ b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py @@ -8,9 +8,9 @@ Create Date: 2026-02-02 from __future__ import annotations -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "3f2c1b9c8e12" diff --git a/backend/alembic/versions/bacd5e6a253d_baseline_schema.py b/backend/alembic/versions/bacd5e6a253d_baseline_schema.py index 662689b..e796434 100644 --- a/backend/alembic/versions/bacd5e6a253d_baseline_schema.py +++ b/backend/alembic/versions/bacd5e6a253d_baseline_schema.py @@ -10,6 +10,7 @@ from typing import Sequence, Union import sqlalchemy as sa import sqlmodel + from alembic import op # revision identifiers, used by Alembic. diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index d5daadf..be01418 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -13,7 +13,9 @@ router = APIRouter(prefix="/activities", tags=["activities"]) @router.get("") def list_activities(limit: int = 50, session: Session = Depends(get_session)): - items = session.exec(select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))).all() + items = session.exec( + select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200))) + ).all() out = [] for a in items: out.append( diff --git a/backend/app/api/org.py b/backend/app/api/org.py index a06309c..4444d5a 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -5,21 +5,42 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from app.api.utils import get_actor_employee_id, log_activity +from app.core.urls import public_api_base_url from app.db.session import get_session from app.integrations.openclaw import OpenClawClient -from app.models.org import Department, Team, Employee +from app.models.org import Department, Employee, Team from app.schemas.org import ( DepartmentCreate, DepartmentUpdate, - TeamCreate, - TeamUpdate, EmployeeCreate, EmployeeUpdate, + TeamCreate, + TeamUpdate, ) router = APIRouter(tags=["org"]) +def _enforce_employee_create_policy( + session: Session, *, actor_employee_id: int, target_employee_type: str +) -> None: + """Enforce: agents can only create/provision agents; humans can create humans + agents.""" + + actor = session.get(Employee, actor_employee_id) + if actor is None: + # Actor header is required; if it points to nothing, treat as invalid. + raise HTTPException(status_code=400, detail="Actor employee not found") + + target = (target_employee_type or "").lower() + actor_type = (actor.employee_type or "").lower() + + if actor_type == "agent" and target != "agent": + raise HTTPException( + status_code=403, + detail="Agent employees may only create/provision agent employees", + ) + + def _default_agent_prompt(emp: Employee) -> str: """Generate a conservative default prompt for a newly-created agent employee. @@ -34,9 +55,15 @@ def _default_agent_prompt(emp: Employee) -> str: f"Your employee_id is {emp.id}.\n" f"Title: {title}. Department id: {dept}.\n\n" "Mission Control API access (no UI):\n" - "- Base URL: http://127.0.0.1:8000 (if running locally) OR http://:8000\n" - "- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: \n" - f" For you: X-Actor-Employee-Id: {emp.id}\n\n" + f"- Base URL: {public_api_base_url()}\n" + "- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: \n" + f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n" + "How to execute writes from an OpenClaw agent (IMPORTANT):\n" + "- Use the exec tool to run curl against the Base URL above.\n" + "- Example: start a task\n" + " curl -sS -X PATCH $BASE/tasks/ -H 'X-Actor-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: ' -H 'Content-Type: application/json' -d '{\"task_id\":,\"body\":\"...\"}'\n\n" "Common endpoints (JSON):\n" "- GET /tasks, POST /tasks\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" "Rules:\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" ) @@ -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). """ + # Enforce: agent actors may only provision agents (humans can provision agents). + _enforce_employee_create_policy( + session, actor_employee_id=actor_employee_id, target_employee_type=emp.employee_type + ) + if emp.employee_type != "agent": return if emp.status != "active": @@ -159,7 +195,11 @@ def create_team( entity_type="team", entity_id=team.id, verb="created", - payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id}, + payload={ + "name": team.name, + "department_id": team.department_id, + "lead_employee_id": team.lead_employee_id, + }, ) session.commit() except IntegrityError: @@ -188,7 +228,14 @@ def update_team( session.add(team) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="team", + entity_id=team.id, + verb="updated", + payload=data, + ) session.commit() except IntegrityError: session.rollback() @@ -226,7 +273,9 @@ def create_department( session.commit() except IntegrityError: 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) return dept @@ -250,7 +299,14 @@ def update_department( session.add(dept) session.commit() session.refresh(dept) - log_activity(session, actor_employee_id=actor_employee_id, entity_type="department", entity_id=dept.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="department", + entity_id=dept.id, + verb="updated", + payload=data, + ) session.commit() return dept @@ -266,6 +322,10 @@ def create_employee( session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id), ): + _enforce_employee_create_policy( + session, actor_employee_id=actor_employee_id, target_employee_type=payload.employee_type + ) + emp = Employee(**payload.model_dump()) session.add(emp) @@ -310,7 +370,14 @@ def update_employee( session.add(emp) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="employee", + entity_id=emp.id, + verb="updated", + payload=data, + ) session.commit() except IntegrityError: session.rollback() @@ -357,7 +424,10 @@ def deprovision_employee_agent( try: client.tools_invoke( "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, ) except Exception: diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 2b788ee..2f90232 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException 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.models.projects import Project, ProjectMember from app.schemas.projects import ProjectCreate, ProjectUpdate @@ -45,15 +45,21 @@ def create_project( session.commit() except IntegrityError: 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) return proj - @router.patch("/{project_id}", response_model=Project) -def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_project( + project_id: int, + payload: ProjectUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): proj = session.get(Project, project_id) if not proj: raise HTTPException(status_code=404, detail="Project not found") @@ -65,7 +71,14 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D session.add(proj) session.commit() session.refresh(proj) - log_activity(session, actor_employee_id=actor_employee_id, entity_type="project", entity_id=proj.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="project", + entity_id=proj.id, + verb="updated", + payload=data, + ) session.commit() 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]) def list_project_members(project_id: int, session: Session = Depends(get_session)): return session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc()) + select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .order_by(ProjectMember.id.asc()) ).all() @router.post("/{project_id}/members", response_model=ProjectMember) -def add_project_member(project_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): - existing = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id)).first() +def add_project_member( + project_id: int, + payload: ProjectMember, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + existing = session.exec( + select(ProjectMember).where( + ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id + ) + ).first() if existing: raise HTTPException(status_code=409, detail="Member already added") - member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role) + member = ProjectMember( + project_id=project_id, employee_id=payload.employee_id, role=payload.role + ) session.add(member) session.commit() 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}") -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) if not member or member.project_id != project_id: 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) -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) if not member or member.project_id != project_id: raise HTTPException(status_code=404, detail="Project member not found") diff --git a/backend/app/api/work.py b/backend/app/api/work.py index d1f87aa..8f96936 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -1,23 +1,49 @@ from __future__ import annotations +import logging from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlmodel import Session, select +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException 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.integrations.notify import NotifyContext, notify_openclaw +from app.integrations.openclaw import OpenClawClient from app.models.org import Employee from app.models.work import Task, TaskComment -from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate -from app.integrations.notify import NotifyContext, notify_openclaw +from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate + +logger = logging.getLogger("app.work") router = APIRouter(tags=["work"]) ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"} +def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None: + """Enforce that only provisioned agents can be assigned tasks. + + Humans can be assigned regardless. + Agents must be active, notify_enabled, and have openclaw_session_key. + """ + + emp = session.get(Employee, assignee_employee_id) + if emp is None: + raise HTTPException(status_code=400, detail="Assignee employee not found") + + if emp.employee_type == "agent": + if emp.status != "active": + raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent") + if not emp.notify_enabled: + raise HTTPException( + status_code=400, detail="Cannot assign task to agent with notifications disabled" + ) + if not emp.openclaw_session_key: + raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent") + + @router.get("/tasks", response_model=list[Task]) def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)): stmt = select(Task).order_by(Task.id.asc()) @@ -27,15 +53,27 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses @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: - 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). if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None: assignee = session.get(Employee, payload.assignee_employee_id) if assignee is not None and assignee.manager_id is not None: - payload = TaskCreate(**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}) + payload = TaskCreate( + **{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id} + ) task = Task(**payload.model_dump()) 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") 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) 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)): +@router.post("/tasks/{task_id}/dispatch") +def dispatch_task( + task_id: int, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id}) task = session.get(Task, task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") - 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) + if "assignee_employee_id" in data and data["assignee_employee_id"] is not None: + _validate_task_assignee(session, data["assignee_employee_id"]) if "status" in data and data["status"] not in ALLOWED_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") + # Enforce review workflow: agent assignees cannot mark tasks done directly. + if data.get("status") == "done": + assignee = ( + session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None + ) + if assignee is not None and assignee.employee_type == "agent": + if actor_employee_id == task.assignee_employee_id: + raise HTTPException( + status_code=403, + detail="Assignee agents cannot mark tasks done; set status=review for manager approval", + ) + if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id: + raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done") + + # If a task is sent to review and no reviewer is set, default reviewer to assignee's manager. + if ( + data.get("status") in {"review", "ready_for_review"} + and data.get("reviewer_employee_id") is None + ): + assignee_id = data.get("assignee_employee_id", task.assignee_employee_id) + if assignee_id is not None: + assignee = session.get(Employee, assignee_id) + if assignee is not None and assignee.manager_id is not None: + data["reviewer_employee_id"] = assignee.manager_id + for k, v in data.items(): setattr(task, k, v) task.updated_at = datetime.utcnow() @@ -83,7 +295,14 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, try: 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() except IntegrityError: session.rollback() @@ -94,19 +313,51 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, # notify based on meaningful changes changed = {} if before.get("assignee_employee_id") != task.assignee_employee_id: - changed["assignee_employee_id"] = {"from": before.get("assignee_employee_id"), "to": task.assignee_employee_id} - background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, changed_fields=changed)) + changed["assignee_employee_id"] = { + "from": before.get("assignee_employee_id"), + "to": task.assignee_employee_id, + } + background.add_task( + notify_openclaw, + NotifyContext( + event="task.assigned", + actor_employee_id=actor_employee_id, + task_id=task.id, + changed_fields=changed, + ), + ) if before.get("status") != task.status: changed["status"] = {"from": before.get("status"), "to": task.status} - background.add_task(notify_openclaw, 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: - 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) @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) if not task: 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) try: 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() except IntegrityError: 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]) 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) -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: - 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()) session.add(c) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=c.task_id, verb="commented") + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="task", + entity_id=c.task_id, + verb="commented", + ) session.commit() except IntegrityError: session.rollback() @@ -147,5 +419,13 @@ def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks, session.refresh(c) task = session.get(Task, c.task_id) 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) diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc index 0392db7..2695f54 100644 Binary files a/backend/app/core/__pycache__/config.cpython-312.pyc and b/backend/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..4513bb0 --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/core/urls.py b/backend/app/core/urls.py new file mode 100644 index 0000000..f0cd852 --- /dev/null +++ b/backend/app/core/urls.py @@ -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://:8000" diff --git a/backend/app/db/__pycache__/base.cpython-312.pyc b/backend/app/db/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 57ba8e7..0000000 Binary files a/backend/app/db/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/backend/app/db/__pycache__/session.cpython-312.pyc b/backend/app/db/__pycache__/session.cpython-312.pyc deleted file mode 100644 index 7d7d283..0000000 Binary files a/backend/app/db/__pycache__/session.cpython-312.pyc and /dev/null differ diff --git a/backend/app/integrations/notify.py b/backend/app/integrations/notify.py index 712d1f6..d979901 100644 --- a/backend/app/integrations/notify.py +++ b/backend/app/integrations/notify.py @@ -1,43 +1,51 @@ from __future__ import annotations +import logging from dataclasses import dataclass from typing import Iterable from sqlmodel import Session, select +from app.db.session import engine from app.integrations.openclaw import OpenClawClient from app.models.org import Employee from app.models.projects import ProjectMember from app.models.work import Task, TaskComment +logger = logging.getLogger("app.notify") + @dataclass(frozen=True) 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 actor_employee_id: int - task: Task - comment: TaskComment | None = None + task_id: int + comment_id: int | 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}) if not ids: return [] emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all() - keys: list[str] = [] + out: list[Employee] = [] for e in emps: if not getattr(e, "notify_enabled", True): continue - sk = getattr(e, "openclaw_session_key", None) - if sk: - keys.append(sk) - return sorted(set(keys)) + if getattr(e, "openclaw_session_key", None): + out.append(e) + return out 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() pm_ids: set[int] = set() for m in pms: @@ -47,89 +55,221 @@ def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]: return pm_ids -def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]: - t = ctx.task +def resolve_recipients( + session: Session, ctx: NotifyContext, task: Task, comment: TaskComment | None +) -> set[int]: recipients: set[int] = set() if ctx.event == "task.created": - # notify assignee + PMs - if t.assignee_employee_id: - recipients.add(t.assignee_employee_id) - recipients |= _project_pm_employee_ids(session, t.project_id) + if task.assignee_employee_id: + recipients.add(task.assignee_employee_id) + recipients |= _project_pm_employee_ids(session, task.project_id) elif ctx.event == "task.assigned": - if t.assignee_employee_id: - recipients.add(t.assignee_employee_id) - recipients |= _project_pm_employee_ids(session, t.project_id) + if task.assignee_employee_id: + recipients.add(task.assignee_employee_id) + recipients |= _project_pm_employee_ids(session, task.project_id) elif ctx.event == "comment.created": - # notify assignee + reviewer + PMs, excluding author - if t.assignee_employee_id: - recipients.add(t.assignee_employee_id) - if t.reviewer_employee_id: - recipients.add(t.reviewer_employee_id) - recipients |= _project_pm_employee_ids(session, t.project_id) - if ctx.comment and ctx.comment.author_employee_id: - recipients.discard(ctx.comment.author_employee_id) + if task.assignee_employee_id: + recipients.add(task.assignee_employee_id) + if task.reviewer_employee_id: + recipients.add(task.reviewer_employee_id) + recipients |= _project_pm_employee_ids(session, task.project_id) + if comment and comment.author_employee_id: + recipients.discard(comment.author_employee_id) elif ctx.event == "status.changed": - new_status = (getattr(t, "status", None) or "").lower() - if new_status in {"review", "ready_for_review"} and t.reviewer_employee_id: - recipients.add(t.reviewer_employee_id) - recipients |= _project_pm_employee_ids(session, t.project_id) + new_status = (getattr(task, "status", None) or "").lower() + if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id: + recipients.add(task.reviewer_employee_id) + recipients |= _project_pm_employee_ids(session, task.project_id) elif ctx.event == "task.updated": - # conservative: PMs only - recipients |= _project_pm_employee_ids(session, t.project_id) + recipients |= _project_pm_employee_ids(session, task.project_id) recipients.discard(ctx.actor_employee_id) return recipients -def build_message(ctx: NotifyContext) -> str: - t = ctx.task - base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}" +def ensure_employee_provisioned(session: Session, employee_id: int) -> None: + """Best-effort provisioning of a reviewer/manager so notifications can be delivered.""" - if ctx.event == "task.assigned": - return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress." + emp = session.get(Employee, employee_id) + 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": snippet = "" - if ctx.comment and ctx.comment.body: - snippet = ctx.comment.body.strip().replace("\n", " ") + if comment and comment.body: + snippet = comment.body.strip().replace("\n", " ") if len(snippet) > 180: snippet = snippet[:177] + "..." 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": - 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": - 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() + logger.info( + "notify_openclaw: start", + extra={"event": ctx.event, "task_id": ctx.task_id, "actor": ctx.actor_employee_id}, + ) if client is None: + logger.warning("notify_openclaw: skipped (missing OpenClaw env)") return - recipient_ids = resolve_recipients(session, ctx) - session_keys = _employee_session_keys(session, recipient_ids) - if not session_keys: - return + with Session(engine) as session: + task = session.get(Task, ctx.task_id) + if task is None: + logger.warning("notify_openclaw: task not found", extra={"task_id": ctx.task_id}) + return - message = build_message(ctx) + comment = session.get(TaskComment, ctx.comment_id) if ctx.comment_id else None - for sk in session_keys: - try: - client.tools_invoke( - "sessions_send", - {"sessionKey": sk, "message": message}, - timeout_s=3.0, + 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, ) - except Exception: - # best-effort; never break Mission Control writes - continue + + try: + client.tools_invoke( + "sessions_send", + {"sessionKey": sk, "message": message}, + timeout_s=30.0, + ) + except Exception: + # 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 diff --git a/backend/app/integrations/openclaw.py b/backend/app/integrations/openclaw.py index 26fba7d..a39e6c0 100644 --- a/backend/app/integrations/openclaw.py +++ b/backend/app/integrations/openclaw.py @@ -1,9 +1,14 @@ from __future__ import annotations +import logging import os +import time from typing import Any import requests +from requests.exceptions import ReadTimeout, RequestException + +logger = logging.getLogger("app.openclaw") class OpenClawClient: @@ -13,22 +18,71 @@ class OpenClawClient: @classmethod 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") token = os.environ.get("OPENCLAW_GATEWAY_TOKEN") if not url or not token: return None 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} + logger.info( + "openclaw.tools_invoke", + extra={"tool": tool, "has_session_key": bool(session_key), "timeout_s": timeout_s}, + ) if session_key is not None: payload["sessionKey"] = session_key - r = requests.post( - f"{self.base_url}/tools/invoke", - headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, - json=payload, - timeout=timeout_s, - ) - r.raise_for_status() - return r.json() + 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( + f"{self.base_url}/tools/invoke", + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + json=payload, + # connect timeout, read timeout + timeout=(2.0, timeout_s), + ) + r.raise_for_status() + logger.info( + "openclaw.tools_invoke: ok", + extra={"tool": tool, "status": r.status_code, "attempt": attempt + 1}, + ) + 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 diff --git a/backend/app/main.py b/backend/app/main.py index f383bdf..4442bd6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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.work import router as work_router from app.core.config import settings +from app.core.logging import configure_logging from app.db.session import init_db +configure_logging() + app = FastAPI(title="OpenClaw Agency API", version="0.3.0") origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9bd9a65..85f0fd7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ 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.work import Task, TaskComment diff --git a/backend/app/models/org.py b/backend/app/models/org.py index 81a07c4..826841d 100644 --- a/backend/app/models/org.py +++ b/backend/app/models/org.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - from sqlmodel import Field, SQLModel diff --git a/backend/app/schemas/work.py b/backend/app/schemas/work.py index 9d067d7..92b460c 100644 --- a/backend/app/schemas/work.py +++ b/backend/app/schemas/work.py @@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel): author_employee_id: int | None = None reply_to_comment_id: int | None = None body: str + + +class TaskReviewDecision(SQLModel): + decision: str # approve | changes + comment_body: str diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..02f5b30 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4d45217 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +black==24.10.0 +isort==5.13.2 +flake8==7.1.1 +pre-commit==4.1.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 5bb061f..a8ce58a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] sqlmodel alembic -psycopg2-binary +psycopg[binary] python-dotenv pydantic-settings requests diff --git a/backend/scripts/README_seed.md b/backend/scripts/README_seed.md new file mode 100644 index 0000000..a0d36f7 --- /dev/null +++ b/backend/scripts/README_seed.md @@ -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) +``` diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh new file mode 100755 index 0000000..36a0808 --- /dev/null +++ b/backend/scripts/lint.sh @@ -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 . diff --git a/backend/scripts/reset_db.sh b/backend/scripts/reset_db.sh new file mode 100755 index 0000000..5a8e0e7 --- /dev/null +++ b/backend/scripts/reset_db.sh @@ -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" diff --git a/backend/scripts/seed_data.sql b/backend/scripts/seed_data.sql new file mode 100644 index 0000000..9398dd4 --- /dev/null +++ b/backend/scripts/seed_data.sql @@ -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; diff --git a/frontend/public/file.svg b/frontend/public/file.svg index 004145c..16fe3d3 100644 --- a/frontend/public/file.svg +++ b/frontend/public/file.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg index 567f17b..c7215fe 100644 --- a/frontend/public/globe.svg +++ b/frontend/public/globe.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/next.svg b/frontend/public/next.svg index 5174b28..5bb00d4 100644 --- a/frontend/public/next.svg +++ b/frontend/public/next.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg index 7705396..5215157 100644 --- a/frontend/public/vercel.svg +++ b/frontend/public/vercel.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/window.svg b/frontend/public/window.svg index b2b2a44..d05e7a1 100644 --- a/frontend/public/window.svg +++ b/frontend/public/window.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/src/api/generated/model/employee.ts b/frontend/src/api/generated/model/employee.ts index 893f4eb..5e273ee 100644 --- a/frontend/src/api/generated/model/employee.ts +++ b/frontend/src/api/generated/model/employee.ts @@ -10,6 +10,7 @@ export interface Employee { name: string; employee_type: string; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string; diff --git a/frontend/src/api/generated/model/employeeCreate.ts b/frontend/src/api/generated/model/employeeCreate.ts index 74dcd45..4ca4c9a 100644 --- a/frontend/src/api/generated/model/employeeCreate.ts +++ b/frontend/src/api/generated/model/employeeCreate.ts @@ -9,6 +9,7 @@ export interface EmployeeCreate { name: string; employee_type: string; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string; diff --git a/frontend/src/api/generated/model/employeeUpdate.ts b/frontend/src/api/generated/model/employeeUpdate.ts index 4cb343d..28f0384 100644 --- a/frontend/src/api/generated/model/employeeUpdate.ts +++ b/frontend/src/api/generated/model/employeeUpdate.ts @@ -9,6 +9,7 @@ export interface EmployeeUpdate { name?: string | null; employee_type?: string | null; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string | null; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index f1e08e2..3dbfd2a 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -23,6 +23,7 @@ export * from "./hTTPValidationError"; export * from "./listActivitiesActivitiesGetParams"; export * from "./listTaskCommentsTaskCommentsGetParams"; export * from "./listTasksTasksGetParams"; +export * from "./listTeamsTeamsGetParams"; export * from "./project"; export * from "./projectCreate"; export * from "./projectMember"; @@ -31,5 +32,9 @@ export * from "./task"; export * from "./taskComment"; export * from "./taskCommentCreate"; export * from "./taskCreate"; +export * from "./taskReviewDecision"; export * from "./taskUpdate"; +export * from "./team"; +export * from "./teamCreate"; +export * from "./teamUpdate"; export * from "./validationError"; diff --git a/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts b/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts new file mode 100644 index 0000000..54cb6eb --- /dev/null +++ b/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/project.ts b/frontend/src/api/generated/model/project.ts index 5e3c8b5..d886a9a 100644 --- a/frontend/src/api/generated/model/project.ts +++ b/frontend/src/api/generated/model/project.ts @@ -9,4 +9,5 @@ export interface Project { id?: number | null; name: string; status?: string; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/projectCreate.ts b/frontend/src/api/generated/model/projectCreate.ts index 01a2db5..9432eb9 100644 --- a/frontend/src/api/generated/model/projectCreate.ts +++ b/frontend/src/api/generated/model/projectCreate.ts @@ -8,4 +8,5 @@ export interface ProjectCreate { name: string; status?: string; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/projectUpdate.ts b/frontend/src/api/generated/model/projectUpdate.ts index 84ea2e9..8e6f796 100644 --- a/frontend/src/api/generated/model/projectUpdate.ts +++ b/frontend/src/api/generated/model/projectUpdate.ts @@ -8,4 +8,5 @@ export interface ProjectUpdate { name?: string | null; status?: string | null; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/taskReviewDecision.ts b/frontend/src/api/generated/model/taskReviewDecision.ts new file mode 100644 index 0000000..ed64c20 --- /dev/null +++ b/frontend/src/api/generated/model/taskReviewDecision.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/team.ts b/frontend/src/api/generated/model/team.ts new file mode 100644 index 0000000..a407a72 --- /dev/null +++ b/frontend/src/api/generated/model/team.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/teamCreate.ts b/frontend/src/api/generated/model/teamCreate.ts new file mode 100644 index 0000000..bdf963a --- /dev/null +++ b/frontend/src/api/generated/model/teamCreate.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/teamUpdate.ts b/frontend/src/api/generated/model/teamUpdate.ts new file mode 100644 index 0000000..1bfa271 --- /dev/null +++ b/frontend/src/api/generated/model/teamUpdate.ts @@ -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; +} diff --git a/frontend/src/api/generated/org/org.ts b/frontend/src/api/generated/org/org.ts index 4c7c3f4..23f7828 100644 --- a/frontend/src/api/generated/org/org.ts +++ b/frontend/src/api/generated/org/org.ts @@ -28,6 +28,10 @@ import type { EmployeeCreate, EmployeeUpdate, HTTPValidationError, + ListTeamsTeamsGetParams, + Team, + TeamCreate, + TeamUpdate, } from ".././model"; import { customFetch } from "../../mutator"; @@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = < 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 => { + return customFetch( + getListTeamsTeamsGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListTeamsTeamsGetQueryKey = ( + params?: ListTeamsTeamsGetParams, +) => { + return [`/teams`, ...(params ? [params] : [])] as const; +}; + +export const getListTeamsTeamsGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListTeamsTeamsGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listTeamsTeamsGet(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListTeamsTeamsGetQueryResult = NonNullable< + Awaited> +>; +export type ListTeamsTeamsGetQueryError = HTTPValidationError; + +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListTeamsTeamsGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Teams + */ + +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListTeamsTeamsGetQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + 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 => { + return customFetch(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>, + TError, + { data: TeamCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { data: TeamCreate } + > = (props) => { + const { data } = props ?? {}; + + return createTeamTeamsPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateTeamTeamsPostMutationResult = NonNullable< + Awaited> +>; +export type CreateTeamTeamsPostMutationBody = TeamCreate; +export type CreateTeamTeamsPostMutationError = HTTPValidationError; + +/** + * @summary Create Team + */ +export const useCreateTeamTeamsPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: TeamCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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 => { + return customFetch( + 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>, + TError, + { teamId: number; data: TeamUpdate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { teamId: number; data: TeamUpdate } + > = (props) => { + const { teamId, data } = props ?? {}; + + return updateTeamTeamsTeamIdPatch(teamId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateTeamTeamsTeamIdPatchMutationResult = NonNullable< + Awaited> +>; +export type UpdateTeamTeamsTeamIdPatchMutationBody = TeamUpdate; +export type UpdateTeamTeamsTeamIdPatchMutationError = HTTPValidationError; + +/** + * @summary Update Team + */ +export const useUpdateTeamTeamsTeamIdPatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext +> => { + return useMutation( + getUpdateTeamTeamsTeamIdPatchMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Department */ diff --git a/frontend/src/api/generated/work/work.ts b/frontend/src/api/generated/work/work.ts index 6ad2a47..64fb02c 100644 --- a/frontend/src/api/generated/work/work.ts +++ b/frontend/src/api/generated/work/work.ts @@ -28,6 +28,7 @@ import type { TaskComment, TaskCommentCreate, TaskCreate, + TaskReviewDecision, TaskUpdate, } from ".././model"; @@ -351,6 +352,246 @@ export const useCreateTaskTasksPost = < 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 => { + return customFetch( + getDispatchTaskTasksTaskIdDispatchPostUrl(taskId), + { + ...options, + method: "POST", + }, + ); +}; + +export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { taskId: number } + > = (props) => { + const { taskId } = props ?? {}; + + return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable< + Awaited> +>; + +export type DispatchTaskTasksTaskIdDispatchPostMutationError = + HTTPValidationError; + +/** + * @summary Dispatch Task + */ +export const useDispatchTaskTasksTaskIdDispatchPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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 => { + return customFetch( + 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>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { taskId: number; data: TaskReviewDecision } + > = (props) => { + const { taskId, data } = props ?? {}; + + return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable< + Awaited> +>; +export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision; +export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError; + +/** + * @summary Review Task + */ +export const useReviewTaskTasksTaskIdReviewPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { taskId: number; data: TaskReviewDecision }, + TContext +> => { + return useMutation( + getReviewTaskTasksTaskIdReviewPostMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Task */ diff --git a/frontend/src/app/_components/Shell.tsx b/frontend/src/app/_components/Shell.tsx index 6caaa05..55b6a91 100644 --- a/frontend/src/app/_components/Shell.tsx +++ b/frontend/src/app/_components/Shell.tsx @@ -10,6 +10,7 @@ const NAV = [ { href: "/projects", label: "Projects" }, { href: "/kanban", label: "Kanban" }, { href: "/departments", label: "Departments" }, + { href: "/teams", label: "Teams" }, { href: "/people", label: "People" }, ]; diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx index 96f2337..b5cbc77 100644 --- a/frontend/src/app/people/page.tsx +++ b/frontend/src/app/people/page.tsx @@ -13,28 +13,58 @@ import { useCreateEmployeeEmployeesPost, useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet, + useListTeamsTeamsGet, + useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost, + useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost, } from "@/api/generated/org/org"; 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 [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [title, setTitle] = useState(""); const [departmentId, setDepartmentId] = useState(""); + const [teamId, setTeamId] = useState(""); const [managerId, setManagerId] = useState(""); const employees = useListEmployeesEmployeesGet(); const departments = useListDepartmentsDepartmentsGet(); + 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 provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost(); + const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost(); const createEmployee = useCreateEmployeeEmployeesPost({ mutation: { - onSuccess: () => { + onSuccess: async (res) => { setName(""); setTitle(""); setDepartmentId(""); + setTeamId(""); 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(); + teams.refetch(); }, }, }); @@ -47,6 +77,14 @@ export default function PeoplePage() { return m; }, [departmentList]); + const teamNameById = useMemo(() => { + const m = new Map(); + for (const t of teamList) { + if (t.id != null) m.set(t.id, t.name); + } + return m; + }, [teamList]); + const empNameById = useMemo(() => { const m = new Map(); for (const e of employeeList) { @@ -88,6 +126,14 @@ export default function PeoplePage() { ))} + setTitle(e.target.value)} /> -