Merge pull request #28 from abhi1693/feature/board-lead-orchestration
Board lead orchestration: goals, onboarding, approvals, and live comments
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ node_modules/
|
||||
# IDE
|
||||
.idea/
|
||||
.runlogs/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -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
385
backend/app/api/agent.py
Normal 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),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
162
backend/app/api/approvals.py
Normal file
162
backend/app/api/approvals.py
Normal 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
|
||||
238
backend/app/api/board_memory.py
Normal file
238
backend/app/api/board_memory.py
Normal 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
|
||||
332
backend/app/api/board_onboarding.py
Normal file
332
backend/app/api/board_onboarding.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -31,5 +31,4 @@ class Settings(BaseSettings):
|
||||
log_use_utc: bool = False
|
||||
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
backend/app/models/approvals.py
Normal file
22
backend/app/models/approvals.py
Normal 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
|
||||
18
backend/app/models/board_memory.py
Normal file
18
backend/app/models/board_memory.py
Normal 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)
|
||||
20
backend/app/models/board_onboarding.py
Normal file
20
backend/app/models/board_onboarding.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
16
backend/app/models/task_fingerprints.py
Normal file
16
backend/app/models/task_fingerprints.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
30
backend/app/schemas/approvals.py
Normal file
30
backend/app/schemas/approvals.py
Normal 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
|
||||
18
backend/app/schemas/board_memory.py
Normal file
18
backend/app/schemas/board_memory.py
Normal 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
|
||||
41
backend/app/schemas/board_onboarding.py
Normal file
41
backend/app/schemas/board_onboarding.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
27
backend/app/services/lead_policy.py
Normal file
27
backend/app/services/lead_policy.py
Normal 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()
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"redis==5.1.1",
|
||||
"fastapi-clerk-auth==0.0.9",
|
||||
"sse-starlette==2.1.3",
|
||||
"jinja2"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
6
backend/tests/conftest.py
Normal file
6
backend/tests/conftest.py
Normal 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))
|
||||
50
backend/tests/test_board_schema.py
Normal file
50
backend/tests/test_board_schema.py
Normal 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")
|
||||
50
backend/tests/test_lead_policy.py
Normal file
50
backend/tests/test_lead_policy.py
Normal 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
14
backend/uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
tailwind.config.*
|
||||
postcss.config.*
|
||||
orval.config.*
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
45
frontend/src/app/boards/[boardId]/approvals/page.tsx
Normal file
45
frontend/src/app/boards/[boardId]/approvals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -29,6 +29,7 @@ type Agent = {
|
||||
board_id?: string | null;
|
||||
last_seen_at?: string | null;
|
||||
updated_at: string;
|
||||
is_board_lead?: boolean;
|
||||
};
|
||||
|
||||
type GatewayStatus = {
|
||||
|
||||
685
frontend/src/components/BoardApprovalsPanel.tsx
Normal file
685
frontend/src/components/BoardApprovalsPanel.tsx
Normal 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;
|
||||
145
frontend/src/components/BoardGoalPanel.tsx
Normal file
145
frontend/src/components/BoardGoalPanel.tsx
Normal 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;
|
||||
326
frontend/src/components/BoardOnboardingChat.tsx
Normal file
326
frontend/src/components/BoardOnboardingChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
501
frontend/src/components/charts/chart.tsx
Normal file
501
frontend/src/components/charts/chart.tsx
Normal 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,
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`: 1–3 bullets
|
||||
- `next`: next step or handoff request
|
||||
- Comments must be markdown content (no plain‑text status updates).
|
||||
- Comments should be clear, well‑formatted 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).
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Pre‑flight 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 two‑step 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"}'
|
||||
```
|
||||
|
||||
137
templates/HEARTBEAT_AGENT.md
Normal file
137
templates/HEARTBEAT_AGENT.md
Normal 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.
|
||||
|
||||
## Non‑negotiable 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.
|
||||
|
||||
## Pre‑flight 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. “I’m 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 two‑step 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)
|
||||
- Check‑in 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
177
templates/HEARTBEAT_LEAD.md
Normal 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.
|
||||
|
||||
## Non‑negotiable 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.
|
||||
|
||||
## Pre‑flight 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 non‑lead agent (or create one if missing).
|
||||
- Assign the task to that agent (do NOT change status).
|
||||
- Never assign a task to yourself.
|
||||
Assign endpoint (lead‑allowed):
|
||||
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 auto‑create 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 human‑like name **only** (first name style). Do not add role, team, or extra words.
|
||||
Agent create (lead‑allowed):
|
||||
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 follow‑up 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 follow‑up 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 non‑lead 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 non‑agent endpoints or Authorization header.
|
||||
32
templates/MAIN_AGENTS.md
Normal file
32
templates/MAIN_AGENTS.md
Normal 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
7
templates/MAIN_BOOT.md
Normal 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.
|
||||
34
templates/MAIN_HEARTBEAT.md
Normal file
34
templates/MAIN_HEARTBEAT.md
Normal 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 check‑in 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
11
templates/MAIN_TOOLS.md
Normal 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
19
templates/MAIN_USER.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user