Merge pull request #28 from abhi1693/feature/board-lead-orchestration

Board lead orchestration: goals, onboarding, approvals, and live comments
This commit is contained in:
Abhimanyu Saharan
2026-02-06 11:50:34 +05:30
committed by GitHub
61 changed files with 7043 additions and 307 deletions

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ node_modules/
# IDE
.idea/
.runlogs/
# Worktrees
.worktrees/

View File

@@ -0,0 +1,145 @@
"""board lead orchestration
Revision ID: 3b9b2f1a6c2d
Revises: 9f2c1a7b0d3e
Create Date: 2026-02-05 14:45:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "3b9b2f1a6c2d"
down_revision = "9f2c1a7b0d3e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("boards", sa.Column("board_type", sa.String(), server_default="goal", nullable=False))
op.add_column("boards", sa.Column("objective", sa.Text(), nullable=True))
op.add_column("boards", sa.Column("success_metrics", sa.JSON(), nullable=True))
op.add_column("boards", sa.Column("target_date", sa.DateTime(), nullable=True))
op.add_column(
"boards",
sa.Column("goal_confirmed", sa.Boolean(), server_default=sa.text("false"), nullable=False),
)
op.add_column("boards", sa.Column("goal_source", sa.Text(), nullable=True))
op.add_column(
"agents",
sa.Column("is_board_lead", sa.Boolean(), server_default=sa.text("false"), nullable=False),
)
op.add_column(
"tasks",
sa.Column("auto_created", sa.Boolean(), server_default=sa.text("false"), nullable=False),
)
op.add_column("tasks", sa.Column("auto_reason", sa.Text(), nullable=True))
op.create_table(
"board_memory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column("source", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_memory_board_id", "board_memory", ["board_id"], unique=False)
op.create_table(
"approvals",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("agent_id", sa.Uuid(), nullable=True),
sa.Column("action_type", sa.String(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("confidence", sa.Integer(), nullable=False),
sa.Column("rubric_scores", sa.JSON(), nullable=True),
sa.Column("status", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_approvals_board_id", "approvals", ["board_id"], unique=False)
op.create_index("ix_approvals_agent_id", "approvals", ["agent_id"], unique=False)
op.create_index("ix_approvals_status", "approvals", ["status"], unique=False)
op.create_table(
"board_onboarding_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("session_key", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("messages", sa.JSON(), nullable=True),
sa.Column("draft_goal", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_board_onboarding_sessions_board_id",
"board_onboarding_sessions",
["board_id"],
unique=False,
)
op.create_index(
"ix_board_onboarding_sessions_status",
"board_onboarding_sessions",
["status"],
unique=False,
)
op.create_table(
"task_fingerprints",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("fingerprint_hash", sa.String(), nullable=False),
sa.Column("task_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_task_fingerprints_board_hash",
"task_fingerprints",
["board_id", "fingerprint_hash"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_task_fingerprints_board_hash", table_name="task_fingerprints")
op.drop_table("task_fingerprints")
op.drop_index(
"ix_board_onboarding_sessions_status", table_name="board_onboarding_sessions"
)
op.drop_index(
"ix_board_onboarding_sessions_board_id", table_name="board_onboarding_sessions"
)
op.drop_table("board_onboarding_sessions")
op.drop_index("ix_approvals_status", table_name="approvals")
op.drop_index("ix_approvals_agent_id", table_name="approvals")
op.drop_index("ix_approvals_board_id", table_name="approvals")
op.drop_table("approvals")
op.drop_index("ix_board_memory_board_id", table_name="board_memory")
op.drop_table("board_memory")
op.drop_column("tasks", "auto_reason")
op.drop_column("tasks", "auto_created")
op.drop_column("agents", "is_board_lead")
op.drop_column("boards", "goal_source")
op.drop_column("boards", "goal_confirmed")
op.drop_column("boards", "target_date")
op.drop_column("boards", "success_metrics")
op.drop_column("boards", "objective")
op.drop_column("boards", "board_type")

385
backend/app/api/agent.py Normal file
View File

@@ -0,0 +1,385 @@
from __future__ import annotations
import asyncio
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.api import agents as agents_api
from app.api import approvals as approvals_api
from app.api import board_memory as board_memory_api
from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_api
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.tasks import Task
from app.schemas.agents import AgentCreate, AgentHeartbeat, AgentHeartbeatCreate, AgentNudge, AgentRead
from app.schemas.approvals import ApprovalCreate, ApprovalRead
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingRead
from app.schemas.boards import BoardRead
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
router = APIRouter(prefix="/agent", tags=["agent"])
def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
return ActorContext(actor_type="agent", agent=agent_ctx.agent)
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def _gateway_config(session: Session, board: Board) -> GatewayClientConfig:
if not board.gateway_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
gateway = session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return GatewayClientConfig(url=gateway.url, token=gateway.token)
@router.get("/boards", response_model=list[BoardRead])
def list_boards(
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[Board]:
if agent_ctx.agent.board_id:
board = session.get(Board, agent_ctx.agent.board_id)
return [board] if board else []
return list(session.exec(select(Board)))
@router.get("/boards/{board_id}", response_model=BoardRead)
def get_board(
board: Board = Depends(get_board_or_404),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> Board:
_guard_board_access(agent_ctx, board)
return board
@router.get("/agents", response_model=list[AgentRead])
def list_agents(
board_id: UUID | None = Query(default=None),
limit: int | None = Query(default=None, ge=1, le=200),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[AgentRead]:
statement = select(Agent)
if agent_ctx.agent.board_id:
if board_id and board_id != agent_ctx.agent.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = statement.where(Agent.board_id == agent_ctx.agent.board_id)
elif board_id:
statement = statement.where(Agent.board_id == board_id)
if limit is not None:
statement = statement.limit(limit)
agents = list(session.exec(statement))
main_session_keys = agents_api._get_gateway_main_session_keys(session)
return [
agents_api._to_agent_read(agents_api._with_computed_status(agent), main_session_keys)
for agent in agents
]
@router.get("/boards/{board_id}/tasks", response_model=list[TaskRead])
def list_tasks(
status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
limit: int | None = Query(default=None, ge=1, le=200),
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[TaskRead]:
_guard_board_access(agent_ctx, board)
return tasks_api.list_tasks(
status_filter=status_filter,
assigned_agent_id=assigned_agent_id,
unassigned=unassigned,
limit=limit,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/tasks", response_model=TaskRead)
def create_task(
payload: TaskCreate,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> TaskRead:
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
tasks_api.validate_task_status(payload.status)
task = Task.model_validate(payload)
task.board_id = board.id
task.auto_created = True
task.auto_reason = f"lead_agent:{agent_ctx.agent.id}"
if task.assigned_agent_id:
agent = session.get(Agent, task.assigned_agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads cannot assign tasks to themselves.",
)
if agent.board_id and agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
session.add(task)
session.commit()
session.refresh(task)
record_activity(
session,
event_type="task.created",
task_id=task.id,
message=f"Task created by lead: {task.title}.",
agent_id=agent_ctx.agent.id,
)
session.commit()
if task.assigned_agent_id:
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
tasks_api._notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return task
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
def update_task(
payload: TaskUpdate,
task=Depends(get_task_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> TaskRead:
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return tasks_api.update_task(
payload=payload,
task=task,
session=session,
actor=_actor(agent_ctx),
)
@router.get("/boards/{board_id}/tasks/{task_id}/comments", response_model=list[TaskCommentRead])
def list_task_comments(
task=Depends(get_task_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[TaskCommentRead]:
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return tasks_api.list_task_comments(
task=task,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead)
def create_task_comment(
payload: TaskCommentCreate,
task=Depends(get_task_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> TaskCommentRead:
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return tasks_api.create_task_comment(
payload=payload,
task=task,
session=session,
actor=_actor(agent_ctx),
)
@router.get("/boards/{board_id}/memory", response_model=list[BoardMemoryRead])
def list_board_memory(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[BoardMemoryRead]:
_guard_board_access(agent_ctx, board)
return board_memory_api.list_board_memory(
limit=limit,
offset=offset,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead)
def create_board_memory(
payload: BoardMemoryCreate,
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> BoardMemoryRead:
_guard_board_access(agent_ctx, board)
return board_memory_api.create_board_memory(
payload=payload,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.get("/boards/{board_id}/approvals", response_model=list[ApprovalRead])
def list_approvals(
status_filter: str | None = Query(default=None, alias="status"),
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> list[ApprovalRead]:
_guard_board_access(agent_ctx, board)
return approvals_api.list_approvals(
status_filter=status_filter,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead)
def create_approval(
payload: ApprovalCreate,
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> ApprovalRead:
_guard_board_access(agent_ctx, board)
return approvals_api.create_approval(
payload=payload,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead)
def update_onboarding(
payload: dict[str, object],
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> BoardOnboardingRead:
_guard_board_access(agent_ctx, board)
return onboarding_api.agent_onboarding_update(
payload=payload,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/agents", response_model=AgentRead)
async def create_agent(
payload: AgentCreate,
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> AgentRead:
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not agent_ctx.agent.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id})
return await agents_api.create_agent(
payload=payload,
session=session,
actor=_actor(agent_ctx),
)
@router.post("/boards/{board_id}/agents/{agent_id}/nudge")
def nudge_agent(
payload: AgentNudge,
agent_id: str,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> dict[str, bool]:
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
target = session.get(Agent, agent_id)
if target is None or (target.board_id and target.board_id != board.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not target.openclaw_session_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Target agent has no session key",
)
message = payload.message.strip()
if not message:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="message is required",
)
config = _gateway_config(session, board)
async def _send() -> None:
await ensure_session(target.openclaw_session_id, config=config, label=target.name)
await send_message(
message,
session_key=target.openclaw_session_id,
config=config,
deliver=True,
)
try:
asyncio.run(_send())
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="agent.nudge.failed",
message=f"Nudge failed for {target.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
session.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
record_activity(
session,
event_type="agent.nudge.sent",
message=f"Nudge sent to {target.name}.",
agent_id=agent_ctx.agent.id,
)
session.commit()
return {"ok": True}
@router.post("/heartbeat", response_model=AgentRead)
async def agent_heartbeat(
payload: AgentHeartbeatCreate,
session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> AgentRead:
# Heartbeats must apply to the authenticated agent; agent names are not unique.
return agents_api.heartbeat_agent( # type: ignore[attr-defined]
agent_id=str(agent_ctx.agent.id),
payload=AgentHeartbeat(status=payload.status),
session=session,
actor=_actor(agent_ctx),
)

View File

@@ -1,17 +1,21 @@
from __future__ import annotations
import asyncio
import json
import re
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import update
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, or_, update
from sqlmodel import Session, col, select
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.db.session import get_session
from app.db.session import engine, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent
@@ -31,6 +35,7 @@ from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
cleanup_agent,
provision_agent,
provision_main_agent,
)
router = APIRouter(prefix="/agents", tags=["agents"])
@@ -39,6 +44,22 @@ OFFLINE_AFTER = timedelta(minutes=10)
AGENT_SESSION_PREFIX = "agent"
def _parse_since(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.strip()
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _normalize_identity_profile(
profile: dict[str, object] | None,
) -> dict[str, str] | None:
@@ -121,6 +142,35 @@ def _require_gateway(session: Session, board: Board) -> tuple[Gateway, GatewayCl
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
def _gateway_client_config(gateway: Gateway) -> GatewayClientConfig:
if not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
return GatewayClientConfig(url=gateway.url, token=gateway.token)
def _get_gateway_main_session_keys(session: Session) -> set[str]:
keys = session.exec(select(Gateway.main_session_key)).all()
return {key for key in keys if key}
def _is_gateway_main(agent: Agent, main_session_keys: set[str]) -> bool:
return bool(agent.openclaw_session_id and agent.openclaw_session_id in main_session_keys)
def _to_agent_read(agent: Agent, main_session_keys: set[str]) -> AgentRead:
model = AgentRead.model_validate(agent, from_attributes=True)
return model.model_copy(update={"is_gateway_main": _is_gateway_main(agent, main_session_keys)})
def _find_gateway_for_main_session(session: Session, session_key: str | None) -> Gateway | None:
if not session_key:
return None
return session.exec(select(Gateway).where(Gateway.main_session_key == session_key)).first()
async def _ensure_gateway_session(
agent_name: str,
config: GatewayClientConfig,
@@ -144,6 +194,27 @@ def _with_computed_status(agent: Agent) -> Agent:
return agent
def _serialize_agent(agent: Agent, main_session_keys: set[str]) -> dict[str, object]:
return _to_agent_read(_with_computed_status(agent), main_session_keys).model_dump(mode="json")
def _fetch_agent_events(
board_id: UUID | None,
since: datetime,
) -> list[Agent]:
with Session(engine) as session:
statement = select(Agent)
if board_id:
statement = statement.where(col(Agent.board_id) == board_id)
statement = statement.where(
or_(
col(Agent.updated_at) >= since,
col(Agent.last_seen_at) >= since,
)
).order_by(asc(col(Agent.updated_at)))
return list(session.exec(statement))
def _record_heartbeat(session: Session, agent: Agent) -> None:
record_activity(
session,
@@ -182,25 +253,84 @@ def list_agents(
auth: AuthContext = Depends(require_admin_auth),
) -> list[Agent]:
agents = list(session.exec(select(Agent)))
return [_with_computed_status(agent) for agent in agents]
main_session_keys = _get_gateway_main_session_keys(session)
return [_to_agent_read(_with_computed_status(agent), main_session_keys) for agent in agents]
@router.get("/stream")
async def stream_agents(
request: Request,
board_id: UUID | None = Query(default=None),
since: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin_auth),
) -> EventSourceResponse:
since_dt = _parse_since(since) or datetime.utcnow()
last_seen = since_dt
async def event_generator():
nonlocal last_seen
while True:
if await request.is_disconnected():
break
agents = await run_in_threadpool(_fetch_agent_events, board_id, last_seen)
if agents:
with Session(engine) as session:
main_session_keys = _get_gateway_main_session_keys(session)
for agent in agents:
updated_at = agent.updated_at or agent.last_seen_at or datetime.utcnow()
if updated_at > last_seen:
last_seen = updated_at
payload = {"agent": _serialize_agent(agent, main_session_keys)}
yield {"event": "agent", "data": json.dumps(payload)}
await asyncio.sleep(2)
return EventSourceResponse(event_generator(), ping=15)
@router.post("", response_model=AgentRead)
async def create_agent(
payload: AgentCreate,
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent:
if actor.actor_type == "agent":
if not actor.agent or not actor.agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only board leads can create agents",
)
if not actor.agent.board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board lead must be assigned to a board",
)
if payload.board_id and payload.board_id != actor.agent.board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only create agents in their own board",
)
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
board = _require_board(session, payload.board_id)
gateway, client_config = _require_gateway(session, board)
data = payload.model_dump()
requested_name = (data.get("name") or "").strip()
if requested_name:
existing = session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.name).ilike(requested_name))
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An agent with this name already exists on this board.",
)
if data.get("identity_template") == "":
data["identity_template"] = None
if data.get("soul_template") == "":
data["soul_template"] = None
data["identity_profile"] = _normalize_identity_profile(
data.get("identity_profile")
)
data["identity_profile"] = _normalize_identity_profile(data.get("identity_profile"))
agent = Agent.model_validate(data)
agent.status = "provisioning"
raw_token = generate_agent_token()
@@ -230,7 +360,14 @@ async def create_agent(
)
session.commit()
try:
await provision_agent(agent, board, gateway, raw_token, auth.user, action="provision")
await provision_agent(
agent,
board,
gateway,
raw_token,
actor.user if actor.actor_type == "user" else None,
action="provision",
)
await _send_wakeup_message(agent, client_config, verb="provisioned")
agent.provision_confirm_token_hash = None
agent.provision_requested_at = None
@@ -269,13 +406,15 @@ def get_agent(
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return _with_computed_status(agent)
main_session_keys = _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@router.patch("/{agent_id}", response_model=AgentRead)
async def update_agent(
agent_id: str,
payload: AgentUpdate,
force: bool = False,
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Agent:
@@ -283,6 +422,7 @@ async def update_agent(
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True)
make_main = updates.pop("is_gateway_main", None)
if "status" in updates:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -293,25 +433,72 @@ async def update_agent(
if updates.get("soul_template") == "":
updates["soul_template"] = None
if "identity_profile" in updates:
updates["identity_profile"] = _normalize_identity_profile(
updates.get("identity_profile")
)
if not updates:
return _with_computed_status(agent)
if "board_id" in updates:
updates["identity_profile"] = _normalize_identity_profile(updates.get("identity_profile"))
if not updates and not force and make_main is None:
main_session_keys = _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
main_gateway = _find_gateway_for_main_session(session, agent.openclaw_session_id)
gateway_for_main: Gateway | None = None
if make_main is True:
board_source = updates.get("board_id") or agent.board_id
board_for_main = _require_board(session, board_source)
gateway_for_main, _ = _require_gateway(session, board_for_main)
updates["board_id"] = None
agent.is_board_lead = False
agent.openclaw_session_id = gateway_for_main.main_session_key
main_gateway = gateway_for_main
elif make_main is False:
agent.openclaw_session_id = None
if make_main is not True and "board_id" in updates:
_require_board(session, updates["board_id"])
for key, value in updates.items():
setattr(agent, key, value)
if make_main is None and main_gateway is not None:
agent.board_id = None
agent.is_board_lead = False
agent.updated_at = datetime.utcnow()
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent)
session.commit()
session.refresh(agent)
board = _require_board(session, agent.board_id)
gateway, client_config = _require_gateway(session, board)
is_main_agent = False
board: Board | None = None
gateway: Gateway | None = None
client_config: GatewayClientConfig | None = None
if make_main is True:
is_main_agent = True
gateway = gateway_for_main
elif make_main is None and agent.board_id is None and main_gateway is not None:
is_main_agent = True
gateway = main_gateway
if is_main_agent:
if gateway is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Main agent requires a gateway main_session_key",
)
if not gateway.main_session_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway main_session_key is required",
)
client_config = _gateway_client_config(gateway)
else:
if agent.board_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_id is required for non-main agents",
)
board = _require_board(session, agent.board_id)
gateway, client_config = _require_gateway(session, board)
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
try:
if client_config is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway configuration is required",
)
await ensure_session(session_key, config=client_config, label=agent.name)
if not agent.openclaw_session_id:
agent.openclaw_session_id = session_key
@@ -330,7 +517,32 @@ async def update_agent(
session.commit()
session.refresh(agent)
try:
await provision_agent(agent, board, gateway, raw_token, auth.user, action="update")
if gateway is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway configuration is required",
)
if is_main_agent:
await provision_main_agent(
agent,
gateway,
raw_token,
auth.user,
action="update",
force_bootstrap=force,
reset_session=True,
)
else:
await provision_agent(
agent,
board,
gateway,
raw_token,
auth.user,
action="update",
force_bootstrap=force,
reset_session=True,
)
await _send_wakeup_message(agent, client_config, verb="updated")
agent.provision_confirm_token_hash = None
agent.provision_requested_at = None
@@ -355,10 +567,19 @@ async def update_agent(
except OpenClawGatewayError as exc:
_record_instruction_failure(session, agent, str(exc), "update")
session.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway update failed: {exc}",
) from exc
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "update")
session.commit()
return _with_computed_status(agent)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Unexpected error updating agent provisioning.",
) from exc
main_session_keys = _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@router.post("/{agent_id}/heartbeat", response_model=AgentRead)
@@ -367,7 +588,7 @@ def heartbeat_agent(
payload: AgentHeartbeat,
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent:
) -> AgentRead:
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -383,7 +604,8 @@ def heartbeat_agent(
session.add(agent)
session.commit()
session.refresh(agent)
return _with_computed_status(agent)
main_session_keys = _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@router.post("/heartbeat", response_model=AgentRead)
@@ -391,8 +613,20 @@ async def heartbeat_or_create_agent(
payload: AgentHeartbeatCreate,
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent:
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first()
) -> AgentRead:
# Agent tokens must heartbeat their authenticated agent record. Names are not unique.
if actor.actor_type == "agent" and actor.agent:
return heartbeat_agent(
agent_id=str(actor.agent.id),
payload=AgentHeartbeat(status=payload.status),
session=session,
actor=actor,
)
statement = select(Agent).where(Agent.name == payload.name)
if payload.board_id is not None:
statement = statement.where(Agent.board_id == payload.board_id)
agent = session.exec(statement).first()
if agent is None:
if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -528,7 +762,8 @@ async def heartbeat_or_create_agent(
session.add(agent)
session.commit()
session.refresh(agent)
return _with_computed_status(agent)
main_session_keys = _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@router.delete("/{agent_id}")
@@ -590,9 +825,7 @@ def delete_agent(
)
)
session.execute(
update(ActivityEvent)
.where(col(ActivityEvent.agent_id) == agent.id)
.values(agent_id=None)
update(ActivityEvent).where(col(ActivityEvent.agent_id) == agent.id).values(agent_id=None)
)
session.delete(agent)
session.commit()

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, or_
from sqlmodel import Session, col, select
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.db.session import engine, get_session
from app.models.approvals import Approval
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate
router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"])
ALLOWED_STATUSES = {"pending", "approved", "rejected"}
def _parse_since(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.strip()
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _approval_updated_at(approval: Approval) -> datetime:
return approval.resolved_at or approval.created_at
def _serialize_approval(approval: Approval) -> dict[str, object]:
return ApprovalRead.model_validate(approval, from_attributes=True).model_dump(mode="json")
def _fetch_approval_events(
board_id: UUID,
since: datetime,
) -> list[Approval]:
with Session(engine) as session:
statement = (
select(Approval)
.where(col(Approval.board_id) == board_id)
.where(
or_(
col(Approval.created_at) >= since,
col(Approval.resolved_at) >= since,
)
)
.order_by(asc(col(Approval.created_at)))
)
return list(session.exec(statement))
@router.get("", response_model=list[ApprovalRead])
def list_approvals(
status_filter: str | None = Query(default=None, alias="status"),
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[Approval]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = select(Approval).where(col(Approval.board_id) == board.id)
if status_filter:
if status_filter not in ALLOWED_STATUSES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
statement = statement.where(col(Approval.status) == status_filter)
statement = statement.order_by(col(Approval.created_at).desc())
return list(session.exec(statement))
@router.get("/stream")
async def stream_approvals(
request: Request,
board=Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since_dt = _parse_since(since) or datetime.utcnow()
last_seen = since_dt
async def event_generator():
nonlocal last_seen
while True:
if await request.is_disconnected():
break
approvals = await run_in_threadpool(_fetch_approval_events, board.id, last_seen)
for approval in approvals:
updated_at = _approval_updated_at(approval)
if updated_at > last_seen:
last_seen = updated_at
payload = {"approval": _serialize_approval(approval)}
yield {"event": "approval", "data": json.dumps(payload)}
await asyncio.sleep(2)
return EventSourceResponse(event_generator(), ping=15)
@router.post("", response_model=ApprovalRead)
def create_approval(
payload: ApprovalCreate,
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Approval:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
approval = Approval(
board_id=board.id,
agent_id=payload.agent_id,
action_type=payload.action_type,
payload=payload.payload,
confidence=payload.confidence,
rubric_scores=payload.rubric_scores,
status=payload.status,
)
session.add(approval)
session.commit()
session.refresh(approval)
return approval
@router.patch("/{approval_id}", response_model=ApprovalRead)
def update_approval(
approval_id: str,
payload: ApprovalUpdate,
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
auth=Depends(require_admin_auth),
) -> Approval:
approval = session.get(Approval, approval_id)
if approval is None or approval.board_id != board.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True)
if "status" in updates:
if updates["status"] not in ALLOWED_STATUSES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
approval.status = updates["status"]
if approval.status != "pending":
approval.resolved_at = datetime.utcnow()
session.add(approval)
session.commit()
session.refresh(approval)
return approval

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import asyncio
import json
import re
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlmodel import Session, col, select
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent
from app.core.config import settings
from app.db.session import engine, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.gateways import Gateway
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"])
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def _parse_since(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.strip()
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _serialize_memory(memory: BoardMemory) -> dict[str, object]:
return BoardMemoryRead.model_validate(memory, from_attributes=True).model_dump(mode="json")
def _extract_mentions(message: str) -> set[str]:
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
if not mentions:
return False
name = (agent.name or "").strip()
if not name:
return False
normalized = name.lower()
if normalized in mentions:
return True
first = normalized.split()[0]
return first in mentions
def _gateway_config(session: Session, board) -> GatewayClientConfig | None:
if not board.gateway_id:
return None
gateway = session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _send_agent_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=False)
def _fetch_memory_events(
board_id,
since: datetime,
) -> list[BoardMemory]:
with Session(engine) as session:
statement = (
select(BoardMemory)
.where(col(BoardMemory.board_id) == board_id)
.where(col(BoardMemory.created_at) >= since)
.order_by(col(BoardMemory.created_at))
)
return list(session.exec(statement))
def _notify_chat_targets(
*,
session: Session,
board,
memory: BoardMemory,
actor: ActorContext,
) -> None:
if not memory.content:
return
config = _gateway_config(session, board)
if config is None:
return
mentions = _extract_mentions(memory.content)
statement = select(Agent).where(col(Agent.board_id) == board.id)
targets: dict[str, Agent] = {}
for agent in session.exec(statement):
if agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and _matches_mention(agent, mentions):
targets[str(agent.id)] = agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(str(actor.agent.id), None)
if not targets:
return
actor_name = "User"
if actor.actor_type == "agent" and actor.agent:
actor_name = actor.agent.name
elif actor.user:
actor_name = actor.user.preferred_name or actor.user.name or actor_name
snippet = memory.content.strip()
if len(snippet) > 800:
snippet = f"{snippet[:797]}..."
base_url = settings.base_url or "http://localhost:8000"
for agent in targets.values():
if not agent.openclaw_session_id:
continue
mentioned = _matches_mention(agent, mentions)
header = "BOARD CHAT MENTION" if mentioned else "BOARD CHAT"
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"From: {actor_name}\n\n"
f"{snippet}\n\n"
"Reply via board chat:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
'Body: {"content":"...","tags":["chat"]}'
)
try:
asyncio.run(
_send_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
)
except OpenClawGatewayError:
continue
@router.get("", response_model=list[BoardMemoryRead])
def list_board_memory(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[BoardMemory]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(BoardMemory)
.where(col(BoardMemory.board_id) == board.id)
.order_by(col(BoardMemory.created_at).desc())
.offset(offset)
.limit(limit)
)
return list(session.exec(statement))
@router.get("/stream")
async def stream_board_memory(
request: Request,
board=Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since_dt = _parse_since(since) or datetime.utcnow()
last_seen = since_dt
async def event_generator():
nonlocal last_seen
while True:
if await request.is_disconnected():
break
memories = await run_in_threadpool(_fetch_memory_events, board.id, last_seen)
for memory in memories:
if memory.created_at > last_seen:
last_seen = memory.created_at
payload = {"memory": _serialize_memory(memory)}
yield {"event": "memory", "data": json.dumps(payload)}
await asyncio.sleep(2)
return EventSourceResponse(event_generator(), ping=15)
@router.post("", response_model=BoardMemoryRead)
def create_board_memory(
payload: BoardMemoryCreate,
board=Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardMemory:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
is_chat = payload.tags is not None and "chat" in payload.tags
source = payload.source
if is_chat and not source:
if actor.actor_type == "agent" and actor.agent:
source = actor.agent.name
elif actor.user:
source = actor.user.preferred_name or actor.user.name or "User"
memory = BoardMemory(
board_id=board.id,
content=payload.content,
tags=payload.tags,
source=source,
)
session.add(memory)
session.commit()
session.refresh(memory)
if is_chat:
_notify_chat_targets(session=session, board=board, memory=memory, actor=actor)
return memory

View File

@@ -0,0 +1,332 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.config import settings
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.board_onboarding import (
BoardOnboardingAnswer,
BoardOnboardingConfirm,
BoardOnboardingRead,
BoardOnboardingStart,
)
from app.schemas.boards import BoardRead
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
logger = logging.getLogger(__name__)
def _gateway_config(session: Session, board: Board) -> tuple[Gateway, GatewayClientConfig]:
if not board.gateway_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
gateway = session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url or not gateway.main_session_key:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
def _build_session_key(agent_name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", agent_name.lower()).strip("-")
return f"agent:{slug or uuid4().hex}:main"
def _lead_agent_name(board: Board) -> str:
return "Lead Agent"
def _lead_session_key(board: Board) -> str:
return f"agent:lead-{board.id}:main"
async def _ensure_lead_agent(
session: Session,
board: Board,
gateway: Gateway,
config: GatewayClientConfig,
auth: AuthContext,
) -> Agent:
existing = session.exec(
select(Agent).where(Agent.board_id == board.id).where(Agent.is_board_lead.is_(True))
).first()
if existing:
if existing.name != _lead_agent_name(board):
existing.name = _lead_agent_name(board)
session.add(existing)
session.commit()
session.refresh(existing)
return existing
agent = Agent(
name=_lead_agent_name(board),
status="provisioning",
board_id=board.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile={
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
},
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
agent.provision_requested_at = datetime.utcnow()
agent.provision_action = "provision"
agent.openclaw_session_id = _lead_session_key(board)
session.add(agent)
session.commit()
session.refresh(agent)
try:
await provision_agent(agent, board, gateway, raw_token, auth.user, action="provision")
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. Board confirmation should still succeed.
pass
return agent
@router.get("", response_model=BoardOnboardingRead)
def get_onboarding(
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = session.exec(
select(BoardOnboardingSession)
.where(BoardOnboardingSession.board_id == board.id)
.order_by(BoardOnboardingSession.created_at.desc())
).first()
if onboarding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return onboarding
@router.post("/start", response_model=BoardOnboardingRead)
async def start_onboarding(
payload: BoardOnboardingStart,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = session.exec(
select(BoardOnboardingSession)
.where(BoardOnboardingSession.board_id == board.id)
.where(BoardOnboardingSession.status == "active")
).first()
if onboarding:
return onboarding
gateway, config = _gateway_config(session, board)
session_key = gateway.main_session_key
base_url = settings.base_url or "http://localhost:8000"
prompt = (
"BOARD ONBOARDING REQUEST\n\n"
f"Board Name: {board.name}\n"
"You are the main agent. Ask the user 3-6 focused questions to clarify their goal.\n"
"Do NOT respond in OpenClaw chat.\n"
"All onboarding responses MUST be sent to Mission Control via API.\n"
f"Mission Control base URL: {base_url}\n"
"Use the AUTH_TOKEN from USER.md or TOOLS.md and pass it as X-Agent-Token.\n"
"Onboarding response endpoint:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/onboarding\n"
"QUESTION example (send JSON body exactly as shown):\n"
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
'-H "X-Agent-Token: $AUTH_TOKEN" '
'-H "Content-Type: application/json" '
'-d \'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\'\n'
"COMPLETION example (send JSON body exactly as shown):\n"
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
'-H "X-Agent-Token: $AUTH_TOKEN" '
'-H "Content-Type: application/json" '
'-d \'{"status":"complete","board_type":"goal","objective":"...","success_metrics":{...},"target_date":"YYYY-MM-DD"}\'\n'
"QUESTION FORMAT (one question per response, no arrays, no markdown, no extra text):\n"
'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\n'
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
"When you have enough info, return JSON ONLY (via API):\n"
'{"status":"complete","board_type":"goal"|"general","objective":"...",'
'"success_metrics":{...},"target_date":"YYYY-MM-DD"}.'
)
try:
await ensure_session(session_key, config=config, label="Main Agent")
await send_message(prompt, session_key=session_key, config=config, deliver=False)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
onboarding = BoardOnboardingSession(
board_id=board.id,
session_key=session_key,
status="active",
messages=[{"role": "user", "content": prompt, "timestamp": datetime.utcnow().isoformat()}],
)
session.add(onboarding)
session.commit()
session.refresh(onboarding)
return onboarding
@router.post("/answer", response_model=BoardOnboardingRead)
async def answer_onboarding(
payload: BoardOnboardingAnswer,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = session.exec(
select(BoardOnboardingSession)
.where(BoardOnboardingSession.board_id == board.id)
.order_by(BoardOnboardingSession.created_at.desc())
).first()
if onboarding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
_, config = _gateway_config(session, board)
answer_text = payload.answer
if payload.other_text:
answer_text = f"{payload.answer}: {payload.other_text}"
messages = list(onboarding.messages or [])
messages.append(
{"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()}
)
try:
await ensure_session(onboarding.session_key, config=config, label="Main Agent")
await send_message(
answer_text, session_key=onboarding.session_key, config=config, deliver=False
)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
onboarding.messages = messages
onboarding.updated_at = datetime.utcnow()
session.add(onboarding)
session.commit()
session.refresh(onboarding)
return onboarding
@router.post("/agent", response_model=BoardOnboardingRead)
def agent_onboarding_update(
payload: dict[str, object],
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardOnboardingSession:
if actor.actor_type != "agent" or actor.agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
agent = actor.agent
if agent.board_id is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if board.gateway_id:
gateway = session.get(Gateway, board.gateway_id)
if gateway and gateway.main_session_key and agent.openclaw_session_id:
if agent.openclaw_session_id != gateway.main_session_key:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
onboarding = session.exec(
select(BoardOnboardingSession)
.where(BoardOnboardingSession.board_id == board.id)
.order_by(BoardOnboardingSession.created_at.desc())
).first()
if onboarding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if onboarding.status == "confirmed":
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
messages = list(onboarding.messages or [])
now = datetime.utcnow().isoformat()
payload_text = json.dumps(payload)
logger.info(
"onboarding.agent.update board_id=%s agent_id=%s payload=%s",
board.id,
agent.id,
payload_text,
)
payload_status = payload.get("status")
if payload_status == "complete":
onboarding.draft_goal = payload
onboarding.status = "completed"
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
else:
question = payload.get("question")
options = payload.get("options")
if not isinstance(question, str) or not isinstance(options, list):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
onboarding.messages = messages
onboarding.updated_at = datetime.utcnow()
session.add(onboarding)
session.commit()
session.refresh(onboarding)
logger.info(
"onboarding.agent.update stored board_id=%s messages_count=%s status=%s",
board.id,
len(onboarding.messages or []),
onboarding.status,
)
return onboarding
@router.post("/confirm", response_model=BoardRead)
async def confirm_onboarding(
payload: BoardOnboardingConfirm,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Board:
onboarding = session.exec(
select(BoardOnboardingSession)
.where(BoardOnboardingSession.board_id == board.id)
.order_by(BoardOnboardingSession.created_at.desc())
).first()
if onboarding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board.board_type = payload.board_type
board.objective = payload.objective
board.success_metrics = payload.success_metrics
board.target_date = payload.target_date
board.goal_confirmed = True
board.goal_source = "lead_agent_onboarding"
onboarding.status = "confirmed"
onboarding.updated_at = datetime.utcnow()
gateway, config = _gateway_config(session, board)
session.add(board)
session.add(onboarding)
session.commit()
session.refresh(board)
await _ensure_lead_agent(session, board, gateway, config, auth)
return board

View File

@@ -20,8 +20,12 @@ from app.integrations.openclaw_gateway import (
)
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
@@ -161,6 +165,14 @@ def update_board(
)
for key, value in updates.items():
setattr(board, key, value)
if updates.get("board_type") == "goal":
objective = updates.get("objective") or board.objective
metrics = updates.get("success_metrics") or board.success_metrics
if not objective or not metrics:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Goal boards require objective and success_metrics",
)
if not board.gateway_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -194,10 +206,16 @@ def delete_board(
if task_ids:
session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id))
if agents:
agent_ids = [agent.id for agent in agents]
session.execute(delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)))
session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids)))
session.execute(delete(Approval).where(col(Approval.board_id) == board.id))
session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id))
session.execute(
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id)
)
session.execute(delete(Task).where(col(Task.board_id) == board.id))
session.delete(board)
session.commit()

View File

@@ -1,16 +1,20 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.gateways import Gateway
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_main_agent
router = APIRouter(prefix="/gateways", tags=["gateways"])
@@ -227,6 +231,100 @@ rm -rf ~/.openclaw/skills/skyll
""".strip()
def _main_agent_name(gateway: Gateway) -> str:
return f"{gateway.name} Main"
def _find_main_agent(
session: Session,
gateway: Gateway,
previous_name: str | None = None,
previous_session_key: str | None = None,
) -> Agent | None:
if gateway.main_session_key:
agent = session.exec(
select(Agent).where(Agent.openclaw_session_id == gateway.main_session_key)
).first()
if agent:
return agent
if previous_session_key:
agent = session.exec(
select(Agent).where(Agent.openclaw_session_id == previous_session_key)
).first()
if agent:
return agent
names = {_main_agent_name(gateway)}
if previous_name:
names.add(f"{previous_name} Main")
for name in names:
agent = session.exec(select(Agent).where(Agent.name == name)).first()
if agent:
return agent
return None
async def _ensure_main_agent(
session: Session,
gateway: Gateway,
auth: AuthContext,
*,
previous_name: str | None = None,
previous_session_key: str | None = None,
action: str = "provision",
) -> Agent | None:
if not gateway.url or not gateway.main_session_key:
return None
agent = _find_main_agent(session, gateway, previous_name, previous_session_key)
if agent is None:
agent = Agent(
name=_main_agent_name(gateway),
status="provisioning",
board_id=None,
is_board_lead=False,
openclaw_session_id=gateway.main_session_key,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile={
"role": "Main Agent",
"communication_style": "direct, concise, practical",
"emoji": ":compass:",
},
)
session.add(agent)
agent.name = _main_agent_name(gateway)
agent.openclaw_session_id = gateway.main_session_key
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
agent.provision_requested_at = datetime.utcnow()
agent.provision_action = action
agent.updated_at = datetime.utcnow()
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent)
session.commit()
session.refresh(agent)
try:
await provision_main_agent(agent, gateway, raw_token, auth.user, action=action)
await ensure_session(
gateway.main_session_key,
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
label=agent.name,
)
await send_message(
(
f"Hello {agent.name}. Your gateway provisioning was updated.\n\n"
"Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. "
"If BOOTSTRAP.md exists, run it once then delete it. Begin heartbeats after startup."
),
session_key=gateway.main_session_key,
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning.
pass
return agent
async def _send_skyll_enable_message(gateway: Gateway) -> None:
if not gateway.url:
raise OpenClawGatewayError("Gateway url is required")
@@ -278,6 +376,7 @@ async def create_gateway(
session.add(gateway)
session.commit()
session.refresh(gateway)
await _ensure_main_agent(session, gateway, auth, action="provision")
if gateway.skyll_enabled:
try:
await _send_skyll_enable_message(gateway)
@@ -308,6 +407,8 @@ async def update_gateway(
gateway = session.get(Gateway, gateway_id)
if gateway is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
previous_name = gateway.name
previous_session_key = gateway.main_session_key
previous_skyll_enabled = gateway.skyll_enabled
updates = payload.model_dump(exclude_unset=True)
if updates.get("token") == "":
@@ -317,6 +418,14 @@ async def update_gateway(
session.add(gateway)
session.commit()
session.refresh(gateway)
await _ensure_main_agent(
session,
gateway,
auth,
previous_name=previous_name,
previous_session_key=previous_session_key,
action="update",
)
if not previous_skyll_enabled and gateway.skyll_enabled:
try:
await _send_skyll_enable_message(gateway)

View File

@@ -1,16 +1,17 @@
from __future__ import annotations
from datetime import datetime, timezone
import asyncio
import json
import re
from collections import deque
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, delete, desc
from sqlmodel import Session, col, select
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from sqlalchemy import asc, desc
from sqlmodel import Session, col, select
from app.api.deps import (
ActorContext,
@@ -21,9 +22,13 @@ from app.api.deps import (
)
from app.core.auth import AuthContext
from app.db.session import engine, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
@@ -38,6 +43,7 @@ TASK_EVENT_TYPES = {
"task.comment",
}
SSE_SEEN_MAX = 2000
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def validate_task_status(status_value: str) -> None:
@@ -93,14 +99,55 @@ def _parse_since(value: str | None) -> datetime | None:
return parsed
def _extract_mentions(message: str) -> set[str]:
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
if not mentions:
return False
name = (agent.name or "").strip()
if not name:
return False
normalized = name.lower()
if normalized in mentions:
return True
first = normalized.split()[0]
return first in mentions
def _lead_was_mentioned(
session: Session,
task: Task,
lead: Agent,
) -> bool:
statement = (
select(ActivityEvent.message)
.where(col(ActivityEvent.task_id) == task.id)
.where(col(ActivityEvent.event_type) == "task.comment")
.order_by(desc(col(ActivityEvent.created_at)))
)
for message in session.exec(statement):
if not message:
continue
mentions = _extract_mentions(message)
if _matches_mention(lead, mentions):
return True
return False
def _lead_created_task(task: Task, lead: Agent) -> bool:
if not task.auto_created or not task.auto_reason:
return False
return task.auto_reason == f"lead_agent:{lead.id}"
def _fetch_task_events(
board_id: UUID,
since: datetime,
) -> list[tuple[ActivityEvent, Task | None]]:
with Session(engine) as session:
task_ids = list(
session.exec(select(Task.id).where(col(Task.board_id) == board_id))
)
task_ids = list(session.exec(select(Task.id).where(col(Task.board_id) == board_id)))
if not task_ids:
return []
statement = (
@@ -124,6 +171,206 @@ def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
return TaskCommentRead.model_validate(event).model_dump(mode="json")
def _gateway_config(session: Session, board: Board) -> GatewayClientConfig | None:
if not board.gateway_id:
return None
gateway = session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _send_lead_task_message(
*,
session_key: str,
config: GatewayClientConfig,
message: str,
) -> None:
await ensure_session(session_key, config=config, label="Lead Agent")
await send_message(message, session_key=session_key, config=config, deliver=False)
async def _send_agent_task_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=False)
def _notify_agent_on_task_assign(
*,
session: Session,
board: Board,
task: Task,
agent: Agent,
) -> None:
if not agent.openclaw_session_id:
return
config = _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
if len(description) > 500:
description = f"{description[:497]}..."
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
message = (
"TASK ASSIGNED\n"
+ "\n".join(details)
+ "\n\nTake action: open the task and begin work. Post updates as task comments."
)
try:
asyncio.run(
_send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
)
record_activity(
session,
event_type="task.assignee_notified",
message=f"Agent notified for assignment: {agent.name}.",
agent_id=agent.id,
task_id=task.id,
)
session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="task.assignee_notify_failed",
message=f"Assignee notify failed: {exc}",
agent_id=agent.id,
task_id=task.id,
)
session.commit()
def _notify_lead_on_task_create(
*,
session: Session,
board: Board,
task: Task,
) -> None:
lead = session.exec(
select(Agent).where(Agent.board_id == board.id).where(Agent.is_board_lead.is_(True))
).first()
if lead is None or not lead.openclaw_session_id:
return
config = _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
if len(description) > 500:
description = f"{description[:497]}..."
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
message = (
"NEW TASK ADDED\n"
+ "\n".join(details)
+ "\n\nTake action: triage, assign, or plan next steps."
)
try:
asyncio.run(
_send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
)
record_activity(
session,
event_type="task.lead_notified",
message=f"Lead agent notified for task: {task.title}.",
agent_id=lead.id,
task_id=task.id,
)
session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="task.lead_notify_failed",
message=f"Lead notify failed: {exc}",
agent_id=lead.id,
task_id=task.id,
)
session.commit()
def _notify_lead_on_task_unassigned(
*,
session: Session,
board: Board,
task: Task,
) -> None:
lead = session.exec(
select(Agent).where(Agent.board_id == board.id).where(Agent.is_board_lead.is_(True))
).first()
if lead is None or not lead.openclaw_session_id:
return
config = _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
if len(description) > 500:
description = f"{description[:497]}..."
details = [
f"Board: {board.name}",
f"Task: {task.title}",
f"Task ID: {task.id}",
f"Status: {task.status}",
]
if description:
details.append(f"Description: {description}")
message = (
"TASK BACK IN INBOX\n"
+ "\n".join(details)
+ "\n\nTake action: assign a new owner or adjust the plan."
)
try:
asyncio.run(
_send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
)
record_activity(
session,
event_type="task.lead_unassigned_notified",
message=f"Lead notified task returned to inbox: {task.title}.",
agent_id=lead.id,
task_id=task.id,
)
session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="task.lead_unassigned_notify_failed",
message=f"Lead notify failed: {exc}",
agent_id=lead.id,
task_id=task.id,
)
session.commit()
@router.get("/stream")
async def stream_tasks(
request: Request,
@@ -214,6 +461,16 @@ def create_task(
message=f"Task created: {task.title}.",
)
session.commit()
_notify_lead_on_task_create(session=session, board=board, task=task)
if task.assigned_agent_id:
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
_notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return task
@@ -225,10 +482,83 @@ def update_task(
actor: ActorContext = Depends(require_admin_or_agent),
) -> Task:
previous_status = task.status
previous_assigned = task.assigned_agent_id
updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None)
if comment is not None and not comment.strip():
comment = None
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
allowed_fields = {"assigned_agent_id", "status"}
if comment is not None or not set(updates).issubset(allowed_fields):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only assign or unassign tasks.",
)
if "assigned_agent_id" in updates:
assigned_id = updates["assigned_agent_id"]
if assigned_id:
agent = session.get(Agent, assigned_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads cannot assign tasks to themselves.",
)
if agent.board_id and task.board_id and agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
task.assigned_agent_id = agent.id
else:
task.assigned_agent_id = None
if "status" in updates:
validate_task_status(updates["status"])
if task.status != "review":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only change status when a task is in review.",
)
if updates["status"] not in {"done", "inbox"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only move review tasks to done or inbox.",
)
if updates["status"] == "inbox":
task.assigned_agent_id = None
task.in_progress_at = None
task.status = updates["status"]
task.updated_at = datetime.utcnow()
session.add(task)
if task.status != previous_status:
event_type = "task.status_changed"
message = f"Task moved to {task.status}: {task.title}."
else:
event_type = "task.updated"
message = f"Task updated: {task.title}."
record_activity(
session,
event_type=event_type,
task_id=task.id,
message=message,
agent_id=actor.agent.id,
)
session.commit()
session.refresh(task)
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id:
return task
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
board = session.get(Board, task.board_id) if task.board_id else None
if board:
_notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return task
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != task.board_id:
@@ -303,6 +633,28 @@ def update_task(
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.commit()
if task.status == "inbox" and task.assigned_agent_id is None:
if previous_status != "inbox" or previous_assigned is not None:
board = session.get(Board, task.board_id) if task.board_id else None
if board:
_notify_lead_on_task_unassigned(
session=session,
board=board,
task=task,
)
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id:
return task
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
board = session.get(Board, task.board_id) if task.board_id else None
if board:
_notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return task
@@ -312,6 +664,8 @@ def delete_task(
task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]:
session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
session.delete(task)
session.commit()
return {"ok": True}
@@ -343,6 +697,16 @@ def create_task_comment(
actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review":
if not _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
task, actor.agent
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Board leads can only comment during review, when mentioned, or on tasks they created."
),
)
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip():
@@ -356,4 +720,56 @@ def create_task_comment(
session.add(event)
session.commit()
session.refresh(event)
mention_names = _extract_mentions(payload.message)
targets: dict[UUID, Agent] = {}
if mention_names and task.board_id:
statement = select(Agent).where(col(Agent.board_id) == task.board_id)
for agent in session.exec(statement):
if _matches_mention(agent, mention_names):
targets[agent.id] = agent
if not mention_names and task.assigned_agent_id:
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
targets[assigned_agent.id] = assigned_agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(actor.agent.id, None)
if targets:
board = session.get(Board, task.board_id) if task.board_id else None
config = _gateway_config(session, board) if board else None
if board and config:
snippet = payload.message.strip()
if len(snippet) > 500:
snippet = f"{snippet[:497]}..."
actor_name = actor.agent.name if actor.actor_type == "agent" and actor.agent else "User"
for agent in targets.values():
if not agent.openclaw_session_id:
continue
mentioned = _matches_mention(agent, mention_names)
header = "TASK MENTION" if mentioned else "NEW TASK COMMENT"
action_line = (
"You were mentioned in this comment."
if mentioned
else "A new comment was posted on your task."
)
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Task: {task.title}\n"
f"Task ID: {task.id}\n"
f"From: {actor_name}\n\n"
f"{action_line}\n\n"
f"Comment:\n{snippet}\n\n"
"If you are mentioned but not assigned, reply in the task thread but do not change task status."
)
try:
asyncio.run(
_send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
)
except OpenClawGatewayError:
pass
return event

