feat: add boards and tasks management endpoints

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

View File

View File

@@ -1,282 +0,0 @@
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_id: int
comment_id: int | None = None
changed_fields: dict | None = None
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()
out: list[Employee] = []
for e in emps:
if not getattr(e, "notify_enabled", True):
continue
if getattr(e, "openclaw_session_key", None):
out.append(e)
return out
def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
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, task: Task, comment: TaskComment | None
) -> set[int]:
recipients: set[int] = set()
if ctx.event == "task.created":
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 task.assignee_employee_id:
recipients.add(task.assignee_employee_id)
recipients |= _project_pm_employee_ids(session, task.project_id)
elif ctx.event == "comment.created":
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(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":
recipients |= _project_pm_employee_ids(session, task.project_id)
recipients.discard(ctx.actor_employee_id)
return recipients
def ensure_employee_provisioned(session: Session, employee_id: int) -> None:
"""Best-effort provisioning of a reviewer/manager so notifications can be delivered."""
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\n"
"Your job is to REVIEW work within the bounds of what the task requester asked for, using only the task + comments + current system state.\n"
"Do NOT wait for the requester to provide more info by default.\n\n"
"When a task is in review you must:\n"
"1) Read the task title/description and all comments\n"
"2) Verify the requested changes were actually made (check via Mission Control API if needed)\n"
"3) Decide: approve or request changes\n"
"4) Leave an audit comment explaining your decision (required)\n\n"
"If something is ambiguous or missing, request changes with a clear checklist. Only ask the human if it's truly impossible to decide.\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 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}\nPlease review and respond in Mission Control."
if ctx.event == "status.changed":
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}.\nPlease review and respond in 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(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
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
comment = session.get(TaskComment, ctx.comment_id) if ctx.comment_id else None
if ctx.event == "status.changed":
new_status = (getattr(task, "status", None) or "").lower()
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
ensure_employee_provisioned(session, int(task.reviewer_employee_id))
recipient_ids = resolve_recipients(session, ctx, task, comment)
logger.info(
"notify_openclaw: recipients resolved", extra={"recipient_ids": sorted(recipient_ids)}
)
recipients = _employees_with_session_keys(session, recipient_ids)
if not recipients:
logger.info("notify_openclaw: no recipients with session keys")
return
# base URL used in agent messages
base_url = __import__(
"app.core.urls", fromlist=["public_api_base_url"]
).public_api_base_url()
for e in recipients:
sk = getattr(e, "openclaw_session_key", None)
if not sk:
continue
message = build_message(
ctx=ctx,
task=task,
comment=comment,
recipient=e,
base_url=base_url,
)
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

View File

@@ -1,88 +0,0 @@
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:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.token = token
@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 = 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
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

View File

@@ -1,129 +0,0 @@
from __future__ import annotations
import json
import re
import time
from typing import Any
from app.integrations.openclaw import OpenClawClient
def _slug(s: str) -> str:
s = (s or "").strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
return s or "agent"
def desired_agent_id(*, employee_id: int, name: str) -> str:
return f"employee-{employee_id}-{_slug(name)}"
def ensure_full_agent_profile(
*,
client: OpenClawClient,
employee_id: int,
employee_name: str,
) -> dict[str, str]:
"""Ensure an OpenClaw agent profile exists for this employee.
Returns {"agent_id": ..., "workspace": ...}.
Implementation strategy:
- Create per-agent workspace + agent dir on the gateway host.
- Add/ensure entry in openclaw.json agents.list.
NOTE: This uses OpenClaw gateway tools via /tools/invoke (gateway + exec).
"""
agent_id = desired_agent_id(employee_id=employee_id, name=employee_name)
workspace = f"/home/asaharan/.openclaw/workspaces/{agent_id}"
agent_dir = f"/home/asaharan/.openclaw/agents/{agent_id}/agent"
# 1) Create dirs
client.tools_invoke(
"exec",
{
"command": f"mkdir -p {workspace} {agent_dir}",
},
timeout_s=20.0,
)
# 2) Write minimal identity files in the per-agent workspace
identity_md = (
"# IDENTITY.md\n\n"
"- **Name:** " + employee_name + "\n"
"- **Creature:** AI agent employee (Mission Control)\n"
"- **Vibe:** Direct, action-oriented, leaves audit trails\n"
)
user_md = (
"# USER.md\n\n"
"You work for Abhimanyu.\n"
"You must execute Mission Control tasks via the API and keep state synced.\n"
)
# Use cat heredocs to avoid dependency on extra tooling.
client.tools_invoke(
"exec",
{
"command": "bash -lc "
+ json.dumps(
"""
cat > {ws}/IDENTITY.md <<'EOF'
{identity}
EOF
cat > {ws}/USER.md <<'EOF'
{user}
EOF
""".format(ws=workspace, identity=identity_md, user=user_md)
),
},
timeout_s=20.0,
)
# 3) Update openclaw.json agents.list (idempotent)
cfg_resp = client.tools_invoke("gateway", {"action": "config.get"}, timeout_s=20.0)
raw = (
(((cfg_resp or {}).get("result") or {}).get("content") or [{}])[0].get("text")
if isinstance((((cfg_resp or {}).get("result") or {}).get("content") or [{}]), list)
else None
)
if not raw:
# fallback: tool may return {ok:true,result:{raw:...}}
raw = ((cfg_resp.get("result") or {}).get("raw")) if isinstance(cfg_resp, dict) else None
if not raw:
raise RuntimeError("Unable to read gateway config via tools")
cfg = json.loads(raw)
agents = cfg.get("agents") or {}
agents_list = agents.get("list") or []
if not isinstance(agents_list, list):
agents_list = []
exists = any(isinstance(a, dict) and a.get("id") == agent_id for a in agents_list)
if not exists:
agents_list.append(
{
"id": agent_id,
"name": employee_name,
"workspace": workspace,
"agentDir": agent_dir,
"identity": {"name": employee_name, "emoji": "🜁"},
}
)
agents["list"] = agents_list
cfg["agents"] = agents
client.tools_invoke(
"gateway",
{"action": "config.apply", "raw": json.dumps(cfg)},
timeout_s=30.0,
)
# give the gateway a moment to reload the agent registry
time.sleep(2.5)
return {"agent_id": agent_id, "workspace": workspace}

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode, urlparse, urlunparse
from uuid import uuid4
import websockets
from app.core.config import settings
class OpenClawGatewayError(RuntimeError):
pass
@dataclass
class OpenClawResponse:
payload: Any
def _build_gateway_url() -> str:
base_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789"
token = settings.openclaw_gateway_token
if not token:
return base_url
parsed = urlparse(base_url)
query = urlencode({"token": token})
return urlunparse(parsed._replace(query=query))
async def _await_response(ws: websockets.WebSocketClientProtocol, request_id: str) -> Any:
while True:
raw = await ws.recv()
data = json.loads(raw)
if data.get("type") == "res" and data.get("id") == request_id:
if data.get("ok") is False:
error = data.get("error", {}).get("message", "Gateway error")
raise OpenClawGatewayError(error)
return data.get("payload")
if data.get("id") == request_id:
if data.get("error"):
raise OpenClawGatewayError(data["error"].get("message", "Gateway error"))
return data.get("result")
async def _send_request(
ws: websockets.WebSocketClientProtocol, method: str, params: dict[str, Any] | None
) -> Any:
request_id = str(uuid4())
message = {"type": "req", "id": request_id, "method": method, "params": params or {}}
await ws.send(json.dumps(message))
return await _await_response(ws, request_id)
async def _handle_challenge(
ws: websockets.WebSocketClientProtocol, first_message: str | None
) -> None:
if not first_message:
return
data = json.loads(first_message)
if data.get("type") != "event" or data.get("event") != "connect.challenge":
return
connect_id = str(uuid4())
response = {
"type": "req",
"id": connect_id,
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 3,
"client": {
"id": "gateway-client",
"version": "1.0.0",
"platform": "web",
"mode": "ui",
},
"auth": {"token": settings.openclaw_gateway_token},
},
}
await ws.send(json.dumps(response))
await _await_response(ws, connect_id)
async def openclaw_call(method: str, params: dict[str, Any] | None = None) -> Any:
gateway_url = _build_gateway_url()
try:
async with websockets.connect(gateway_url, ping_interval=None) as ws:
first_message = None
try:
first_message = await asyncio.wait_for(ws.recv(), timeout=2)
except asyncio.TimeoutError:
first_message = None
await _handle_challenge(ws, first_message)
return await _send_request(ws, method, params)
except OpenClawGatewayError:
raise
except Exception as exc: # pragma: no cover - network errors
raise OpenClawGatewayError(str(exc)) from exc
async def send_message(
message: str,
*,
session_key: str,
deliver: bool = False,
) -> Any:
params: dict[str, Any] = {
"sessionKey": session_key,
"message": message,
"deliver": deliver,
"idempotencyKey": str(uuid4()),
}
return await openclaw_call("chat.send", params)
async def get_chat_history(session_key: str, limit: int | None = None) -> Any:
params: dict[str, Any] = {"sessionKey": session_key}
if limit is not None:
params["limit"] = limit
return await openclaw_call("chat.history", params)