feat: implement agent nudging functionality and enhance task assignment rules for board leads
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -13,13 +14,22 @@ from app.api import tasks as tasks_api
|
|||||||
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
|
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.core.agent_auth import AgentAuthContext, get_agent_auth_context
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
from app.integrations.openclaw_gateway import (
|
||||||
|
GatewayConfig as GatewayClientConfig,
|
||||||
|
OpenClawGatewayError,
|
||||||
|
ensure_session,
|
||||||
|
send_message,
|
||||||
|
)
|
||||||
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead
|
from app.schemas.approvals import ApprovalCreate, ApprovalRead
|
||||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||||
from app.schemas.board_onboarding import BoardOnboardingRead
|
from app.schemas.board_onboarding import BoardOnboardingRead
|
||||||
from app.schemas.boards import BoardRead
|
from app.schemas.boards import BoardRead
|
||||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate
|
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate
|
||||||
from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentRead
|
from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead
|
||||||
|
from app.services.activity_log import record_activity
|
||||||
|
|
||||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||||
|
|
||||||
@@ -33,6 +43,15 @@ def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
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])
|
@router.get("/boards", response_model=list[BoardRead])
|
||||||
def list_boards(
|
def list_boards(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -53,6 +72,32 @@ def get_board(
|
|||||||
return 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])
|
@router.get("/boards/{board_id}/tasks", response_model=list[TaskRead])
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
@@ -207,7 +252,7 @@ def update_onboarding(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/agents", response_model=AgentRead)
|
@router.post("/agents", response_model=AgentRead)
|
||||||
def create_agent(
|
async def create_agent(
|
||||||
payload: AgentCreate,
|
payload: AgentCreate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||||
@@ -217,13 +262,69 @@ def create_agent(
|
|||||||
if not agent_ctx.agent.board_id:
|
if not agent_ctx.agent.board_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id})
|
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id})
|
||||||
return agents_api.create_agent(
|
return await agents_api.create_agent(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
session=session,
|
session=session,
|
||||||
actor=_actor(agent_ctx),
|
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)
|
@router.post("/heartbeat", response_model=AgentRead)
|
||||||
async def agent_heartbeat(
|
async def agent_heartbeat(
|
||||||
payload: AgentHeartbeatCreate,
|
payload: AgentHeartbeatCreate,
|
||||||
|
|||||||
@@ -445,9 +445,26 @@ async def update_agent(
|
|||||||
detail="Gateway configuration is required",
|
detail="Gateway configuration is required",
|
||||||
)
|
)
|
||||||
if is_main_agent:
|
if is_main_agent:
|
||||||
await provision_main_agent(agent, gateway, raw_token, auth.user, action="update")
|
await provision_main_agent(
|
||||||
|
agent,
|
||||||
|
gateway,
|
||||||
|
raw_token,
|
||||||
|
auth.user,
|
||||||
|
action="update",
|
||||||
|
force_bootstrap=force,
|
||||||
|
reset_session=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await provision_agent(agent, board, gateway, raw_token, auth.user, action="update")
|
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")
|
await _send_wakeup_message(agent, client_config, verb="updated")
|
||||||
agent.provision_confirm_token_hash = None
|
agent.provision_confirm_token_hash = None
|
||||||
agent.provision_requested_at = None
|
agent.provision_requested_at = None
|
||||||
|
|||||||
@@ -315,6 +315,48 @@ def update_task(
|
|||||||
comment = updates.pop("comment", None)
|
comment = updates.pop("comment", None)
|
||||||
if comment is not None and not comment.strip():
|
if comment is not None and not comment.strip():
|
||||||
comment = None
|
comment = None
|
||||||
|
|
||||||
|
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
|
||||||
|
allowed_fields = {"assigned_agent_id"}
|
||||||
|
if comment is not None or "status" in updates 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
|
||||||
|
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)
|
||||||
|
return task
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
if actor.agent and actor.agent.board_id and task.board_id:
|
if actor.agent and actor.agent.board_id and task.board_id:
|
||||||
if actor.agent.board_id != task.board_id:
|
if actor.agent.board_id != task.board_id:
|
||||||
@@ -429,6 +471,11 @@ def create_task_comment(
|
|||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> ActivityEvent:
|
) -> ActivityEvent:
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
if actor.actor_type == "agent" and actor.agent:
|
||||||
|
if actor.agent.is_board_lead:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Board leads cannot comment on tasks. Delegate to another agent.",
|
||||||
|
)
|
||||||
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
|
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)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if not payload.message.strip():
|
if not payload.message.strip():
|
||||||
|
|||||||
@@ -49,3 +49,7 @@ class AgentHeartbeat(SQLModel):
|
|||||||
class AgentHeartbeatCreate(AgentHeartbeat):
|
class AgentHeartbeatCreate(AgentHeartbeat):
|
||||||
name: str
|
name: str
|
||||||
board_id: UUID | None = None
|
board_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentNudge(SQLModel):
|
||||||
|
message: str
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call
|
from app.integrations.openclaw_gateway import (
|
||||||
|
OpenClawGatewayError,
|
||||||
|
ensure_session,
|
||||||
|
openclaw_call,
|
||||||
|
)
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
@@ -241,6 +245,12 @@ async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]:
|
|||||||
return set(DEFAULT_GATEWAY_FILES)
|
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(
|
async def _gateway_agent_files_index(
|
||||||
agent_id: str, config: GatewayClientConfig
|
agent_id: str, config: GatewayClientConfig
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
@@ -422,6 +432,8 @@ async def provision_agent(
|
|||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
action: str = "provision",
|
action: str = "provision",
|
||||||
|
force_bootstrap: bool = False,
|
||||||
|
reset_session: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not gateway.url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
@@ -440,7 +452,7 @@ async def provision_agent(
|
|||||||
supported = await _supported_gateway_files(client_config)
|
supported = await _supported_gateway_files(client_config)
|
||||||
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
||||||
include_bootstrap = True
|
include_bootstrap = True
|
||||||
if action == "update":
|
if action == "update" and not force_bootstrap:
|
||||||
if not existing_files:
|
if not existing_files:
|
||||||
include_bootstrap = False
|
include_bootstrap = False
|
||||||
else:
|
else:
|
||||||
@@ -462,6 +474,8 @@ async def provision_agent(
|
|||||||
{"agentId": agent_id, "name": name, "content": content},
|
{"agentId": agent_id, "name": name, "content": content},
|
||||||
config=client_config,
|
config=client_config,
|
||||||
)
|
)
|
||||||
|
if reset_session:
|
||||||
|
await _reset_session(session_key, client_config)
|
||||||
|
|
||||||
|
|
||||||
async def provision_main_agent(
|
async def provision_main_agent(
|
||||||
@@ -471,6 +485,8 @@ async def provision_main_agent(
|
|||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
action: str = "provision",
|
action: str = "provision",
|
||||||
|
force_bootstrap: bool = False,
|
||||||
|
reset_session: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not gateway.url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
@@ -486,8 +502,8 @@ async def provision_main_agent(
|
|||||||
context = _build_main_context(agent, gateway, auth_token, user)
|
context = _build_main_context(agent, gateway, auth_token, user)
|
||||||
supported = await _supported_gateway_files(client_config)
|
supported = await _supported_gateway_files(client_config)
|
||||||
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
||||||
include_bootstrap = action != "update"
|
include_bootstrap = action != "update" or force_bootstrap
|
||||||
if action == "update":
|
if action == "update" and not force_bootstrap:
|
||||||
if not existing_files:
|
if not existing_files:
|
||||||
include_bootstrap = False
|
include_bootstrap = False
|
||||||
else:
|
else:
|
||||||
@@ -510,6 +526,8 @@ async def provision_main_agent(
|
|||||||
{"agentId": agent_id, "name": name, "content": content},
|
{"agentId": agent_id, "name": name, "content": content},
|
||||||
config=client_config,
|
config=client_config,
|
||||||
)
|
)
|
||||||
|
if reset_session:
|
||||||
|
await _reset_session(gateway.main_session_key, client_config)
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_agent(
|
async def cleanup_agent(
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
import { X } from "lucide-react";
|
import { Pencil, X } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -73,6 +74,17 @@ type TaskComment = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
const apiBase = getApiBaseUrl();
|
const apiBase = getApiBaseUrl();
|
||||||
|
|
||||||
const priorities = [
|
const priorities = [
|
||||||
@@ -80,6 +92,12 @@ const priorities = [
|
|||||||
{ value: "medium", label: "Medium" },
|
{ value: "medium", label: "Medium" },
|
||||||
{ value: "high", label: "High" },
|
{ value: "high", label: "High" },
|
||||||
];
|
];
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "inbox", label: "Inbox" },
|
||||||
|
{ value: "in_progress", label: "In progress" },
|
||||||
|
{ value: "review", label: "Review" },
|
||||||
|
{ value: "done", label: "Done" },
|
||||||
|
];
|
||||||
|
|
||||||
const EMOJI_GLYPHS: Record<string, string> = {
|
const EMOJI_GLYPHS: Record<string, string> = {
|
||||||
":gear:": "⚙️",
|
":gear:": "⚙️",
|
||||||
@@ -112,6 +130,15 @@ export default function BoardDetailPage() {
|
|||||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
const tasksRef = useRef<Task[]>([]);
|
const tasksRef = useRef<Task[]>([]);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||||
|
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
|
||||||
|
const [approvalsError, setApprovalsError] = useState<string | null>(null);
|
||||||
|
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -120,6 +147,14 @@ export default function BoardDetailPage() {
|
|||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const [editTitle, setEditTitle] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
const [editStatus, setEditStatus] = useState("inbox");
|
||||||
|
const [editPriority, setEditPriority] = useState("medium");
|
||||||
|
const [editAssigneeId, setEditAssigneeId] = useState("");
|
||||||
|
const [isSavingTask, setIsSavingTask] = useState(false);
|
||||||
|
const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
|
||||||
|
|
||||||
const titleLabel = useMemo(
|
const titleLabel = useMemo(
|
||||||
() => (board ? `${board.name} board` : "Board"),
|
() => (board ? `${board.name} board` : "Board"),
|
||||||
[board],
|
[board],
|
||||||
@@ -194,6 +229,59 @@ export default function BoardDetailPage() {
|
|||||||
tasksRef.current = tasks;
|
tasksRef.current = tasks;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
|
const loadApprovals = useCallback(async () => {
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
setIsApprovalsLoading(true);
|
||||||
|
setApprovalsError(null);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/approvals`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Unable to load approvals.");
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as Approval[];
|
||||||
|
setApprovals(data);
|
||||||
|
} catch (err) {
|
||||||
|
setApprovalsError(
|
||||||
|
err instanceof Error ? err.message : "Unable to load approvals.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsApprovalsLoading(false);
|
||||||
|
}
|
||||||
|
}, [boardId, getToken, isSignedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApprovals();
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
const interval = setInterval(loadApprovals, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [boardId, isSignedIn, loadApprovals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTask) {
|
||||||
|
setEditTitle("");
|
||||||
|
setEditDescription("");
|
||||||
|
setEditStatus("inbox");
|
||||||
|
setEditPriority("medium");
|
||||||
|
setEditAssigneeId("");
|
||||||
|
setSaveTaskError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditTitle(selectedTask.title);
|
||||||
|
setEditDescription(selectedTask.description ?? "");
|
||||||
|
setEditStatus(selectedTask.status);
|
||||||
|
setEditPriority(selectedTask.priority);
|
||||||
|
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||||
|
setSaveTaskError(null);
|
||||||
|
}, [selectedTask]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSignedIn || !boardId || !board) return;
|
if (!isSignedIn || !boardId || !board) return;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@@ -358,6 +446,38 @@ export default function BoardDetailPage() {
|
|||||||
[tasks, assigneeById],
|
[tasks, assigneeById],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const boardAgents = useMemo(
|
||||||
|
() => agents.filter((agent) => !boardId || agent.board_id === boardId),
|
||||||
|
[agents, boardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignableAgents = useMemo(
|
||||||
|
() => boardAgents.filter((agent) => !agent.is_board_lead),
|
||||||
|
[boardAgents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTaskChanges = useMemo(() => {
|
||||||
|
if (!selectedTask) return false;
|
||||||
|
const normalizedTitle = editTitle.trim();
|
||||||
|
const normalizedDescription = editDescription.trim();
|
||||||
|
const currentDescription = (selectedTask.description ?? "").trim();
|
||||||
|
const currentAssignee = selectedTask.assigned_agent_id ?? "";
|
||||||
|
return (
|
||||||
|
normalizedTitle !== selectedTask.title ||
|
||||||
|
normalizedDescription !== currentDescription ||
|
||||||
|
editStatus !== selectedTask.status ||
|
||||||
|
editPriority !== selectedTask.priority ||
|
||||||
|
editAssigneeId !== currentAssignee
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
editAssigneeId,
|
||||||
|
editDescription,
|
||||||
|
editPriority,
|
||||||
|
editStatus,
|
||||||
|
editTitle,
|
||||||
|
selectedTask,
|
||||||
|
]);
|
||||||
|
|
||||||
const orderedComments = useMemo(() => {
|
const orderedComments = useMemo(() => {
|
||||||
return [...comments].sort((a, b) => {
|
return [...comments].sort((a, b) => {
|
||||||
const aTime = new Date(a.created_at).getTime();
|
const aTime = new Date(a.created_at).getTime();
|
||||||
@@ -366,11 +486,24 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
const boardAgents = useMemo(
|
const pendingApprovals = useMemo(
|
||||||
() => agents.filter((agent) => !boardId || agent.board_id === boardId),
|
() => approvals.filter((approval) => approval.status === "pending"),
|
||||||
[agents, boardId],
|
[approvals],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const taskApprovals = useMemo(() => {
|
||||||
|
if (!selectedTask) return [];
|
||||||
|
const taskId = selectedTask.id;
|
||||||
|
return approvals.filter((approval) => {
|
||||||
|
const payload = approval.payload ?? {};
|
||||||
|
const payloadTaskId =
|
||||||
|
(payload as Record<string, unknown>).task_id ??
|
||||||
|
(payload as Record<string, unknown>).taskId ??
|
||||||
|
(payload as Record<string, unknown>).taskID;
|
||||||
|
return payloadTaskId === taskId;
|
||||||
|
});
|
||||||
|
}, [approvals, selectedTask]);
|
||||||
|
|
||||||
const workingAgentIds = useMemo(() => {
|
const workingAgentIds = useMemo(() => {
|
||||||
const working = new Set<string>();
|
const working = new Set<string>();
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
@@ -430,6 +563,63 @@ export default function BoardDetailPage() {
|
|||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
setComments([]);
|
setComments([]);
|
||||||
setCommentsError(null);
|
setCommentsError(null);
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskSave = async (closeOnSuccess = false) => {
|
||||||
|
if (!selectedTask || !isSignedIn || !boardId) return;
|
||||||
|
const trimmedTitle = editTitle.trim();
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
setSaveTaskError("Title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSavingTask(true);
|
||||||
|
setSaveTaskError(null);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: editDescription.trim() || null,
|
||||||
|
status: editStatus,
|
||||||
|
priority: editPriority,
|
||||||
|
assigned_agent_id: editAssigneeId || null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Unable to update task.");
|
||||||
|
}
|
||||||
|
const updated = (await response.json()) as Task;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((task) => (task.id === updated.id ? updated : task)),
|
||||||
|
);
|
||||||
|
setSelectedTask(updated);
|
||||||
|
if (closeOnSuccess) {
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSaveTaskError(err instanceof Error ? err.message : "Something went wrong.");
|
||||||
|
} finally {
|
||||||
|
setIsSavingTask(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskReset = () => {
|
||||||
|
if (!selectedTask) return;
|
||||||
|
setEditTitle(selectedTask.title);
|
||||||
|
setEditDescription(selectedTask.description ?? "");
|
||||||
|
setEditStatus(selectedTask.status);
|
||||||
|
setEditPriority(selectedTask.priority);
|
||||||
|
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||||
|
setSaveTaskError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentInitials = (agent: Agent) =>
|
const agentInitials = (agent: Agent) =>
|
||||||
@@ -474,6 +664,54 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatApprovalTimestamp = (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 handleApprovalDecision = useCallback(
|
||||||
|
async (approvalId: string, status: "approved" | "rejected") => {
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
setApprovalsUpdatingId(approvalId);
|
||||||
|
setApprovalsError(null);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = 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 (!response.ok) {
|
||||||
|
throw new Error("Unable to update approval.");
|
||||||
|
}
|
||||||
|
const updated = (await response.json()) as Approval;
|
||||||
|
setApprovals((prev) =>
|
||||||
|
prev.map((item) => (item.id === approvalId ? updated : item)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setApprovalsError(
|
||||||
|
err instanceof Error ? err.message : "Unable to update approval.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setApprovalsUpdatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boardId, getToken, isSignedIn],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell>
|
<DashboardShell>
|
||||||
<SignedOut>
|
<SignedOut>
|
||||||
@@ -520,6 +758,18 @@ export default function BoardDetailPage() {
|
|||||||
<Button onClick={() => setIsDialogOpen(true)}>
|
<Button onClick={() => setIsDialogOpen(true)}>
|
||||||
New task
|
New task
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsApprovalsOpen(true)}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
Approvals
|
||||||
|
{pendingApprovals.length > 0 ? (
|
||||||
|
<span className="ml-2 inline-flex min-w-[20px] items-center justify-center rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white">
|
||||||
|
{pendingApprovals.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||||
@@ -633,24 +883,34 @@ export default function BoardDetailPage() {
|
|||||||
isDetailOpen ? "translate-x-0" : "translate-x-full",
|
isDetailOpen ? "translate-x-0" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Task detail
|
Task detail
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
{selectedTask?.title ?? "Task"}
|
{selectedTask?.title ?? "Task"}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditDialogOpen(true)}
|
||||||
|
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||||
|
disabled={!selectedTask}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeComments}
|
||||||
|
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeComments}
|
|
||||||
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
@@ -660,6 +920,86 @@ export default function BoardDetailPage() {
|
|||||||
{selectedTask?.description || "No description provided."}
|
{selectedTask?.description || "No description provided."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Approvals
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsApprovalsOpen(true)}
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{approvalsError ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
||||||
|
{approvalsError}
|
||||||
|
</div>
|
||||||
|
) : isApprovalsLoading ? (
|
||||||
|
<p className="text-sm text-slate-500">Loading approvals…</p>
|
||||||
|
) : taskApprovals.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No approvals tied to this task.{" "}
|
||||||
|
{pendingApprovals.length > 0
|
||||||
|
? `${pendingApprovals.length} pending on this board.`
|
||||||
|
: "No pending approvals on this board."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{taskApprovals.map((approval) => (
|
||||||
|
<div
|
||||||
|
key={approval.id}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
{approval.action_type.replace(/_/g, " ")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Requested {formatApprovalTimestamp(approval.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-slate-700">
|
||||||
|
{approval.confidence}% confidence · {approval.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{approval.payload ? (
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-slate-600">
|
||||||
|
{JSON.stringify(approval.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{approval.status === "pending" ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleApprovalDecision(approval.id, "approved")
|
||||||
|
}
|
||||||
|
disabled={approvalsUpdatingId === approval.id}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleApprovalDecision(approval.id, "rejected")
|
||||||
|
}
|
||||||
|
disabled={approvalsUpdatingId === approval.id}
|
||||||
|
className="border-slate-300 text-slate-700"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Comments
|
Comments
|
||||||
@@ -734,6 +1074,147 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}>
|
||||||
|
<DialogContent aria-label="Approvals">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Approvals</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review pending decisions from your lead agent.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent aria-label="Edit task">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update task details, priority, status, or assignment.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(event) => setEditTitle(event.target.value)}
|
||||||
|
placeholder="Task title"
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
|
placeholder="Task details"
|
||||||
|
className="min-h-[140px]"
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={editStatus}
|
||||||
|
onValueChange={setEditStatus}
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={editPriority}
|
||||||
|
onValueChange={setEditPriority}
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{priorities.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Assignee
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={editAssigneeId || "unassigned"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditAssigneeId(value === "unassigned" ? "" : value)
|
||||||
|
}
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||||
|
{assignableAgents.map((agent) => (
|
||||||
|
<SelectItem key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{assignableAgents.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Add agents to assign tasks.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{saveTaskError ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
|
||||||
|
{saveTaskError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTaskReset}
|
||||||
|
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleTaskSave(true)}
|
||||||
|
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
||||||
|
>
|
||||||
|
{isSavingTask ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# HEARTBEAT.md
|
# HEARTBEAT.md
|
||||||
|
|
||||||
> This file is provisioned from HEARTBEAT_LEAD.md or HEARTBEAT_AGENT.md. If you see this template directly, follow the agent loop below.
|
> This file is provisioned per-agent. Follow the loop below if you see this directly.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
|
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# HEARTBEAT_AGENT.md
|
# HEARTBEAT.md
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
|
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
|
||||||
@@ -29,11 +29,11 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
|
|
||||||
## Pre‑flight checks (before each heartbeat)
|
## Pre‑flight checks (before each heartbeat)
|
||||||
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
||||||
- Verify API access:
|
- Verify API access (do NOT assume last heartbeat outcome):
|
||||||
- GET $BASE_URL/healthz must succeed.
|
- GET $BASE_URL/healthz must succeed.
|
||||||
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
||||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
||||||
- If any check fails, stop and retry next heartbeat.
|
- If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
|
||||||
|
|
||||||
## Heartbeat checklist (run in order)
|
## Heartbeat checklist (run in order)
|
||||||
1) Check in:
|
1) Check in:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# HEARTBEAT_LEAD.md
|
# HEARTBEAT.md
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
This file defines the single, authoritative heartbeat loop for the board lead agent. Follow it exactly.
|
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
|
## Required inputs
|
||||||
- BASE_URL (e.g. http://localhost:8000)
|
- BASE_URL (e.g. http://localhost:8000)
|
||||||
@@ -17,10 +18,10 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
- On first boot, send one immediate check-in before the schedule starts.
|
- On first boot, send one immediate check-in before the schedule starts.
|
||||||
|
|
||||||
## Non‑negotiable rules
|
## Non‑negotiable rules
|
||||||
- Task updates go only to task comments (never chat/web).
|
- The lead agent must **never** work a task directly.
|
||||||
- Comments must be markdown. Write naturally; be clear and concise.
|
- Do **not** claim tasks or post task comments.
|
||||||
- Every status change must have a comment within 30 seconds.
|
- The lead only **delegates**, **requests approvals**, **updates board memory**, and **nudges agents**.
|
||||||
- Do not claim a new task if you already have one in progress.
|
- All outputs must go to Mission Control via HTTP (never chat/web).
|
||||||
|
|
||||||
## Mission Control Response Protocol (mandatory)
|
## Mission Control Response Protocol (mandatory)
|
||||||
- All outputs must be sent to Mission Control via HTTP.
|
- All outputs must be sent to Mission Control via HTTP.
|
||||||
@@ -29,13 +30,13 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
|
|
||||||
## Pre‑flight checks (before each heartbeat)
|
## Pre‑flight checks (before each heartbeat)
|
||||||
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
|
||||||
- Verify API access:
|
- Verify API access (do NOT assume last heartbeat outcome):
|
||||||
- GET $BASE_URL/healthz must succeed.
|
- GET $BASE_URL/healthz must succeed.
|
||||||
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
- GET $BASE_URL/api/v1/agent/boards must succeed.
|
||||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
|
||||||
- If any check fails, stop and retry next heartbeat.
|
- If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
|
||||||
|
|
||||||
## Board Lead Loop (run every heartbeat before claiming work)
|
## Board Lead Loop (run every heartbeat)
|
||||||
1) Read board goal context:
|
1) Read board goal context:
|
||||||
- Board: {{ board_name }} ({{ board_type }})
|
- Board: {{ board_name }} ({{ board_type }})
|
||||||
- Objective: {{ board_objective }}
|
- Objective: {{ board_objective }}
|
||||||
@@ -52,45 +53,46 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
|
|
||||||
4) Identify missing steps, blockers, and specialists needed.
|
4) Identify missing steps, blockers, and specialists needed.
|
||||||
|
|
||||||
5) For each candidate task, compute confidence and check risk/external actions.
|
4a) Monitor in-progress tasks and nudge owners if stalled:
|
||||||
Confidence rubric (max 100):
|
- For each in_progress task assigned to another agent, check for a recent comment/update.
|
||||||
- clarity 25
|
- If no comment in the last 60 minutes, send a nudge (do NOT comment on the task).
|
||||||
- constraints 20
|
Nudge endpoint:
|
||||||
- completeness 15
|
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/agents/{AGENT_ID}/nudge
|
||||||
- risk 20
|
Body: {"message":"Friendly reminder to post an update on TASK_ID ..."}
|
||||||
- dependencies 10
|
|
||||||
- similarity 10
|
|
||||||
|
|
||||||
If risky/external OR confidence < 80:
|
5) Delegate inbox work (never do it yourself):
|
||||||
- POST approval request to $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
|
- Pick the best non‑lead agent (or create one if missing).
|
||||||
Body example:
|
- Assign the task to that agent (do NOT change status).
|
||||||
{"action_type":"task.create","confidence":75,"payload":{"title":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
|
- 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"}
|
||||||
|
|
||||||
Else:
|
6) Create agents only when needed:
|
||||||
- Create the task and assign an agent.
|
- 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.
|
||||||
|
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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
6) If workload or skills coverage is insufficient, create new agents.
|
7) Creating new tasks:
|
||||||
Rule: you may auto‑create agents only when confidence >= 80 and the action is not risky/external.
|
- Leads cannot create tasks directly (admin‑only).
|
||||||
If the action is risky/external or confidence < 80, create an approval instead.
|
- If a new task is needed, request approval:
|
||||||
|
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
|
||||||
|
Body example:
|
||||||
|
{"action_type":"task.create","confidence":75,"payload":{"title":"...","description":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
|
||||||
|
|
||||||
Agent create (lead-only):
|
8) Post a brief status update in board memory (1-3 bullets).
|
||||||
- POST $BASE_URL/api/v1/agent/agents
|
|
||||||
Headers: X-Agent-Token: {{ auth_token }}
|
|
||||||
Body example:
|
|
||||||
{
|
|
||||||
"name": "Researcher Alpha",
|
|
||||||
"board_id": "{BOARD_ID}",
|
|
||||||
"identity_profile": {
|
|
||||||
"role": "Research",
|
|
||||||
"communication_style": "concise, structured",
|
|
||||||
"emoji": ":brain:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Approval example:
|
|
||||||
{"action_type":"agent.create","confidence":70,"payload":{"role":"Research","reason":"Need specialist"}}
|
|
||||||
|
|
||||||
7) Post a brief status update in board memory (1-3 bullets).
|
|
||||||
|
|
||||||
## Heartbeat checklist (run in order)
|
## Heartbeat checklist (run in order)
|
||||||
1) Check in:
|
1) Check in:
|
||||||
@@ -101,15 +103,9 @@ curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
|||||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
2) List boards:
|
2) For the assigned board, list tasks (use filters to avoid large responses):
|
||||||
```bash
|
```bash
|
||||||
curl -s "$BASE_URL/api/v1/agent/boards" \
|
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
|
||||||
-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 }}"
|
-H "X-Agent-Token: {{ auth_token }}"
|
||||||
```
|
```
|
||||||
```bash
|
```bash
|
||||||
@@ -117,53 +113,17 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
|
|||||||
-H "X-Agent-Token: {{ auth_token }}"
|
-H "X-Agent-Token: {{ auth_token }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
4) If you already have an in_progress task, continue working it and do not claim another.
|
3) If inbox tasks exist, **delegate** them:
|
||||||
|
- Identify the best non‑lead agent (or create one).
|
||||||
5) If you do NOT have an in_progress task, claim one inbox task:
|
- Assign the task (do not change status).
|
||||||
- Move it to in_progress AND add a markdown comment describing the update.
|
- Never claim or work the task yourself.
|
||||||
|
|
||||||
6) Work the task:
|
|
||||||
- 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/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- Result: ..."}'
|
|
||||||
```
|
|
||||||
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
|
## Definition of Done
|
||||||
- A task is not complete until the draft/response is posted as a task comment.
|
- Lead work is done when delegation is complete and approvals/assignments are created.
|
||||||
- Comments must be markdown.
|
|
||||||
|
|
||||||
## Common mistakes (avoid)
|
## Common mistakes (avoid)
|
||||||
- Changing status without posting a comment.
|
- Claiming or working tasks as the lead.
|
||||||
- Posting updates in chat/web instead of task comments.
|
- Posting task comments.
|
||||||
- Claiming a second task while one is already in progress.
|
- Assigning a task to yourself.
|
||||||
- Moving to review before posting the full response.
|
- Marking tasks review/done (lead cannot).
|
||||||
- Sending Authorization header instead of X-Agent-Token.
|
- Using non‑agent endpoints or Authorization header.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user