View File

@@ -1,15 +1,18 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Literal
from fastapi import Depends, Header, HTTPException, status
from fastapi import Depends, Header, HTTPException, Request, status
from sqlmodel import Session, col, select
from app.core.agent_tokens import verify_agent_token
from app.db.session import get_session
from app.models.agents import Agent
logger = logging.getLogger(__name__)
@dataclass
class AgentAuthContext:
@@ -25,25 +28,78 @@ def _find_agent_for_token(session: Session, token: str) -> Agent | None:
return None
def _resolve_agent_token(
agent_token: str | None,
authorization: str | None,
*,
accept_authorization: bool = True,
) -> str | None:
if agent_token:
return agent_token
if not accept_authorization:
return None
if not authorization:
return None
value = authorization.strip()
if not value:
return None
if value.lower().startswith("bearer "):
return value.split(" ", 1)[1].strip() or None
return None
def get_agent_auth_context(
request: Request,
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
authorization: str | None = Header(default=None, alias="Authorization"),
session: Session = Depends(get_session),
) -> AgentAuthContext:
if not agent_token:
resolved = _resolve_agent_token(agent_token, authorization, accept_authorization=True)
if not resolved:
logger.warning(
"agent auth missing token path=%s x_agent=%s authorization=%s",
request.url.path,
bool(agent_token),
bool(authorization),
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
agent = _find_agent_for_token(session, agent_token)
agent = _find_agent_for_token(session, resolved)
if agent is None:
logger.warning(
"agent auth invalid token path=%s token_prefix=%s",
request.url.path,
resolved[:6],
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return AgentAuthContext(actor_type="agent", agent=agent)
def get_agent_auth_context_optional(
request: Request,
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
authorization: str | None = Header(default=None, alias="Authorization"),
session: Session = Depends(get_session),
) -> AgentAuthContext | None:
if not agent_token:
resolved = _resolve_agent_token(
agent_token,
authorization,
accept_authorization=False,
)
if not resolved:
if agent_token:
logger.warning(
"agent auth optional missing token path=%s x_agent=%s authorization=%s",
request.url.path,
bool(agent_token),
bool(authorization),
)
return None
agent = _find_agent_for_token(session, agent_token)
agent = _find_agent_for_token(session, resolved)
if agent is None:
logger.warning(
"agent auth optional invalid token path=%s token_prefix=%s",
request.url.path,
resolved[:6],
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return AgentAuthContext(actor_type="agent", agent=agent)

View File

@@ -112,17 +112,17 @@ async def get_auth_context_optional(
clerk_credentials = await guard(request)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
except HTTPException as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
except HTTPException:
return None
auth_data = _resolve_clerk_auth(request, clerk_credentials)
try:
clerk_user_id = _parse_subject(auth_data)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
except ValidationError:
return None
if not clerk_user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return None
user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first()
if user is None:

View File

@@ -31,5 +31,4 @@ class Settings(BaseSettings):
log_use_utc: bool = False
settings = Settings()

View File

@@ -4,8 +4,12 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.activity import router as activity_router
from app.api.agent import router as agent_router
from app.api.agents import router as agents_router
from app.api.approvals import router as approvals_router
from app.api.auth import router as auth_router
from app.api.board_memory import router as board_memory_router
from app.api.board_onboarding import router as board_onboarding_router
from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router
@@ -53,12 +57,16 @@ def readyz() -> dict[str, bool]:
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(auth_router)
api_v1.include_router(agent_router)
api_v1.include_router(agents_router)
api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)
api_v1.include_router(boards_router)
api_v1.include_router(board_memory_router)
api_v1.include_router(board_onboarding_router)
api_v1.include_router(approvals_router)
api_v1.include_router(tasks_router)
api_v1.include_router(users_router)
app.include_router(api_v1)

View File

@@ -1,15 +1,23 @@
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
from app.models.users import User
__all__ = [
"ActivityEvent",
"Agent",
"Approval",
"BoardMemory",
"BoardOnboardingSession",
"Board",
"Gateway",
"Task",
"TaskFingerprint",
"User",
]

View File

@@ -27,5 +27,6 @@ class Agent(SQLModel, table=True):
delete_requested_at: datetime | None = Field(default=None)
delete_confirm_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime | None = Field(default=None)
is_board_lead: bool = Field(default=False, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
class Approval(SQLModel, table=True):
__tablename__ = "approvals"
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
action_type: str
payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
confidence: int
rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON))
status: str = Field(default="pending", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
resolved_at: datetime | None = None

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
class BoardMemory(SQLModel, table=True):
__tablename__ = "board_memory"
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
content: str
tags: list[str] | None = Field(default=None, sa_column=Column(JSON))
source: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
class BoardOnboardingSession(SQLModel, table=True):
__tablename__ = "board_onboarding_sessions"
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
session_key: str
status: str = Field(default="active", index=True)
messages: list[dict[str, object]] | None = Field(default=None, sa_column=Column(JSON))
draft_goal: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field
from app.models.tenancy import TenantScoped
@@ -15,5 +16,11 @@ class Board(TenantScoped, table=True):
name: str
slug: str = Field(index=True)
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
board_type: str = Field(default="goal", index=True)
objective: str | None = None
success_metrics: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
target_date: datetime | None = None
goal_confirmed: bool = Field(default=False)
goal_source: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlmodel import Field, SQLModel
class TaskFingerprint(SQLModel, table=True):
__tablename__ = "task_fingerprints"
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
fingerprint_hash: str = Field(index=True)
task_id: UUID = Field(foreign_key="tasks.id")
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -23,6 +23,8 @@ class Task(TenantScoped, table=True):
created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
auto_created: bool = Field(default=False)
auto_reason: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -1,5 +1,13 @@
from app.schemas.activity_events import ActivityEventRead
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import (
BoardOnboardingAnswer,
BoardOnboardingConfirm,
BoardOnboardingRead,
BoardOnboardingStart,
)
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.schemas.metrics import DashboardMetrics
@@ -11,6 +19,15 @@ __all__ = [
"AgentCreate",
"AgentRead",
"AgentUpdate",
"ApprovalCreate",
"ApprovalRead",
"ApprovalUpdate",
"BoardMemoryCreate",
"BoardMemoryRead",
"BoardOnboardingAnswer",
"BoardOnboardingConfirm",
"BoardOnboardingRead",
"BoardOnboardingStart",
"BoardCreate",
"BoardRead",
"BoardUpdate",

View File

@@ -23,6 +23,7 @@ class AgentCreate(AgentBase):
class AgentUpdate(SQLModel):
board_id: UUID | None = None
is_gateway_main: bool | None = None
name: str | None = None
status: str | None = None
heartbeat_config: dict[str, Any] | None = None
@@ -33,6 +34,8 @@ class AgentUpdate(SQLModel):
class AgentRead(AgentBase):
id: UUID
is_board_lead: bool = False
is_gateway_main: bool = False
openclaw_session_id: str | None = None
last_seen_at: datetime | None
created_at: datetime
@@ -46,3 +49,7 @@ class AgentHeartbeat(SQLModel):
class AgentHeartbeatCreate(AgentHeartbeat):
name: str
board_id: UUID | None = None
class AgentNudge(SQLModel):
message: str

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel
class ApprovalBase(SQLModel):
action_type: str
payload: dict[str, object] | None = None
confidence: int
rubric_scores: dict[str, int] | None = None
status: str = "pending"
class ApprovalCreate(ApprovalBase):
agent_id: UUID | None = None
class ApprovalUpdate(SQLModel):
status: str | None = None
class ApprovalRead(ApprovalBase):
id: UUID
board_id: UUID
agent_id: UUID | None = None
created_at: datetime
resolved_at: datetime | None = None

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel
class BoardMemoryCreate(SQLModel):
content: str
tags: list[str] | None = None
source: str | None = None
class BoardMemoryRead(BoardMemoryCreate):
id: UUID
board_id: UUID
created_at: datetime

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import model_validator
from sqlmodel import SQLModel
class BoardOnboardingStart(SQLModel):
pass
class BoardOnboardingAnswer(SQLModel):
answer: str
other_text: str | None = None
class BoardOnboardingConfirm(SQLModel):
board_type: str
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
@model_validator(mode="after")
def validate_goal_fields(self):
if self.board_type == "goal":
if not self.objective or not self.success_metrics:
raise ValueError("Confirmed goal boards require objective and success_metrics")
return self
class BoardOnboardingRead(SQLModel):
id: UUID
board_id: UUID
session_key: str
status: str
messages: list[dict[str, object]] | None = None
draft_goal: dict[str, object] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import model_validator
from sqlmodel import SQLModel
@@ -10,16 +11,33 @@ class BoardBase(SQLModel):
name: str
slug: str
gateway_id: UUID | None = None
board_type: str = "goal"
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
goal_confirmed: bool = False
goal_source: str | None = None
class BoardCreate(BoardBase):
pass
@model_validator(mode="after")
def validate_goal_fields(self):
if self.board_type == "goal" and self.goal_confirmed:
if not self.objective or not self.success_metrics:
raise ValueError("Confirmed goal boards require objective and success_metrics")
return self
class BoardUpdate(SQLModel):
name: str | None = None
slug: str | None = None
gateway_id: UUID | None = None
board_type: str | None = None
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
goal_confirmed: bool | None = None
goal_source: str | None = None
class BoardRead(BoardBase):

View File

@@ -37,11 +37,22 @@ DEFAULT_GATEWAY_FILES = frozenset(
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOT.md",
"BOOTSTRAP.md",
"MEMORY.md",
}
)
HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md"
HEARTBEAT_AGENT_TEMPLATE = "HEARTBEAT_AGENT.md"
MAIN_TEMPLATE_MAP = {
"AGENTS.md": "MAIN_AGENTS.md",
"HEARTBEAT.md": "MAIN_HEARTBEAT.md",
"USER.md": "MAIN_USER.md",
"BOOT.md": "MAIN_BOOT.md",
"TOOLS.md": "MAIN_TOOLS.md",
}
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
@@ -80,6 +91,10 @@ def _template_env() -> Environment:
)
def _heartbeat_template_name(agent: Agent) -> str:
return HEARTBEAT_LEAD_TEMPLATE if agent.is_board_lead else HEARTBEAT_AGENT_TEMPLATE
def _workspace_path(agent_name: str, workspace_root: str) -> str:
if not workspace_root:
raise ValueError("gateway_workspace_root is required")
@@ -125,10 +140,20 @@ def _build_context(
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
}
preferred_name = (user.preferred_name or "") if user else ""
if preferred_name:
preferred_name = preferred_name.strip().split()[0]
return {
"agent_name": agent.name,
"agent_id": agent_id,
"board_id": str(board.id),
"board_name": board.name,
"board_type": board.board_type,
"board_objective": board.objective or "",
"board_success_metrics": json.dumps(board.success_metrics or {}),
"board_target_date": board.target_date.isoformat() if board.target_date else "",
"board_goal_confirmed": str(board.goal_confirmed).lower(),
"is_board_lead": str(agent.is_board_lead).lower(),
"session_key": session_key,
"workspace_path": workspace_path,
"base_url": base_url,
@@ -136,7 +161,55 @@ def _build_context(
"main_session_key": main_session_key,
"workspace_root": workspace_root,
"user_name": (user.name or "") if user else "",
"user_preferred_name": (user.preferred_name or "") if user else "",
"user_preferred_name": preferred_name,
"user_pronouns": (user.pronouns or "") if user else "",
"user_timezone": (user.timezone or "") if user else "",
"user_notes": (user.notes or "") if user else "",
"user_context": (user.context or "") if user else "",
**identity_context,
}
def _build_main_context(
agent: Agent,
gateway: Gateway,
auth_token: str,
user: User | None,
) -> dict[str, str]:
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
identity_profile: dict[str, Any] = {}
if isinstance(agent.identity_profile, dict):
identity_profile = agent.identity_profile
normalized_identity: dict[str, str] = {}
for key, value in identity_profile.items():
if value is None:
continue
if isinstance(value, list):
parts = [str(item).strip() for item in value if str(item).strip()]
if not parts:
continue
normalized_identity[key] = ", ".join(parts)
continue
text = str(value).strip()
if text:
normalized_identity[key] = text
identity_context = {
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
}
preferred_name = (user.preferred_name or "") if user else ""
if preferred_name:
preferred_name = preferred_name.strip().split()[0]
return {
"agent_name": agent.name,
"agent_id": str(agent.id),
"session_key": agent.openclaw_session_id or "",
"base_url": base_url,
"auth_token": auth_token,
"main_session_key": gateway.main_session_key or "",
"workspace_root": gateway.workspace_root or "",
"user_name": (user.name or "") if user else "",
"user_preferred_name": preferred_name,
"user_pronouns": (user.pronouns or "") if user else "",
"user_timezone": (user.timezone or "") if user else "",
"user_notes": (user.notes or "") if user else "",
@@ -174,6 +247,12 @@ async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]:
return set(DEFAULT_GATEWAY_FILES)
async def _reset_session(session_key: str, config: GatewayClientConfig) -> None:
if not session_key:
return
await openclaw_call("sessions.reset", {"key": session_key}, config=config)
async def _gateway_agent_files_index(
agent_id: str, config: GatewayClientConfig
) -> dict[str, dict[str, Any]]:
@@ -197,6 +276,7 @@ def _render_agent_files(
file_names: set[str],
*,
include_bootstrap: bool,
template_overrides: dict[str, str] | None = None,
) -> dict[str, str]:
env = _template_env()
overrides: dict[str, str] = {}
@@ -212,18 +292,53 @@ def _render_agent_files(
if name == "MEMORY.md":
rendered[name] = "# MEMORY\n\nBootstrap pending.\n"
continue
if name == "HEARTBEAT.md":
heartbeat_template = (
template_overrides.get(name)
if template_overrides and name in template_overrides
else _heartbeat_template_name(agent)
)
heartbeat_path = _templates_root() / heartbeat_template
if heartbeat_path.exists():
rendered[name] = env.get_template(heartbeat_template).render(**context).strip()
continue
override = overrides.get(name)
if override:
rendered[name] = env.from_string(override).render(**context).strip()
continue
path = _templates_root() / name
template_name = (
template_overrides.get(name)
if template_overrides and name in template_overrides
else name
)
path = _templates_root() / template_name
if path.exists():
rendered[name] = env.get_template(name).render(**context).strip()
rendered[name] = env.get_template(template_name).render(**context).strip()
continue
rendered[name] = ""
return rendered
async def _gateway_default_agent_id(
config: GatewayClientConfig,
) -> str | None:
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
return None
if not isinstance(payload, dict):
return None
default_id = payload.get("defaultId") or payload.get("default_id")
if default_id:
return default_id
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
return first.get("id")
return None
async def _patch_gateway_agent_list(
agent_id: str,
workspace_path: str,
@@ -279,7 +394,9 @@ async def _remove_gateway_agent_list(
if not isinstance(lst, list):
raise OpenClawGatewayError("config agents.list is not a list")
new_list = [entry for entry in lst if not (isinstance(entry, dict) and entry.get("id") == agent_id)]
new_list = [
entry for entry in lst if not (isinstance(entry, dict) and entry.get("id") == agent_id)
]
if len(new_list) == len(lst):
return
patch = {"agents": {"list": new_list}}
@@ -317,6 +434,8 @@ async def provision_agent(
user: User | None,
*,
action: str = "provision",
force_bootstrap: bool = False,
reset_session: bool = False,
) -> None:
if not gateway.url:
return
@@ -332,10 +451,11 @@ async def provision_agent(
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
context = _build_context(agent, board, gateway, auth_token, user)
supported = await _supported_gateway_files(client_config)
supported = set(await _supported_gateway_files(client_config))
supported.add("USER.md")
existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = True
if action == "update":
if action == "update" and not force_bootstrap:
if not existing_files:
include_bootstrap = False
else:
@@ -357,6 +477,61 @@ async def provision_agent(
{"agentId": agent_id, "name": name, "content": content},
config=client_config,
)
if reset_session:
await _reset_session(session_key, client_config)
async def provision_main_agent(
agent: Agent,
gateway: Gateway,
auth_token: str,
user: User | None,
*,
action: str = "provision",
force_bootstrap: bool = False,
reset_session: bool = False,
) -> None:
if not gateway.url:
return
if not gateway.main_session_key:
raise ValueError("gateway main_session_key is required")
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent")
agent_id = await _gateway_default_agent_id(client_config)
if not agent_id:
raise OpenClawGatewayError("Unable to resolve gateway main agent id")
context = _build_main_context(agent, gateway, auth_token, user)
supported = set(await _supported_gateway_files(client_config))
supported.add("USER.md")
existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = action != "update" or force_bootstrap
if action == "update" and not force_bootstrap:
if not existing_files:
include_bootstrap = False
else:
entry = existing_files.get("BOOTSTRAP.md")
if entry and entry.get("missing") is True:
include_bootstrap = False
rendered = _render_agent_files(
context,
agent,
supported,
include_bootstrap=include_bootstrap,
template_overrides=MAIN_TEMPLATE_MAP,
)
for name, content in rendered.items():
if content == "":
continue
await openclaw_call(
"agents.files.set",
{"agentId": agent_id, "name": name, "content": content},
config=client_config,
)
if reset_session:
await _reset_session(gateway.main_session_key, client_config)
async def cleanup_agent(

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import hashlib
from typing import Mapping
CONFIDENCE_THRESHOLD = 80
def compute_confidence(rubric_scores: Mapping[str, int]) -> int:
return int(sum(rubric_scores.values()))
def approval_required(*, confidence: int, is_external: bool, is_risky: bool) -> bool:
return is_external or is_risky or confidence < CONFIDENCE_THRESHOLD
def infer_planning(signals: Mapping[str, bool]) -> bool:
# Require at least two planning signals to avoid spam on general boards.
truthy = [key for key, value in signals.items() if value]
return len(truthy) >= 2
def task_fingerprint(title: str, description: str | None, board_id: str) -> str:
normalized_title = title.strip().lower()
normalized_desc = (description or "").strip().lower()
seed = f"{board_id}::{normalized_title}::{normalized_desc}"
return hashlib.sha256(seed.encode()).hexdigest()

View File

@@ -25,6 +25,7 @@ dependencies = [
"redis==5.1.1",
"fastapi-clerk-auth==0.0.9",
"sse-starlette==2.1.3",
"jinja2"
]
[project.optional-dependencies]

View File

@@ -0,0 +1,6 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

View File

@@ -0,0 +1,50 @@
import pytest
from app.schemas.board_onboarding import BoardOnboardingConfirm
from app.schemas.boards import BoardCreate
def test_goal_board_requires_objective_and_metrics_when_confirmed():
with pytest.raises(ValueError):
BoardCreate(
name="Goal Board",
slug="goal",
board_type="goal",
goal_confirmed=True,
)
BoardCreate(
name="Goal Board",
slug="goal",
board_type="goal",
goal_confirmed=True,
objective="Launch onboarding",
success_metrics={"emails": 3},
)
def test_goal_board_allows_missing_objective_before_confirmation():
BoardCreate(name="Draft", slug="draft", board_type="goal")
def test_general_board_allows_missing_objective():
BoardCreate(name="General", slug="general", board_type="general")
def test_onboarding_confirm_requires_goal_fields():
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal")
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal", objective="Ship onboarding")
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal", success_metrics={"emails": 3})
BoardOnboardingConfirm(
board_type="goal",
objective="Ship onboarding",
success_metrics={"emails": 3},
)
BoardOnboardingConfirm(board_type="general")

View File

@@ -0,0 +1,50 @@
import hashlib
from app.services.lead_policy import (
approval_required,
compute_confidence,
infer_planning,
task_fingerprint,
)
def test_compute_confidence_sums_weights():
rubric = {
"clarity": 20,
"constraints": 15,
"completeness": 10,
"risk": 20,
"dependencies": 10,
"similarity": 5,
}
assert compute_confidence(rubric) == 80
def test_approval_required_for_low_confidence():
assert approval_required(confidence=79, is_external=False, is_risky=False)
assert not approval_required(confidence=85, is_external=False, is_risky=False)
def test_approval_required_for_external_or_risky():
assert approval_required(confidence=90, is_external=True, is_risky=False)
assert approval_required(confidence=90, is_external=False, is_risky=True)
def test_infer_planning_requires_signal_threshold():
signals = {
"goal_gap": True,
"recent_ambiguity": False,
"research_only": False,
"stalled_inbox": False,
}
assert infer_planning(signals) is False
signals["recent_ambiguity"] = True
assert infer_planning(signals) is True
def test_task_fingerprint_deterministic():
fp1 = task_fingerprint("Title", "Desc", "board-1")
fp2 = task_fingerprint("Title", "Desc", "board-1")
assert fp1 == fp2
assert fp1 == hashlib.sha256("board-1::title::desc".encode()).hexdigest()

14
backend/uv.lock generated
View File

@@ -325,6 +325,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@@ -444,6 +456,7 @@ dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "fastapi-clerk-auth" },
{ name = "jinja2" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
@@ -475,6 +488,7 @@ requires-dist = [
{ name = "fastapi-clerk-auth", specifier = "==0.0.9" },
{ name = "flake8", marker = "extra == 'dev'", specifier = "==7.1.1" },
{ name = "isort", marker = "extra == 'dev'", specifier = "==5.13.2" },
{ name = "jinja2" },
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.11.2" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.1" },
{ name = "pydantic-settings", specifier = "==2.5.2" },

View File

@@ -1,3 +0,0 @@
tailwind.config.*
postcss.config.*
orval.config.*

View File

@@ -2,10 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.1.101",
"http://192.168.1.101:3000",
"192.168.1.101",
],
images: {
remotePatterns: [

View File

@@ -32,6 +32,7 @@ type Agent = {
id: string;
name: string;
board_id?: string | null;
is_gateway_main?: boolean;
heartbeat_config?: {
every?: string;
target?: string;
@@ -109,6 +110,8 @@ export default function EditAgentPage() {
const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState("");
const [boardTouched, setBoardTouched] = useState(false);
const [isGatewayMain, setIsGatewayMain] = useState(false);
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [identityProfile, setIdentityProfile] = useState<IdentityProfile>({
@@ -150,9 +153,13 @@ export default function EditAgentPage() {
const data = (await response.json()) as Agent;
setAgent(data);
setName(data.name);
if (data.board_id) {
setIsGatewayMain(Boolean(data.is_gateway_main));
if (!data.is_gateway_main && data.board_id) {
setBoardId(data.board_id);
} else {
setBoardId("");
}
setBoardTouched(false);
if (data.heartbeat_config?.every) {
setHeartbeatEvery(data.heartbeat_config.every);
}
@@ -175,7 +182,7 @@ export default function EditAgentPage() {
}, [isSignedIn, agentId]);
useEffect(() => {
if (boardId) return;
if (boardTouched || boardId || isGatewayMain) return;
if (agent?.board_id) {
setBoardId(agent.board_id);
return;
@@ -183,7 +190,7 @@ export default function EditAgentPage() {
if (boards.length > 0) {
setBoardId(boards[0].id);
}
}, [agent, boards, boardId]);
}, [agent, boards, boardId, isGatewayMain, boardTouched]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -193,31 +200,48 @@ export default function EditAgentPage() {
setError("Agent name is required.");
return;
}
if (!boardId) {
setError("Select a board before saving.");
if (!isGatewayMain && !boardId) {
setError("Select a board or mark this agent as the gateway main.");
return;
}
if (isGatewayMain && !boardId && !agent?.is_gateway_main && !agent?.board_id) {
setError(
"Select a board once so we can resolve the gateway main session key."
);
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
const payload: Record<string, unknown> = {
name: trimmed,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
},
body: JSON.stringify({
name: trimmed,
board_id: boardId,
heartbeat_config: {
every: heartbeatEvery.trim() || "10m",
target: heartbeatTarget,
identity_profile: normalizeIdentityProfile(identityProfile),
soul_template: soulTemplate.trim() || null,
};
if (!isGatewayMain) {
payload.board_id = boardId || null;
} else if (boardId) {
payload.board_id = boardId;
}
if (agent?.is_gateway_main !== isGatewayMain) {
payload.is_gateway_main = isGatewayMain;
}
const response = await fetch(
`${apiBase}/api/v1/agents/${agentId}?force=true`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
identity_profile: normalizeIdentityProfile(identityProfile),
soul_template: soulTemplate.trim() || null,
}),
});
body: JSON.stringify(payload),
}
);
if (!response.ok) {
throw new Error("Unable to update agent.");
}
@@ -300,15 +324,40 @@ export default function EditAgentPage() {
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
</label>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-900">
Board
{isGatewayMain ? (
<span className="ml-2 text-xs font-normal text-slate-500">
optional
</span>
) : (
<span className="text-red-500"> *</span>
)}
</label>
{boardId ? (
<button
type="button"
className="text-xs font-medium text-slate-600 hover:text-slate-900"
onClick={() => {
setBoardTouched(true);
setBoardId("");
}}
disabled={isLoading}
>
Clear board
</button>
) : null}
</div>
<SearchableSelect
ariaLabel="Select board"
value={boardId}
onValueChange={setBoardId}
onValueChange={(value) => {
setBoardTouched(true);
setBoardId(value);
}}
options={getBoardOptions(boards)}
placeholder="Select board"
placeholder={isGatewayMain ? "No board (main agent)" : "Select board"}
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
@@ -316,7 +365,13 @@ export default function EditAgentPage() {
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{boards.length === 0 ? (
{isGatewayMain ? (
<p className="text-xs text-slate-500">
Main agents are not attached to a board. If a board is
selected, it is only used to resolve the gateway main
session key and will be cleared on save.
</p>
) : boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before assigning agents.
</p>
@@ -350,6 +405,26 @@ export default function EditAgentPage() {
</div>
</div>
</div>
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<label className="flex items-start gap-3 text-sm text-slate-700">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
checked={isGatewayMain}
onChange={(event) => setIsGatewayMain(event.target.checked)}
disabled={isLoading}
/>
<span>
<span className="block font-medium text-slate-900">
Gateway main agent
</span>
<span className="block text-xs text-slate-500">
Uses the gateway main session key and is not tied to a
single board.
</span>
</span>
</label>
</div>
</div>
<div>

View File

@@ -31,6 +31,8 @@ type Agent = {
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
is_gateway_main?: boolean;
};
type Board = {
@@ -102,9 +104,10 @@ export default function AgentDetailPage() {
return events.filter((event) => event.agent_id === agent.id);
}, [events, agent]);
const linkedBoard = useMemo(() => {
if (!agent?.board_id) return null;
if (!agent?.board_id || agent?.is_gateway_main) return null;
return boards.find((board) => board.id === agent.board_id) ?? null;
}, [boards, agent?.board_id]);
}, [boards, agent?.board_id, agent?.is_gateway_main]);
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
@@ -265,7 +268,9 @@ export default function AgentDetailPage() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{linkedBoard ? (
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">Gateway main (no board)</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"

View File

@@ -38,6 +38,7 @@ type Agent = {
created_at: string;
updated_at: string;
board_id?: string | null;
is_board_lead?: boolean;
};
type Board = {
@@ -138,6 +139,7 @@ export default function AgentsPage() {
const sortedAgents = useMemo(() => [...agents], [agents]);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget);

View File

@@ -0,0 +1,45 @@
"use client";
import { useParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
export default function BoardApprovalsPage() {
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">Sign in to view approvals.</p>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
<div className="p-6">
{boardId ? (
<div className="h-[calc(100vh-160px)] min-h-[520px]">
<BoardApprovalsPanel boardId={boardId} scrollable />
</div>
) : null}
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -5,11 +5,23 @@ import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
@@ -19,6 +31,10 @@ type Board = {
name: string;
slug: string;
gateway_id?: string | null;
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
};
type Gateway = {
@@ -36,6 +52,13 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
const toDateInput = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toISOString().slice(0, 10);
};
export default function EditBoardPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
@@ -47,9 +70,15 @@ export default function EditBoardPage() {
const [name, setName] = useState("");
const [gateways, setGateways] = useState<Gateway[]>([]);
const [gatewayId, setGatewayId] = useState<string>("");
const [boardType, setBoardType] = useState("goal");
const [objective, setObjective] = useState("");
const [successMetrics, setSuccessMetrics] = useState("");
const [targetDate, setTargetDate] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const isFormReady = Boolean(name.trim() && gatewayId);
@@ -88,11 +117,28 @@ export default function EditBoardPage() {
if (data.gateway_id) {
setGatewayId(data.gateway_id);
}
setBoardType(data.board_type ?? "goal");
setObjective(data.objective ?? "");
setSuccessMetrics(
data.success_metrics ? JSON.stringify(data.success_metrics, null, 2) : ""
);
setTargetDate(toDateInput(data.target_date));
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const handleOnboardingConfirmed = (updated: Board) => {
setBoard(updated);
setBoardType(updated.board_type ?? "goal");
setObjective(updated.objective ?? "");
setSuccessMetrics(
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : ""
);
setTargetDate(toDateInput(updated.target_date));
setIsOnboardingOpen(false);
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -126,8 +172,19 @@ export default function EditBoardPage() {
setIsLoading(true);
setError(null);
setMetricsError(null);
try {
const token = await getToken();
let parsedMetrics: Record<string, unknown> | null = null;
if (successMetrics.trim()) {
try {
parsedMetrics = JSON.parse(successMetrics) as Record<string, unknown>;
} catch {
setMetricsError("Success metrics must be valid JSON.");
setIsLoading(false);
return;
}
}
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, {
method: "PATCH",
@@ -139,6 +196,10 @@ export default function EditBoardPage() {
name: name.trim(),
slug: slugify(name.trim()),
gateway_id: gatewayId || null,
board_type: boardType,
objective: objective.trim() || null,
success_metrics: parsedMetrics,
target_date: targetDate ? new Date(targetDate).toISOString() : null,
}),
});
if (!response.ok) {
@@ -154,7 +215,8 @@ export default function EditBoardPage() {
};
return (
<DashboardShell>
<>
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
@@ -184,66 +246,152 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<BoardGoalPanel
board={board}
onStartOnboarding={() => setIsOnboardingOpen(true)}
/>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !board}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={boardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={successMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !board || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</form>
<div className="space-y-6">
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</div>
</div>
</div>
</main>
</SignedIn>
</DashboardShell>
</DashboardShell>
<Dialog open={isOnboardingOpen} onOpenChange={setIsOnboardingOpen}>
<DialogContent aria-label="Board onboarding">
{boardId ? (
<BoardOnboardingChat
boardId={boardId}
onConfirmed={handleOnboardingConfirmed}
/>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
Unable to start onboarding.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ type Agent = {
board_id?: string | null;
last_seen_at?: string | null;
updated_at: string;
is_board_lead?: boolean;
};
type GatewayStatus = {

View File

@@ -0,0 +1,685 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@clerk/nextjs";
import { Clock } from "lucide-react";
import { Cell, Pie, PieChart } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipCard,
type ChartConfig,
} from "@/components/charts/chart";
import { Button } from "@/components/ui/button";
import { getApiBaseUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
const apiBase = getApiBaseUrl();
type Approval = {
id: string;
action_type: string;
payload?: Record<string, unknown> | null;
confidence: number;
rubric_scores?: Record<string, number> | null;
status: string;
created_at: string;
resolved_at?: string | null;
};
type BoardApprovalsPanelProps = {
boardId: string;
approvals?: Approval[];
isLoading?: boolean;
error?: string | null;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
scrollable?: boolean;
};
const formatTimestamp = (value?: string | null) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const statusBadgeClass = (status: string) => {
if (status === "approved") {
return "bg-emerald-50 text-emerald-700";
}
if (status === "rejected") {
return "bg-rose-50 text-rose-700";
}
return "bg-amber-100 text-amber-700";
};
const confidenceBadgeClass = (confidence: number) => {
if (confidence >= 90) {
return "bg-emerald-50 text-emerald-700";
}
if (confidence >= 80) {
return "bg-amber-100 text-amber-700";
}
return "bg-orange-100 text-orange-700";
};
const humanizeAction = (value: string) =>
value
.split(".")
.map((part) =>
part
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
)
.join(" · ");
const formatStatusLabel = (status: string) =>
status
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
const statusDotClass = (status: string) => {
if (status === "approved") return "bg-emerald-500";
if (status === "rejected") return "bg-rose-500";
return "bg-amber-500";
};
const rubricColors = [
"#0f172a",
"#1d4ed8",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
];
type TooltipValue = number | string | Array<number | string>;
const formatRubricTooltipValue = (
value?: TooltipValue,
name?: TooltipValue,
item?:
| {
color?: string | null;
payload?: {
name?: string;
fill?: string;
percent?: number;
percentLabel?: string;
};
}
| null,
) => {
const payload = item?.payload;
const label =
payload?.name ??
(typeof name === "string" || typeof name === "number" ? String(name) : "");
const percentLabel =
payload?.percentLabel ??
(typeof payload?.percent === "number" && Number.isFinite(payload.percent)
? `${payload.percent.toFixed(1)}%`
: null);
const fallback =
value === null || value === undefined ? "" : String(value ?? "");
const displayValue = percentLabel ?? fallback;
const indicatorColor = payload?.fill ?? item?.color ?? "#94a3b8";
return (
<div className="flex w-full items-center justify-between gap-3">
<span className="flex items-center gap-2 text-slate-600">
<span
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: indicatorColor }}
/>
<span>{label}</span>
</span>
<span className="font-mono font-medium tabular-nums text-slate-900">
{displayValue}
</span>
</div>
);
};
const payloadValue = (payload: Approval["payload"], key: string) => {
if (!payload) return null;
const value = payload[key as keyof typeof payload];
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return null;
};
const approvalSummary = (approval: Approval) => {
const payload = approval.payload ?? {};
const taskId =
payloadValue(payload, "task_id") ??
payloadValue(payload, "taskId") ??
payloadValue(payload, "taskID");
const assignedAgentId =
payloadValue(payload, "assigned_agent_id") ??
payloadValue(payload, "assignedAgentId");
const reason = payloadValue(payload, "reason");
const title = payloadValue(payload, "title");
const description = payloadValue(payload, "description");
const role = payloadValue(payload, "role");
const isAssign = approval.action_type.includes("assign");
const rows: Array<{ label: string; value: string }> = [];
if (taskId) rows.push({ label: "Task", value: taskId });
if (isAssign) {
rows.push({
label: "Assignee",
value: assignedAgentId ?? "Unassigned",
});
}
if (title) rows.push({ label: "Title", value: title });
if (role) rows.push({ label: "Role", value: role });
return { taskId, reason, rows, description };
};
export function BoardApprovalsPanel({
boardId,
approvals: externalApprovals,
isLoading: externalLoading,
error: externalError,
onDecision,
scrollable = false,
}: BoardApprovalsPanelProps) {
const { getToken, isSignedIn } = useAuth();
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const lastDecisionRef = useRef<string | null>(null);
const usingExternal = Array.isArray(externalApprovals);
const approvals = useMemo(
() => (usingExternal ? externalApprovals ?? [] : internalApprovals),
[externalApprovals, internalApprovals, usingExternal],
);
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
const errorState = usingExternal ? externalError ?? null : error;
const loadApprovals = useCallback(async () => {
if (usingExternal) return;
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const res = await fetch(`${apiBase}/api/v1/boards/${boardId}/approvals`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!res.ok) throw new Error("Unable to load approvals.");
const data = (await res.json()) as Approval[];
setInternalApprovals(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load approvals.");
} finally {
setIsLoading(false);
}
}, [boardId, getToken, isSignedIn, usingExternal]);
useEffect(() => {
if (usingExternal) return;
loadApprovals();
if (!isSignedIn || !boardId) return;
const interval = setInterval(loadApprovals, 15000);
return () => clearInterval(interval);
}, [boardId, isSignedIn, loadApprovals, usingExternal]);
const handleDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => {
lastDecisionRef.current = approvalId;
if (onDecision) {
onDecision(approvalId, status);
return;
}
if (usingExternal) return;
if (!isSignedIn || !boardId) return;
setUpdatingId(approvalId);
setError(null);
try {
const token = await getToken();
const res = await fetch(
`${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ status }),
}
);
if (!res.ok) throw new Error("Unable to update approval.");
const updated = (await res.json()) as Approval;
setInternalApprovals((prev) =>
prev.map((item) => (item.id === approvalId ? updated : item))
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to update approval."
);
} finally {
setUpdatingId(null);
}
},
[boardId, getToken, isSignedIn, onDecision, usingExternal]
);
const sortedApprovals = useMemo(() => {
const sortByTime = (items: Approval[]) =>
[...items].sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return bTime - aTime;
});
const pending = sortByTime(
approvals.filter((item) => item.status === "pending")
);
const resolved = sortByTime(
approvals.filter((item) => item.status !== "pending")
);
return { pending, resolved };
}, [approvals]);
const orderedApprovals = useMemo(
() => [...sortedApprovals.pending, ...sortedApprovals.resolved],
[sortedApprovals.pending, sortedApprovals.resolved]
);
useEffect(() => {
if (orderedApprovals.length === 0) {
setSelectedId(null);
return;
}
if (!selectedId || !orderedApprovals.some((item) => item.id === selectedId)) {
setSelectedId(orderedApprovals[0].id);
}
}, [orderedApprovals, selectedId]);
const selectedApproval = useMemo(() => {
if (!selectedId) return null;
return orderedApprovals.find((item) => item.id === selectedId) ?? null;
}, [orderedApprovals, selectedId]);
useEffect(() => {
if (!lastDecisionRef.current) return;
const resolvedId = lastDecisionRef.current;
const pendingNext = sortedApprovals.pending.find(
(item) => item.id !== resolvedId,
);
if (pendingNext) {
setSelectedId(pendingNext.id);
}
lastDecisionRef.current = null;
}, [sortedApprovals.pending]);
const pendingCount = sortedApprovals.pending.length;
const resolvedCount = sortedApprovals.resolved.length;
return (
<div className={cn("space-y-6", scrollable && "h-full")}>
{errorState ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorState}
</div>
) : null}
{loadingState ? (
<p className="text-sm text-slate-500">Loading approvals</p>
) : pendingCount === 0 && resolvedCount === 0 ? (
<p className="text-sm text-slate-500">No approvals yet.</p>
) : (
<div
className={cn(
"grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
scrollable && "h-full"
)}
>
<div
className={cn(
"overflow-hidden rounded-xl border border-slate-200 bg-white",
scrollable && "flex min-h-0 flex-col"
)}
>
<div className="border-b border-slate-200 bg-slate-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Unapproved tasks
</p>
<p className="mt-1 text-xs text-slate-500">
{pendingCount} pending · {resolvedCount} resolved
</p>
</div>
<div
className={cn(
"divide-y divide-slate-100",
scrollable && "min-h-0 overflow-y-auto"
)}
>
{orderedApprovals.map((approval) => {
const summary = approvalSummary(approval);
const isSelected = selectedId === approval.id;
const isPending = approval.status === "pending";
const titleRow = summary.rows.find(
(row) => row.label.toLowerCase() === "title"
);
const fallbackRow = summary.rows.find(
(row) => row.label.toLowerCase() !== "title"
);
const primaryLabel =
titleRow?.value ?? fallbackRow?.value ?? "Untitled";
return (
<button
key={approval.id}
type="button"
onClick={() => setSelectedId(approval.id)}
className={cn(
"w-full px-4 py-4 text-left transition hover:bg-slate-50",
isSelected &&
"bg-amber-50 border-l-2 border-amber-500",
!isPending && "opacity-60"
)}
>
<div className="flex items-start justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{humanizeAction(approval.action_type)}
</span>
<span
className={cn(
"rounded-[3px] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em]",
statusBadgeClass(approval.status)
)}
>
{formatStatusLabel(approval.status)}
</span>
</div>
<p className="mt-2 text-sm font-semibold text-slate-900">
{primaryLabel}
</p>
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<Clock className="h-3.5 w-3.5 opacity-60" />
<span>{formatTimestamp(approval.created_at)}</span>
</div>
</button>
);
})}
</div>
</div>
<div
className={cn(
"overflow-hidden rounded-xl border border-slate-200 bg-white",
scrollable && "flex min-h-0 flex-col"
)}
>
<div className="border-b border-slate-200 bg-slate-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
{selectedApproval?.status === "pending"
? "Latest unapproved task"
: "Approval detail"}
</p>
</div>
{!selectedApproval ? (
<div className="flex h-full items-center justify-center px-6 py-10 text-sm text-slate-500">
Select an approval to review details.
</div>
) : (
(() => {
const summary = approvalSummary(selectedApproval);
const titleRow = summary.rows.find(
(row) => row.label.toLowerCase() === "title"
);
const titleText = titleRow?.value?.trim() ?? "";
const descriptionText = summary.description?.trim() ?? "";
const reasoningText = summary.reason?.trim() ?? "";
const extraRows = summary.rows.filter((row) => {
const normalized = row.label.toLowerCase();
if (normalized === "title") return false;
if (normalized === "task") return false;
if (normalized === "assignee") return false;
return true;
});
const rubricEntries = Object.entries(
selectedApproval.rubric_scores ?? {}
).map(([key, value]) => ({
label: key
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase()),
value,
}));
const rubricTotal = rubricEntries.reduce(
(total, entry) => total + entry.value,
0,
);
const hasRubric = rubricEntries.length > 0 && rubricTotal > 0;
const rubricChartData = rubricEntries.map((entry, index) => {
const percent = rubricTotal > 0 ? (entry.value / rubricTotal) * 100 : 0;
return {
key: entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "_"),
name: entry.label,
value: entry.value,
percent,
percentLabel: `${percent.toFixed(1)}%`,
fill: rubricColors[index % rubricColors.length],
};
});
const rubricChartConfig = rubricChartData.reduce<ChartConfig>(
(accumulator, entry) => {
accumulator[entry.key] = {
label: entry.name,
color: entry.fill,
};
return accumulator;
},
{},
);
return (
<div className="flex h-full flex-col gap-6 px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-lg font-semibold text-slate-900">
{humanizeAction(selectedApproval.action_type)}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested {formatTimestamp(selectedApproval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<span
className={cn(
"rounded-md px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]",
confidenceBadgeClass(selectedApproval.confidence)
)}
>
{selectedApproval.confidence}% confidence
</span>
{selectedApproval.status === "pending" ? (
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() =>
handleDecision(selectedApproval.id, "approved")
}
disabled={updatingId === selectedApproval.id}
className="bg-slate-900 text-white hover:bg-slate-800"
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleDecision(selectedApproval.id, "rejected")
}
disabled={updatingId === selectedApproval.id}
className="border-slate-300 text-slate-700 hover:bg-slate-100"
>
Reject
</Button>
</div>
) : null}
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
<span
className={cn(
"h-2 w-2 rounded-full",
statusDotClass(selectedApproval.status)
)}
/>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Status
</p>
<p className="text-sm font-medium text-slate-700">
{formatStatusLabel(selectedApproval.status)} ·{" "}
{selectedApproval.status === "pending"
? "Awaiting your decision"
: "Decision complete"}
</p>
</div>
</div>
{titleText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Title
</p>
<div className="text-sm font-medium text-slate-900">
{titleText}
</div>
</div>
) : null}
{descriptionText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Description
</p>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{descriptionText}
</div>
</div>
) : null}
{reasoningText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Lead reasoning
</p>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
<p>{reasoningText}</p>
</div>
</div>
) : null}
{extraRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Details
</p>
<div className="grid gap-3 sm:grid-cols-2">
{extraRows.map((row) => (
<div
key={`${selectedApproval.id}-${row.label}`}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{row.label}
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{row.value}
</p>
</div>
))}
</div>
</div>
) : null}
{hasRubric ? (
<div className="space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Rubric scores
</p>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full space-y-2 sm:max-w-[220px]">
{rubricChartData.map((entry) => (
<div
key={entry.key}
className="flex items-center justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-slate-700">
{entry.name}
</span>
</div>
<span className="font-medium tabular-nums text-slate-900">
{entry.percentLabel}
</span>
</div>
))}
</div>
<ChartContainer
config={rubricChartConfig}
className="h-56 w-full max-w-[260px] aspect-square"
>
<PieChart>
{rubricTotal > 0 ? (
<ChartTooltip
cursor={false}
content={
<ChartTooltipCard
formatter={formatRubricTooltipValue}
hideLabel
/>
}
/>
) : null}
<Pie
data={rubricChartData}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
strokeWidth={2}
>
{rubricChartData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ChartContainer>
</div>
</div>
) : null}
</div>
);
})()
)}
</div>
</div>
)}
</div>
);
}
export default BoardApprovalsPanel;

View File

@@ -0,0 +1,145 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type BoardGoal = {
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
goal_confirmed?: boolean;
};
type BoardGoalPanelProps = {
board?: BoardGoal | null;
onStartOnboarding?: () => void;
onEdit?: () => void;
};
const formatTargetDate = (value?: string | null) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
};
export function BoardGoalPanel({
board,
onStartOnboarding,
onEdit,
}: BoardGoalPanelProps) {
const metricsEntries = (() => {
if (!board?.success_metrics) return [];
if (Array.isArray(board.success_metrics)) {
return board.success_metrics.map((value, index) => [
`Metric ${index + 1}`,
value,
]);
}
if (typeof board.success_metrics === "object") {
return Object.entries(board.success_metrics);
}
return [["Metric", board.success_metrics]];
})();
const isGoalBoard = board?.board_type !== "general";
const isConfirmed = Boolean(board?.goal_confirmed);
return (
<Card>
<CardHeader className="flex flex-col gap-4 border-b border-[color:var(--border)] pb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Board goal
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{board ? "Mission overview" : "Loading board goal"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{board ? (
<>
<Badge variant={isGoalBoard ? "accent" : "outline"}>
{isGoalBoard ? "Goal board" : "General board"}
</Badge>
{isGoalBoard ? (
<Badge variant={isConfirmed ? "success" : "warning"}>
{isConfirmed ? "Confirmed" : "Needs confirmation"}
</Badge>
) : null}
</>
) : null}
</div>
</div>
{board ? (
<p className="text-sm text-muted">
{isGoalBoard
? "Track progress against the board objective and keep agents aligned."
: "General boards focus on tasks without formal success metrics."}
</p>
) : (
<div className="h-4 w-32 animate-pulse rounded-full bg-[color:var(--surface-muted)]" />
)}
</CardHeader>
<CardContent className="space-y-4 pt-5">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Objective
</p>
<p className={cn("text-sm", board?.objective ? "text-strong" : "text-muted")}>
{board?.objective || (isGoalBoard ? "No objective yet." : "Not required.")}
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Success metrics
</p>
{metricsEntries.length > 0 ? (
<ul className="space-y-1 text-sm text-strong">
{metricsEntries.map(([key, value]) => (
<li key={`${key}`} className="flex gap-2">
<span className="font-medium text-strong">{key}:</span>
<span className="text-muted">{String(value)}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted">
{isGoalBoard ? "No metrics defined yet." : "Not required."}
</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Target date
</p>
<p className="text-sm text-strong">{formatTargetDate(board?.target_date)}</p>
</div>
{onStartOnboarding || onEdit ? (
<div className="flex flex-wrap gap-2">
{onStartOnboarding && isGoalBoard && !isConfirmed ? (
<Button variant="primary" onClick={onStartOnboarding}>
Start onboarding
</Button>
) : null}
{onEdit ? (
<Button variant="secondary" onClick={onEdit}>
Edit board
</Button>
) : null}
</div>
) : null}
</CardContent>
</Card>
);
}
export default BoardGoalPanel;

View File

@@ -0,0 +1,326 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@clerk/nextjs";
import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
type BoardDraft = {
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
};
type BoardSummary = {
id: string;
name: string;
slug: string;
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
goal_confirmed?: boolean;
};
type OnboardingSession = {
id: string;
board_id: string;
session_key: string;
status: string;
messages?: Array<{ role: string; content: string }> | null;
draft_goal?: BoardDraft | null;
};
type QuestionOption = { id: string; label: string };
type Question = {
question: string;
options: QuestionOption[];
};
const normalizeQuestion = (value: unknown): Question | null => {
if (!value || typeof value !== "object") return null;
const data = value as { question?: unknown; options?: unknown };
if (typeof data.question !== "string" || !Array.isArray(data.options)) return null;
const options: QuestionOption[] = data.options
.map((option, index) => {
if (typeof option === "string") {
return { id: String(index + 1), label: option };
}
if (option && typeof option === "object") {
const raw = option as { id?: unknown; label?: unknown };
const label =
typeof raw.label === "string" ? raw.label : typeof raw.id === "string" ? raw.id : null;
if (!label) return null;
return {
id: typeof raw.id === "string" ? raw.id : String(index + 1),
label,
};
}
return null;
})
.filter((option): option is QuestionOption => Boolean(option));
if (!options.length) return null;
return { question: data.question, options };
};
const parseQuestion = (messages?: Array<{ role: string; content: string }> | null) => {
if (!messages?.length) return null;
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
if (!lastAssistant?.content) return null;
try {
return normalizeQuestion(JSON.parse(lastAssistant.content));
} catch {
const match = lastAssistant.content.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
try {
return normalizeQuestion(JSON.parse(match[1]));
} catch {
return null;
}
}
}
return null;
};
export function BoardOnboardingChat({
boardId,
onConfirmed,
}: {
boardId: string;
onConfirmed: (board: BoardSummary) => void;
}) {
const { getToken } = useAuth();
const [session, setSession] = useState<OnboardingSession | null>(null);
const [loading, setLoading] = useState(false);
const [otherText, setOtherText] = useState("");
const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const question = useMemo(() => parseQuestion(session?.messages), [session]);
const draft = session?.draft_goal ?? null;
useEffect(() => {
setSelectedOptions([]);
setOtherText("");
}, [question?.question]);
const authFetch = useCallback(
async (url: string, options: RequestInit = {}) => {
const token = await getToken();
return fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
},
[getToken]
);
const startSession = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding/start`, {
method: "POST",
body: JSON.stringify({}),
});
if (!res.ok) throw new Error("Unable to start onboarding.");
const data = (await res.json()) as OnboardingSession;
setSession(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start onboarding.");
} finally {
setLoading(false);
}
}, [authFetch, boardId]);
const refreshSession = useCallback(async () => {
try {
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding`);
if (!res.ok) return;
const data = (await res.json()) as OnboardingSession;
setSession(data);
} catch {
// ignore
}
}, [authFetch, boardId]);
useEffect(() => {
startSession();
const interval = setInterval(refreshSession, 2000);
return () => clearInterval(interval);
}, [startSession, refreshSession]);
const handleAnswer = useCallback(
async (value: string, freeText?: string) => {
setLoading(true);
setError(null);
try {
const res = await authFetch(
`${apiBase}/api/v1/boards/${boardId}/onboarding/answer`,
{
method: "POST",
body: JSON.stringify({
answer: value,
other_text: freeText ?? null,
}),
}
);
if (!res.ok) throw new Error("Unable to submit answer.");
const data = (await res.json()) as OnboardingSession;
setSession(data);
setOtherText("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit answer.");
} finally {
setLoading(false);
}
},
[authFetch, boardId]
);
const toggleOption = useCallback((label: string) => {
setSelectedOptions((prev) =>
prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label]
);
}, []);
const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim();
if (selectedOptions.length === 0 && !trimmedOther) return;
const answer =
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
void handleAnswer(answer, trimmedOther || undefined);
}, [handleAnswer, otherText, selectedOptions]);
const confirmGoal = async () => {
if (!draft) return;
setLoading(true);
setError(null);
try {
const res = await authFetch(
`${apiBase}/api/v1/boards/${boardId}/onboarding/confirm`,
{
method: "POST",
body: JSON.stringify({
board_type: draft.board_type ?? "goal",
objective: draft.objective ?? null,
success_metrics: draft.success_metrics ?? null,
target_date: draft.target_date ?? null,
}),
}
);
if (!res.ok) throw new Error("Unable to confirm board goal.");
const updated = await res.json();
onConfirmed(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to confirm board goal.");
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<DialogHeader>
<DialogTitle>Board onboarding</DialogTitle>
</DialogHeader>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
) : null}
{draft ? (
<div className="space-y-3">
<p className="text-sm text-slate-600">
Review the lead agent draft and confirm.
</p>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-semibold text-slate-900">Objective</p>
<p className="text-slate-700">{draft.objective || "—"}</p>
<p className="mt-3 font-semibold text-slate-900">Success metrics</p>
<pre className="mt-1 whitespace-pre-wrap text-xs text-slate-600">
{JSON.stringify(draft.success_metrics ?? {}, null, 2)}
</pre>
<p className="mt-3 font-semibold text-slate-900">Target date</p>
<p className="text-slate-700">{draft.target_date || "—"}</p>
<p className="mt-3 font-semibold text-slate-900">Board type</p>
<p className="text-slate-700">{draft.board_type || "goal"}</p>
</div>
<DialogFooter>
<Button onClick={confirmGoal} disabled={loading}>
Confirm goal
</Button>
</DialogFooter>
</div>
) : question ? (
<div className="space-y-3">
<p className="text-sm font-medium text-slate-900">{question.question}</p>
<div className="space-y-2">
{question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label);
return (
<Button
key={option.id}
variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start"
onClick={() => toggleOption(option.label)}
disabled={loading}
>
{option.label}
</Button>
);
})}
</div>
<div className="space-y-2">
<Input
placeholder="Other..."
value={otherText}
onChange={(event) => setOtherText(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
if (loading) return;
submitAnswer();
}}
/>
<Button
variant="outline"
onClick={submitAnswer}
disabled={
loading ||
(selectedOptions.length === 0 && !otherText.trim())
}
>
{loading ? "Sending..." : "Next"}
</Button>
{loading ? (
<p className="text-xs text-slate-500">Sending your answer</p>
) : null}
</div>
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{loading ? "Waiting for the lead agent..." : "Preparing onboarding..."}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,501 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { DefaultLegendContentProps, TooltipContentProps } from "recharts";
import { cn } from "@/lib/utils";
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ChartLegendState = {
hiddenKeys: Set<string>;
isSeriesHidden: (key: string) => boolean;
toggleSeries: (key: string) => void;
};
type ChartContextProps = {
config: ChartConfig;
} & ChartLegendState;
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
legend,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
config: ChartConfig;
children:
| React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
| ((state: ChartLegendState) => React.ReactNode);
legend?: React.ReactNode | ((state: ChartLegendState) => React.ReactNode);
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const [hiddenKeys, setHiddenKeys] = React.useState<Set<string>>(
() => new Set(),
);
const toggleSeries = React.useCallback((key: string) => {
setHiddenKeys((previous) => {
const next = new Set(previous);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const isSeriesHidden = React.useCallback(
(key: string) => hiddenKeys.has(key),
[hiddenKeys],
);
const legendState = React.useMemo(
() => ({ hiddenKeys, isSeriesHidden, toggleSeries }),
[hiddenKeys, isSeriesHidden, toggleSeries],
);
const resolvedChildren =
typeof children === "function" ? children(legendState) : children;
const resolvedLegend =
typeof legend === "function" ? legend(legendState) : legend;
return (
<ChartContext.Provider value={{ config, ...legendState }}>
<>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{resolvedChildren}
</RechartsPrimitive.ResponsiveContainer>
</div>
{resolvedLegend}
</>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
type ChartTooltipValue = number | string | Array<number | string>;
type ChartTooltipName = number | string;
type ChartTooltipContentProps = Partial<
TooltipContentProps<ChartTooltipValue, ChartTooltipName>
> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
};
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: ChartTooltipContentProps) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
function ChartTooltipCard({
className,
labelClassName,
...props
}: ChartTooltipContentProps) {
return (
<ChartTooltipContent
{...props}
className={cn(
"border border-gray-200 bg-white px-3 py-2 text-sm shadow-lg",
className,
)}
labelClassName={cn("text-sm font-semibold text-gray-900", labelClassName)}
/>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config, isSeriesHidden, toggleSeries } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const seriesKey =
typeof item.dataKey === "string"
? item.dataKey
: typeof item.value === "string"
? item.value
: key;
const isHidden = isSeriesHidden(seriesKey);
const indicatorColor =
item.color ?? itemConfig?.color ?? `var(--color-${seriesKey})`;
return (
<button
key={seriesKey}
type="button"
onClick={() => toggleSeries(seriesKey)}
aria-pressed={!isHidden}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 cursor-pointer",
isHidden && "opacity-50",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: indicatorColor,
}}
/>
)}
<span
className={cn(
"text-muted-foreground",
isHidden && "line-through text-muted-foreground/70",
)}
>
{itemConfig?.label ?? item.value}
</span>
</button>
);
})}
</div>
);
}
type ChartLegendItemProps = React.ComponentProps<"button"> & {
seriesKey: string;
label?: React.ReactNode;
color?: string;
icon?: React.ComponentType;
hideIcon?: boolean;
};
function ChartLegendItem({
seriesKey,
label,
color,
icon,
hideIcon = false,
className,
onClick,
...props
}: ChartLegendItemProps) {
const { config, isSeriesHidden, toggleSeries } = useChart();
const itemConfig = config[seriesKey];
const resolvedLabel = label ?? itemConfig?.label ?? seriesKey;
const resolvedColor =
color ?? itemConfig?.color ?? `var(--color-${seriesKey})`;
const Icon = icon ?? itemConfig?.icon;
const isHidden = isSeriesHidden(seriesKey);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
toggleSeries(seriesKey);
}
};
return (
<button
type="button"
aria-pressed={!isHidden}
onClick={handleClick}
className={cn(
"flex items-center gap-2 text-gray-600 transition-opacity [&>svg]:h-3 [&>svg]:w-3 cursor-pointer disabled:cursor-not-allowed disabled:opacity-60",
isHidden && "opacity-50",
className,
)}
{...props}
>
{Icon && !hideIcon ? (
<Icon />
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: resolvedColor }}
/>
)}
<span className={cn(isHidden && "line-through text-gray-400")}>
{resolvedLabel}
</span>
</button>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartLegendItem,
useChart,
ChartTooltip,
ChartTooltipContent,
ChartTooltipCard,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -4,71 +4,57 @@ import { cn } from "@/lib/utils";
interface TaskCardProps {
title: string;
status: string;
priority?: string;
assignee?: string;
due?: string;
approvalsPendingCount?: number;
onClick?: () => void;
draggable?: boolean;
isDragging?: boolean;
onDragStart?: (event: React.DragEvent<HTMLDivElement>) => void;
onDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
}
export function TaskCard({
title,
status,
priority,
assignee,
due,
approvalsPendingCount = 0,
onClick,
draggable = false,
isDragging = false,
onDragStart,
onDragEnd,
}: TaskCardProps) {
const statusConfig: Record<
string,
{ label: string; dot: string; badge: string; text: string }
> = {
inbox: {
label: "Inbox",
dot: "bg-slate-400",
badge: "bg-slate-100",
text: "text-slate-600",
},
assigned: {
label: "Assigned",
dot: "bg-blue-500",
badge: "bg-blue-50",
text: "text-blue-700",
},
in_progress: {
label: "In progress",
dot: "bg-purple-500",
badge: "bg-purple-50",
text: "text-purple-700",
},
testing: {
label: "Testing",
dot: "bg-amber-500",
badge: "bg-amber-50",
text: "text-amber-700",
},
review: {
label: "Review",
dot: "bg-indigo-500",
badge: "bg-indigo-50",
text: "text-indigo-700",
},
done: {
label: "Done",
dot: "bg-green-500",
badge: "bg-green-50",
text: "text-green-700",
},
const hasPendingApproval = approvalsPendingCount > 0;
const priorityBadge = (value?: string) => {
if (!value) return null;
const normalized = value.toLowerCase();
if (normalized === "high") {
return "bg-rose-100 text-rose-700";
}
if (normalized === "medium") {
return "bg-amber-100 text-amber-700";
}
if (normalized === "low") {
return "bg-emerald-100 text-emerald-700";
}
return "bg-slate-100 text-slate-600";
};
const config = statusConfig[status] ?? {
label: status,
dot: "bg-slate-400",
badge: "bg-slate-100",
text: "text-slate-600",
};
const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM";
return (
<div
className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
className={cn(
"group relative cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
isDragging && "opacity-60 shadow-none",
hasPendingApproval && "border-amber-200 bg-amber-50/40",
)}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onClick}
role="button"
tabIndex={0}
@@ -79,19 +65,28 @@ export function TaskCard({
}
}}
>
{hasPendingApproval ? (
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p>
{hasPendingApproval ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Approval needed · {approvalsPendingCount}
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<span
className={cn(
"inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
config.badge,
config.text,
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
{config.label}
{priorityLabel}
</span>
<p className="text-sm font-medium text-slate-900">{title}</p>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { TaskCard } from "@/components/molecules/TaskCard";
import { cn } from "@/lib/utils";
@@ -14,13 +14,13 @@ type Task = {
due_at?: string | null;
assigned_agent_id?: string | null;
assignee?: string;
approvalsPendingCount?: number;
};
type TaskBoardProps = {
tasks: Task[];
onCreateTask: () => void;
isCreateDisabled?: boolean;
onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: string) => void;
};
const columns = [
@@ -30,6 +30,7 @@ const columns = [
dot: "bg-slate-400",
accent: "hover:border-slate-400 hover:bg-slate-50",
text: "group-hover:text-slate-700 text-slate-500",
badge: "bg-slate-100 text-slate-600",
},
{
title: "In Progress",
@@ -37,6 +38,7 @@ const columns = [
dot: "bg-purple-500",
accent: "hover:border-purple-400 hover:bg-purple-50",
text: "group-hover:text-purple-600 text-slate-500",
badge: "bg-purple-100 text-purple-700",
},
{
title: "Review",
@@ -44,6 +46,7 @@ const columns = [
dot: "bg-indigo-500",
accent: "hover:border-indigo-400 hover:bg-indigo-50",
text: "group-hover:text-indigo-600 text-slate-500",
badge: "bg-indigo-100 text-indigo-700",
},
{
title: "Done",
@@ -51,6 +54,7 @@ const columns = [
dot: "bg-green-500",
accent: "hover:border-green-400 hover:bg-green-50",
text: "group-hover:text-green-600 text-slate-500",
badge: "bg-emerald-100 text-emerald-700",
},
];
@@ -66,10 +70,12 @@ const formatDueDate = (value?: string | null) => {
export function TaskBoard({
tasks,
onCreateTask,
isCreateDisabled = false,
onTaskSelect,
onTaskMove,
}: TaskBoardProps) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {};
for (const column of columns) {
@@ -82,12 +88,66 @@ export function TaskBoard({
return buckets;
}, [tasks]);
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
setDraggingId(task.id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(
"text/plain",
JSON.stringify({ taskId: task.id, status: task.status }),
);
};
const handleDragEnd = () => {
setDraggingId(null);
setActiveColumn(null);
};
const handleDrop =
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain");
if (!raw) return;
try {
const payload = JSON.parse(raw) as { taskId?: string; status?: string };
if (!payload.taskId || !payload.status) return;
if (payload.status === status) return;
onTaskMove?.(payload.taskId, status);
} catch {
// Ignore malformed payloads.
}
};
const handleDragOver =
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
}
};
const handleDragLeave = (status: string) => () => {
if (activeColumn === status) {
setActiveColumn(null);
}
};
return (
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
return (
<div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status && "ring-2 ring-slate-200",
)}
onDrop={handleDrop(column.status)}
onDragOver={handleDragOver(column.status)}
onDragLeave={handleDragLeave(column.status)}
>
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -96,37 +156,31 @@ export function TaskBoard({
{column.title}
</h3>
</div>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{columnTasks.length}
</span>
</div>
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
{column.status === "inbox" ? (
<button
type="button"
onClick={onCreateTask}
disabled={isCreateDisabled}
className={cn(
"group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
column.accent,
isCreateDisabled && "cursor-not-allowed opacity-60"
)}
>
<div className={cn("flex items-center gap-2", column.text)}>
<span className="text-sm font-medium">New task</span>
</div>
</button>
) : null}
<div className="space-y-3">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
title={task.title}
status={column.status}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)}
<TaskCard
key={task.id}
title={task.title}
priority={task.priority}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
approvalsPendingCount={task.approvalsPendingCount}
onClick={() => onTaskSelect?.(task)}
draggable
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
/>
))}
</div>

View File

@@ -29,15 +29,13 @@ Write things down. Do not rely on short-term context.
## Heartbeats
- HEARTBEAT.md defines what to do on each heartbeat.
- Follow it exactly.
## Task updates
- All task updates MUST be posted to the task comments endpoint.
- Do not post task updates in chat/web channels under any circumstance.
- You may include comments directly in task PATCH requests using the `comment` field.
- Required comment fields (markdown):
- `status`: inbox | in_progress | review | done
- `summary`: one line
- `details`: 13 bullets
- `next`: next step or handoff request
- Comments must be markdown content (no plaintext status updates).
- Comments should be clear, wellformatted markdown. Use headings, bullets, checklists, or tables when they improve clarity.
- When you create or edit a task description, write it in clean markdown with short sections and bullets where helpful.
- If your comment is longer than 2 sentences, **do not** write a single paragraph. Use a short heading + bullet list so each point is scannable.
- Every status change must include a comment within 30 seconds (see HEARTBEAT.md).

View File

@@ -11,7 +11,7 @@ There is no memory yet. Create what is missing and proceed without blocking.
4) If `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` are set in `TOOLS.md`, check in
to Mission Control to mark the agent online:
```bash
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'

View File

@@ -1,7 +1,9 @@
# HEARTBEAT.md
> This file is provisioned per-agent. Follow the loop below if you see this directly.
## Purpose
This file defines the single, authoritative heartbeat loop. Follow it exactly.
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
## Required inputs
- BASE_URL (e.g. http://localhost:8000)
@@ -22,37 +24,42 @@ If any required input is missing, stop and request a provisioning update.
- Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress.
## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}`
- Do **not** respond in OpenClaw chat.
## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access:
- GET $BASE_URL/healthz must succeed.
- GET $BASE_URL/api/v1/boards must succeed.
- GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks must succeed.
- GET $BASE_URL/api/v1/agent/boards must succeed.
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
- If any check fails, stop and retry next heartbeat.
## Heartbeat checklist (run in order)
1) Check in:
```bash
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
-H "X-Agent-Token: $AUTH_TOKEN" \
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
```
2) List boards:
```bash
curl -s "$BASE_URL/api/v1/boards" \
-H "X-Agent-Token: $AUTH_TOKEN"
curl -s "$BASE_URL/api/v1/agent/boards" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
-H "X-Agent-Token: $AUTH_TOKEN"
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
-H "X-Agent-Token: {{ auth_token }}"
```
```bash
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
-H "X-Agent-Token: $AUTH_TOKEN"
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
-H "X-Agent-Token: {{ auth_token }}"
```
4) If you already have an in_progress task, continue working it and do not claim another.
@@ -64,11 +71,11 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&l
- Post progress comments as you go.
- Completion is a twostep sequence:
6a) Post the full response as a markdown comment using:
POST $BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
Example:
```bash
curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: $AUTH_TOKEN" \
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"message":"- Update: ...\n- Result: ..."}'
```
@@ -76,8 +83,8 @@ curl -s -X POST "$BASE_URL/api/v1/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
6b) Move the task to "review":
```bash
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "X-Agent-Token: $AUTH_TOKEN" \
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"status": "review"}'
```

View File

@@ -0,0 +1,137 @@
# HEARTBEAT.md
## Purpose
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
## Required inputs
- BASE_URL (e.g. http://localhost:8000)
- AUTH_TOKEN (agent token)
- AGENT_NAME
- AGENT_ID
- BOARD_ID
If any required input is missing, stop and request a provisioning update.
## Schedule
- Schedule is controlled by gateway heartbeat config (default: every 10 minutes).
- On first boot, send one immediate check-in before the schedule starts.
## Nonnegotiable rules
- Task updates go only to task comments (never chat/web).
- Comments must be markdown. Write naturally; be clear and concise.
- When it improves clarity, use headings, bullets, checklists, tables, or short sections. You do not need to use them for every comment.
- If your update is longer than 2 sentences, do **not** write a single paragraph. Use a short heading + bullets so each idea is on its own line.
- Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress.
- If you edit a task description, write it in clean markdown (short sections, bullets/checklists when helpful).
## Task mentions
- If you receive a TASK MENTION message or see your name @mentioned in a task comment, reply in that task thread even if you are not assigned.
- Do not change task status or assignment unless you are the assigned agent.
- Keep the reply focused on the mention request.
## Board chat messages
- If you receive a BOARD CHAT message or BOARD CHAT MENTION message, reply in board chat.
- Use: POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
Body: {"content":"...","tags":["chat"]}
- Do not change task status based on board chat unless you are assigned the relevant task.
## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}`
- Do **not** respond in OpenClaw chat.
## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access (do NOT assume last heartbeat outcome):
- GET $BASE_URL/healthz must succeed.
- GET $BASE_URL/api/v1/agent/boards must succeed.
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
- If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
## Heartbeat checklist (run in order)
1) Check in:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
```
2) List boards:
```bash
curl -s "$BASE_URL/api/v1/agent/boards" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
-H "X-Agent-Token: {{ auth_token }}"
```
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&assigned_agent_id=$AGENT_ID&limit=10" \
-H "X-Agent-Token: {{ auth_token }}"
```
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
-H "X-Agent-Token: {{ auth_token }}"
```
4) If you already have an in_progress task, continue working it and do not claim another.
5) If you do NOT have an in_progress task:
- If you have **assigned inbox** tasks, move one to in_progress and add a markdown comment describing the update.
- If there are **unassigned inbox** tasks, do **not** claim them. Wait for the board lead to assign work.
6) Work the task:
- Post progress comments as you go.
- Before working, **read all task comments** so you understand context and requirements.
- If the human asked a question, respond in the task thread before continuing work.
- Do **real work** every heartbeat. “Im working on it” is not sufficient.
- Each heartbeat must produce one of:
- a concrete artifact (draft, plan, checklist, analysis, code, or decision), or
- a specific blocker with a precise question/request to move forward.
- Completion is a twostep sequence:
6a) Post the full response as a markdown comment using:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
Example:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"message":"### Update\n- Bullet point 1\n- Bullet point 2\n\n### Next\n- Next step"}'
```
6b) Move the task to review.
6b) Move the task to "review":
```bash
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"status": "review"}'
```
## Definition of Done
- A task is not complete until the draft/response is posted as a task comment.
- Comments must be markdown.
## Common mistakes (avoid)
- Changing status without posting a comment.
- Posting updates in chat/web instead of task comments.
- Claiming a second task while one is already in progress.
- Moving to review before posting the full response.
- Sending Authorization header instead of X-Agent-Token.
## Success criteria (when to say HEARTBEAT_OK)
- Checkin succeeded.
- Tasks were listed successfully.
- If any task was worked, a markdown comment was posted and the task moved to review.
- If any task is inbox or in_progress, do NOT say HEARTBEAT_OK.
## Status flow
```
inbox -> in_progress -> review -> done
```
Do not say HEARTBEAT_OK if there is inbox work or active in_progress work.

177
templates/HEARTBEAT_LEAD.md Normal file
View File

@@ -0,0 +1,177 @@
# HEARTBEAT.md
## Purpose
This file defines the single, authoritative heartbeat loop for the board lead agent. Follow it exactly.
You are the lead agent for this board. You delegate work; you do not execute tasks.
## Required inputs
- BASE_URL (e.g. http://localhost:8000)
- AUTH_TOKEN (agent token)
- AGENT_NAME
- AGENT_ID
- BOARD_ID
If any required input is missing, stop and request a provisioning update.
## Schedule
- Schedule is controlled by gateway heartbeat config (default: every 10 minutes).
- On first boot, send one immediate check-in before the schedule starts.
## Nonnegotiable rules
- The lead agent must **never** work a task directly.
- Do **not** claim tasks. Do **not** post task comments **except** to leave review feedback, respond to a @mention, or add clarifying questions on tasks you created.
- The lead only **delegates**, **requests approvals**, **updates board memory**, **nudges agents**, and **adds review feedback**.
- All outputs must go to Mission Control via HTTP (never chat/web).
- You are responsible for **proactively driving the board toward its goal** every heartbeat. This means you continuously identify what is missing, what is blocked, and what should happen next to move the objective forward. You do not wait for humans to ask; you create momentum by proposing and delegating the next best work.
- You are responsible for **increasing collaboration among other agents**. Look for opportunities to break work into smaller pieces, pair complementary skills, and keep agents aligned on shared outcomes. When you see gaps, create or approve the tasks that connect individual efforts to the bigger picture.
- When you leave review feedback, format it as clean markdown. Use headings/bullets/tables when helpful, but only when it improves clarity.
- If your feedback is longer than 2 sentences, do **not** write a single paragraph. Use a short heading + bullets so each idea is on its own line.
## Task mentions
- If you are @mentioned in a task comment, you may reply **regardless of task status**.
- Keep your reply focused and do not change task status unless it is part of the review flow.
## Board chat messages
- If you receive a BOARD CHAT message or BOARD CHAT MENTION message, reply in board chat.
- Use: POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
Body: {"content":"...","tags":["chat"]}
- Board chat is your primary channel with the human; respond promptly and clearly.
## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}`
- Do **not** respond in OpenClaw chat.
## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access (do NOT assume last heartbeat outcome):
- GET $BASE_URL/healthz must succeed.
- GET $BASE_URL/api/v1/agent/boards must succeed.
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
- If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
## Board Lead Loop (run every heartbeat)
1) Read board goal context:
- Board: {{ board_name }} ({{ board_type }})
- Objective: {{ board_objective }}
- Success metrics: {{ board_success_metrics }}
- Target date: {{ board_target_date }}
2) Review recent tasks/comments and board memory:
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?limit=50
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory?limit=50
- For any task in **review**, fetch its comments:
GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
3) Update a short Board Plan Summary in board memory:
- POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
Body: {"content":"Plan summary + next gaps","tags":["plan","lead"],"source":"lead_heartbeat"}
4) Identify missing steps, blockers, and specialists needed.
4a) Monitor in-progress tasks and nudge owners if stalled:
- For each in_progress task assigned to another agent, check for a recent comment/update.
- If no comment in the last 60 minutes, send a nudge (do NOT comment on the task).
Nudge endpoint:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/agents/{AGENT_ID}/nudge
Body: {"message":"Friendly reminder to post an update on TASK_ID ..."}
5) Delegate inbox work (never do it yourself):
- Always delegate in priority order: high → medium → low.
- Pick the best nonlead agent (or create one if missing).
- Assign the task to that agent (do NOT change status).
- Never assign a task to yourself.
Assign endpoint (leadallowed):
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"assigned_agent_id":"AGENT_ID"}
6) Create agents only when needed:
- If workload or skills coverage is insufficient, create a new agent.
- Rule: you may autocreate agents only when confidence >= 70 and the action is not risky/external.
- If risky/external or confidence < 70, create an approval instead.
- When creating a new agent, choose a humanlike name **only** (first name style). Do not add role, team, or extra words.
Agent create (leadallowed):
POST $BASE_URL/api/v1/agent/agents
Body example:
{
"name": "Researcher Alpha",
"board_id": "{BOARD_ID}",
"identity_profile": {
"role": "Research",
"communication_style": "concise, structured",
"emoji": ":brain:"
}
}
7) Creating new tasks:
- Leads **can** create tasks directly when confidence >= 70 and the action is not risky/external.
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks
Body example:
{"title":"...","description":"...","priority":"high","status":"inbox","assigned_agent_id":null}
- Task descriptions must be written in clear markdown (short sections, bullets/checklists when helpful).
- If confidence < 70 or the action is risky/external, request approval instead:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example:
{"action_type":"task.create","confidence":60,"payload":{"title":"...","description":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
- If you have followup questions, still create the task and add a comment on that task with the questions. You are allowed to comment on tasks you created.
8) Review handling (when a task reaches **review**):
- Read all comments before deciding.
- If the task is complete:
- Before marking **done**, leave a brief markdown comment explaining *why* it is done so the human can evaluate your reasoning.
- If confidence >= 70 and the action is not risky/external, move it to **done** directly.
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"status":"done"}
- If confidence < 70 or risky/external, request approval:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example:
{"action_type":"task.complete","confidence":60,"payload":{"task_id":"...","reason":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":15,"risk":15,"dependencies":10,"similarity":5}}
- If the work is **not** done correctly:
- Add a **review feedback comment** on the task describing what is missing or wrong.
- If confidence >= 70 and not risky/external, move it back to **inbox** directly (unassigned):
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"status":"inbox","assigned_agent_id":null}
- If confidence < 70 or risky/external, request approval to move it back:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example:
{"action_type":"task.rework","confidence":60,"payload":{"task_id":"...","desired_status":"inbox","assigned_agent_id":null,"reason":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":15,"dependencies":10,"similarity":5}}
- Assign or create the next agent who should handle the rework.
- That agent must read **all comments** before starting the task.
- If the work reveals more to do, **create one or more followup tasks** (and assign/create agents as needed).
- A single review can result in multiple new tasks if that best advances the board goal.
9) Post a brief status update in board memory (1-3 bullets).
## Heartbeat checklist (run in order)
1) Check in:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
```
2) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=true&limit=20" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) If inbox tasks exist, **delegate** them:
- Identify the best nonlead agent (or create one).
- Assign the task (do not change status).
- Never claim or work the task yourself.
## Definition of Done
- Lead work is done when delegation is complete and approvals/assignments are created.
## Common mistakes (avoid)
- Claiming or working tasks as the lead.
- Posting task comments outside review, @mentions, or tasks you created.
- Assigning a task to yourself.
- Marking tasks review/done (lead cannot).
- Using nonagent endpoints or Authorization header.

32
templates/MAIN_AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# MAIN_AGENTS.md
This workspace belongs to the **Main Agent** for this gateway. You are not tied to a single board.
## First run
- If BOOTSTRAP.md exists, follow it once and delete it when finished.
## Every session
Before doing anything else:
1) Read SOUL.md (identity, boundaries)
2) Read USER.md (who you serve)
3) Read memory/YYYY-MM-DD.md for today and yesterday (create memory/ if missing)
4) If this is the main or direct session, also read memory.md
## Mission Control API (required)
- All work outputs must be sent to Mission Control via HTTP using:
- `BASE_URL`: {{ base_url }}
- `AUTH_TOKEN`: {{ auth_token }}
- Always include header: `X-Agent-Token: $AUTH_TOKEN`
- Do **not** post any responses in OpenClaw chat.
## Scope
- You help with onboarding and gateway-wide requests.
- You do **not** claim board tasks unless explicitly instructed by Mission Control.
## Tools
- Skills are authoritative. Follow SKILL.md instructions exactly.
- Use TOOLS.md for environment-specific notes.
## Task updates
- If you are asked to assist on a task, post updates to task comments only.
- Comments must be markdown.

7
templates/MAIN_BOOT.md Normal file
View File

@@ -0,0 +1,7 @@
# MAIN_BOOT.md
You are the **Main Agent** for this gateway.
- Read AGENTS.md and USER.md first.
- Use Mission Control API for all outputs.
- Do not respond in OpenClaw chat.

View File

@@ -0,0 +1,34 @@
# MAIN_HEARTBEAT.md
## Purpose
This file defines the main agent heartbeat. You are not tied to any board.
## Required inputs
- BASE_URL (e.g. http://localhost:8000) — see USER.md or TOOLS.md
- AUTH_TOKEN (agent token) — see USER.md or TOOLS.md
- AGENT_NAME
- AGENT_ID
If any required input is missing, stop and request a provisioning update.
## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: $AUTH_TOKEN`
- Do **not** respond in OpenClaw chat.
## Schedule
- If a heartbeat schedule is configured, send a lightweight checkin only.
- Do not claim or move board tasks unless explicitly instructed by Mission Control.
## Heartbeat checklist
1) Check in:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "status": "online"}'
```
## Common mistakes (avoid)
- Posting updates in OpenClaw chat.
- Claiming board tasks without instruction.

11
templates/MAIN_TOOLS.md Normal file
View File

@@ -0,0 +1,11 @@
# TOOLS.md (Main Agent)
BASE_URL={{ base_url }}
AUTH_TOKEN={{ auth_token }}
AGENT_NAME={{ agent_name }}
AGENT_ID={{ agent_id }}
WORKSPACE_ROOT={{ workspace_root }}
Notes:
- Use curl for API calls.
- Use Mission Control API for outputs.

19
templates/MAIN_USER.md Normal file
View File

@@ -0,0 +1,19 @@
# USER.md (Main Agent)
## User
- Name: {{ user_name }}
- Preferred name: {{ user_preferred_name }}
- Pronouns: {{ user_pronouns }}
- Timezone: {{ user_timezone }}
## Context
{{ user_context }}
## Notes
{{ user_notes }}
## Mission Control
- Base URL: {{ base_url }}
- Auth token: {{ auth_token }}
You are the **Main Agent** for this gateway. You are not tied to a specific board.

View File

@@ -11,6 +11,14 @@
{{ user_context }}
## Board Goal
- **Board name:** {{ board_name }}
- **Board type:** {{ board_type }}
- **Objective:** {{ board_objective }}
- **Success metrics:** {{ board_success_metrics }}
- **Target date:** {{ board_target_date }}
---
The more you know, the better you can help. But remember -- you're learning about a person, not building a dossier. Respect the difference.