Mission Control: notify OpenClaw via tools/invoke on task events
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
"""employee_openclaw_routing_fields
|
||||||
|
|
||||||
|
Revision ID: b8810dd52471
|
||||||
|
Revises: 2b8d1e2c0d01
|
||||||
|
Create Date: 2026-02-02 16:03:56.528787
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b8810dd52471"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2b8d1e2c0d01"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.add_column("employees", sa.Column("openclaw_session_key", sa.String(), nullable=True))
|
||||||
|
op.add_column("employees", sa.Column("notify_enabled", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_column("employees", "notify_enabled")
|
||||||
|
op.drop_column("employees", "openclaw_session_key")
|
||||||
@@ -79,11 +79,17 @@ def list_employees(session: Session = Depends(get_session)):
|
|||||||
def create_employee(payload: EmployeeCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
def create_employee(payload: EmployeeCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||||
emp = Employee(**payload.model_dump())
|
emp = Employee(**payload.model_dump())
|
||||||
session.add(emp)
|
session.add(emp)
|
||||||
session.commit()
|
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="created", payload={"name": emp.name, "type": emp.employee_type})
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Employee create violates constraints")
|
||||||
|
|
||||||
session.refresh(emp)
|
session.refresh(emp)
|
||||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="created", payload={"name": emp.name, "type": emp.employee_type})
|
return Employee.model_validate(emp)
|
||||||
session.commit()
|
|
||||||
return emp
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/employees/{employee_id}", response_model=Employee)
|
@router.patch("/employees/{employee_id}", response_model=Employee)
|
||||||
@@ -97,8 +103,13 @@ def update_employee(employee_id: int, payload: EmployeeUpdate, session: Session
|
|||||||
setattr(emp, k, v)
|
setattr(emp, k, v)
|
||||||
|
|
||||||
session.add(emp)
|
session.add(emp)
|
||||||
session.commit()
|
try:
|
||||||
|
session.flush()
|
||||||
|
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Employee update violates constraints")
|
||||||
|
|
||||||
session.refresh(emp)
|
session.refresh(emp)
|
||||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
|
return Employee.model_validate(emp)
|
||||||
session.commit()
|
|
||||||
return emp
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ from app.api.utils import log_activity, get_actor_employee_id
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
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, TaskUpdate
|
||||||
|
from app.integrations.notify import NotifyContext, notify_openclaw
|
||||||
|
|
||||||
router = APIRouter(tags=["work"])
|
router = APIRouter(tags=["work"])
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ 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, 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})
|
||||||
|
|
||||||
@@ -51,16 +52,19 @@ def create_task(payload: TaskCreate, session: Session = Depends(get_session), ac
|
|||||||
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))
|
||||||
# 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.patch("/tasks/{task_id}", response_model=Task)
|
||||||
def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_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}
|
||||||
|
|
||||||
data = payload.model_dump(exclude_unset=True)
|
data = payload.model_dump(exclude_unset=True)
|
||||||
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")
|
||||||
@@ -79,6 +83,18 @@ def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(ge
|
|||||||
raise HTTPException(status_code=409, detail="Task update violates constraints")
|
raise HTTPException(status_code=409, detail="Task update violates constraints")
|
||||||
|
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
|
||||||
|
# notify based on meaningful changes
|
||||||
|
changed = {}
|
||||||
|
if before.get("assignee_employee_id") != task.assignee_employee_id:
|
||||||
|
changed["assignee_employee_id"] = {"from": before.get("assignee_employee_id"), "to": task.assignee_employee_id}
|
||||||
|
background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, 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))
|
||||||
|
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))
|
||||||
|
|
||||||
return Task.model_validate(task)
|
return Task.model_validate(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +122,7 @@ def list_task_comments(task_id: int, session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/task-comments", response_model=TaskComment)
|
@router.post("/task-comments", response_model=TaskComment)
|
||||||
def create_task_comment(payload: TaskCommentCreate, 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})
|
||||||
|
|
||||||
@@ -122,4 +138,7 @@ def create_task_comment(payload: TaskCommentCreate, session: Session = Depends(g
|
|||||||
raise HTTPException(status_code=409, detail="Comment create violates constraints")
|
raise HTTPException(status_code=409, detail="Comment create violates constraints")
|
||||||
|
|
||||||
session.refresh(c)
|
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))
|
||||||
return TaskComment.model_validate(c)
|
return TaskComment.model_validate(c)
|
||||||
|
|||||||
135
backend/app/integrations/notify.py
Normal file
135
backend/app/integrations/notify.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NotifyContext:
|
||||||
|
event: str # task.created | task.updated | task.assigned | comment.created | status.changed
|
||||||
|
actor_employee_id: int
|
||||||
|
task: Task
|
||||||
|
comment: TaskComment | None = None
|
||||||
|
changed_fields: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]:
|
||||||
|
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] = []
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
role = (m.role or "").lower()
|
||||||
|
if role in {"pm", "product", "product_manager", "manager"}:
|
||||||
|
pm_ids.add(m.employee_id)
|
||||||
|
return pm_ids
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]:
|
||||||
|
t = ctx.task
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
elif ctx.event == "task.updated":
|
||||||
|
# conservative: PMs only
|
||||||
|
recipients |= _project_pm_employee_ids(session, t.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}"
|
||||||
|
|
||||||
|
if ctx.event == "task.assigned":
|
||||||
|
return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress."
|
||||||
|
|
||||||
|
if ctx.event == "comment.created":
|
||||||
|
snippet = ""
|
||||||
|
if ctx.comment and ctx.comment.body:
|
||||||
|
snippet = ctx.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."
|
||||||
|
|
||||||
|
if ctx.event == "status.changed":
|
||||||
|
return f"Status changed on {base} → {t.status}.\nWork ONE task only; update Mission Control with next step."
|
||||||
|
|
||||||
|
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"Update on {base}.\nWork ONE task only; update Mission Control."
|
||||||
|
|
||||||
|
|
||||||
|
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
||||||
|
client = OpenClawClient.from_env()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
recipient_ids = resolve_recipients(session, ctx)
|
||||||
|
session_keys = _employee_session_keys(session, recipient_ids)
|
||||||
|
if not session_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = build_message(ctx)
|
||||||
|
|
||||||
|
for sk in session_keys:
|
||||||
|
try:
|
||||||
|
client.tools_invoke(
|
||||||
|
"sessions_send",
|
||||||
|
{"sessionKey": sk, "message": message},
|
||||||
|
timeout_s=3.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# best-effort; never break Mission Control writes
|
||||||
|
continue
|
||||||
34
backend/app/integrations/openclaw.py
Normal file
34
backend/app/integrations/openclaw.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawClient:
|
||||||
|
def __init__(self, base_url: str, token: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "OpenClawClient | None":
|
||||||
|
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]:
|
||||||
|
payload: dict[str, Any] = {"tool": tool, "args": args}
|
||||||
|
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()
|
||||||
@@ -25,3 +25,7 @@ class Employee(SQLModel, table=True):
|
|||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
status: str = Field(default="active")
|
status: str = Field(default="active")
|
||||||
|
|
||||||
|
# OpenClaw integration
|
||||||
|
openclaw_session_key: str | None = None
|
||||||
|
notify_enabled: bool = Field(default=True)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class EmployeeCreate(SQLModel):
|
|||||||
title: str | None = None
|
title: str | None = None
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
|
|
||||||
|
# OpenClaw integration
|
||||||
|
openclaw_session_key: str | None = None
|
||||||
|
notify_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class EmployeeUpdate(SQLModel):
|
class EmployeeUpdate(SQLModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
@@ -29,3 +33,7 @@ class EmployeeUpdate(SQLModel):
|
|||||||
manager_id: int | None = None
|
manager_id: int | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
# OpenClaw integration
|
||||||
|
openclaw_session_key: str | None = None
|
||||||
|
notify_enabled: bool | None = None
|
||||||
|
|||||||
Reference in New Issue
Block a user