From af8a263c2714a9c662bb3fecfd4ede329917aae4 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 20:27:04 +0530 Subject: [PATCH] feat: add approval-task links model and related functionality for task associations --- backend/app/api/approvals.py | 179 ++++++++++------- backend/app/api/organizations.py | 9 + backend/app/api/tasks.py | 20 +- backend/app/api/users.py | 9 + backend/app/models/__init__.py | 2 + backend/app/models/approval_task_links.py | 32 +++ backend/app/schemas/approvals.py | 20 +- backend/app/services/approval_task_links.py | 190 ++++++++++++++++++ backend/app/services/board_lifecycle.py | 8 + backend/app/services/board_snapshot.py | 45 ++--- .../f4d2b649e93a_add_approval_task_links.py | 137 +++++++++++++ backend/templates/AGENTS.md | 3 + backend/templates/HEARTBEAT_AGENT.md | 1 + backend/templates/HEARTBEAT_LEAD.md | 10 +- backend/tests/test_approval_task_links.py | 147 ++++++++++++++ .../test_approvals_lead_notifications.py | 30 ++- .../tests/test_organizations_delete_api.py | 1 + frontend/src/app/boards/[boardId]/page.tsx | 126 +++++++++--- .../src/components/BoardApprovalsPanel.tsx | 30 ++- 19 files changed, 870 insertions(+), 129 deletions(-) create mode 100644 backend/app/models/approval_task_links.py create mode 100644 backend/app/services/approval_task_links.py create mode 100644 backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py create mode 100644 backend/tests/test_approval_task_links.py diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py index 0a25cd28..edcada9c 100644 --- a/backend/app/api/approvals.py +++ b/backend/app/api/approvals.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from sqlalchemy import asc, case, func, or_ +from sqlalchemy import asc, func, or_ from sqlmodel import col, select from sse_starlette.sse import EventSourceResponse @@ -29,10 +29,16 @@ from app.models.approvals import Approval from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate from app.schemas.pagination import DefaultLimitOffsetPage from app.services.activity_log import record_activity +from app.services.approval_task_links import ( + load_task_ids_by_approval, + normalize_task_ids, + replace_approval_task_links, + task_counts_for_board, +) from app.services.openclaw.gateway_dispatch import GatewayDispatchService if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Sequence from fastapi_pagination.limit_offset import LimitOffsetPage from sqlmodel.ext.asyncio.session import AsyncSession @@ -42,7 +48,6 @@ if TYPE_CHECKING: router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"]) logger = get_logger(__name__) -TASK_ID_KEYS: tuple[str, ...] = ("task_id", "taskId", "taskID") STREAM_POLL_SECONDS = 2 STATUS_FILTER_QUERY = Query(default=None, alias="status") SINCE_QUERY = Query(default=None) @@ -53,21 +58,6 @@ SESSION_DEP = Depends(get_session) ACTOR_DEP = Depends(require_admin_or_agent) -def _extract_task_id(payload: dict[str, object] | None) -> UUID | None: - if not payload: - return None - for key in TASK_ID_KEYS: - value = payload.get(key) - if isinstance(value, UUID): - return value - if isinstance(value, str): - try: - return UUID(value) - except ValueError: - continue - return None - - def _parse_since(value: str | None) -> datetime | None: if not value: return None @@ -88,17 +78,47 @@ 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") +async def _approval_task_ids_map( + session: AsyncSession, + approvals: Sequence[Approval], +) -> dict[UUID, list[UUID]]: + approval_ids = [approval.id for approval in approvals] + mapping = await load_task_ids_by_approval(session, approval_ids=approval_ids) + for approval in approvals: + if mapping.get(approval.id): + continue + if approval.task_id is not None: + mapping[approval.id] = [approval.task_id] + else: + mapping[approval.id] = [] + return mapping + + +def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead: + primary_task_id = task_ids[0] if task_ids else None + model = ApprovalRead.model_validate(approval, from_attributes=True) + return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids}) + + +async def _approval_reads( + session: AsyncSession, + approvals: Sequence[Approval], +) -> list[ApprovalRead]: + mapping = await _approval_task_ids_map(session, approvals) + return [ + _approval_to_read(approval, task_ids=mapping.get(approval.id, [])) for approval in approvals + ] + + +def _serialize_approval(approval: ApprovalRead) -> dict[str, object]: + return approval.model_dump(mode="json") def _approval_resolution_message( *, board: Board, approval: Approval, + task_ids: Sequence[UUID] | None = None, ) -> str: status_text = "approved" if approval.status == "approved" else "rejected" lines = [ @@ -109,8 +129,13 @@ def _approval_resolution_message( f"Decision: {status_text}", f"Confidence: {approval.confidence}", ] - if approval.task_id is not None: - lines.append(f"Task ID: {approval.task_id}") + normalized_task_ids = list(task_ids or []) + if not normalized_task_ids and approval.task_id is not None: + normalized_task_ids = [approval.task_id] + if len(normalized_task_ids) == 1: + lines.append(f"Task ID: {normalized_task_ids[0]}") + elif normalized_task_ids: + lines.append(f"Task IDs: {', '.join(str(value) for value in normalized_task_ids)}") lines.append("") lines.append("Take action: continue execution using the final approval decision.") return "\n".join(lines) @@ -145,7 +170,12 @@ async def _notify_lead_on_approval_resolution( if config is None: return - message = _approval_resolution_message(board=board, approval=approval) + task_ids_by_approval = await load_task_ids_by_approval(session, approval_ids=[approval.id]) + message = _approval_resolution_message( + board=board, + approval=approval, + task_ids=task_ids_by_approval.get(approval.id, []), + ) error = await dispatch.try_send_agent_message( session_key=lead.openclaw_session_id, config=config, @@ -202,7 +232,17 @@ async def list_approvals( if status_filter: statement = statement.filter(col(Approval.status) == status_filter) statement = statement.order_by(col(Approval.created_at).desc()) - return await paginate(session, statement.statement) + + async def _transform(items: Sequence[object]) -> Sequence[ApprovalRead]: + approvals: list[Approval] = [] + for item in items: + if not isinstance(item, Approval): + msg = "Expected Approval items from approvals pagination query." + raise TypeError(msg) + approvals.append(item) + return await _approval_reads(session, approvals) + + return await paginate(session, statement.statement, transformer=_transform) @router.get("/stream") @@ -223,6 +263,7 @@ async def stream_approvals( break async with async_session_maker() as session: approvals = await _fetch_approval_events(session, board.id, last_seen) + approval_reads = await _approval_reads(session, approvals) pending_approvals_count = int( ( await session.exec( @@ -233,50 +274,36 @@ async def stream_approvals( ).one(), ) task_ids = { - approval.task_id for approval in approvals if approval.task_id is not None + task_id + for approval_read in approval_reads + for task_id in approval_read.task_ids } - counts_by_task_id: dict[UUID, tuple[int, int]] = {} - if task_ids: - rows = list( - await session.exec( - select( - col(Approval.task_id), - func.count(col(Approval.id)).label("total"), - func.sum( - case( - (col(Approval.status) == "pending", 1), - else_=0, - ), - ).label("pending"), - ) - .where(col(Approval.board_id) == board.id) - .where(col(Approval.task_id).in_(task_ids)) - .group_by(col(Approval.task_id)), - ), - ) - for task_id, total, pending in rows: - if task_id is None: - continue - counts_by_task_id[task_id] = ( - int(total or 0), - int(pending or 0), - ) - for approval in approvals: + counts_by_task_id = await task_counts_for_board( + session, + board_id=board.id, + task_ids=task_ids, + ) + for approval, approval_read in zip(approvals, approval_reads, strict=True): updated_at = _approval_updated_at(approval) last_seen = max(updated_at, last_seen) payload: dict[str, object] = { - "approval": _serialize_approval(approval), + "approval": _serialize_approval(approval_read), "pending_approvals_count": pending_approvals_count, } - if approval.task_id is not None: - counts = counts_by_task_id.get(approval.task_id) - if counts is not None: - total, pending = counts - payload["task_counts"] = { - "task_id": str(approval.task_id), - "approvals_count": total, - "approvals_pending_count": pending, - } + task_counts = [ + { + "task_id": str(task_id), + "approvals_count": total, + "approvals_pending_count": pending, + } + for task_id in approval_read.task_ids + if (counts := counts_by_task_id.get(task_id)) is not None + for total, pending in [counts] + ] + if len(task_counts) == 1: + payload["task_counts"] = task_counts[0] + elif task_counts: + payload["task_counts"] = task_counts yield {"event": "approval", "data": json.dumps(payload)} await asyncio.sleep(STREAM_POLL_SECONDS) @@ -289,9 +316,14 @@ async def create_approval( board: Board = BOARD_WRITE_DEP, session: AsyncSession = SESSION_DEP, _actor: ActorContext = ACTOR_DEP, -) -> Approval: +) -> ApprovalRead: """Create an approval for a board.""" - task_id = payload.task_id or _extract_task_id(payload.payload) + task_ids = normalize_task_ids( + task_id=payload.task_id, + task_ids=payload.task_ids, + payload=payload.payload, + ) + task_id = task_ids[0] if task_ids else None approval = Approval( board_id=board.id, task_id=task_id, @@ -303,9 +335,15 @@ async def create_approval( status=payload.status, ) session.add(approval) + await session.flush() + await replace_approval_task_links( + session, + approval_id=approval.id, + task_ids=task_ids, + ) await session.commit() await session.refresh(approval) - return approval + return _approval_to_read(approval, task_ids=task_ids) @router.patch("/{approval_id}", response_model=ApprovalRead) @@ -314,7 +352,7 @@ async def update_approval( payload: ApprovalUpdate, board: Board = BOARD_USER_WRITE_DEP, session: AsyncSession = SESSION_DEP, -) -> Approval: +) -> ApprovalRead: """Update an approval's status and resolution timestamp.""" approval = await Approval.objects.by_id(approval_id).first(session) if approval is None or approval.board_id != board.id: @@ -342,4 +380,5 @@ async def update_approval( approval.id, approval.status, ) - return approval + reads = await _approval_reads(session, [approval]) + return reads[0] diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index aeb8b881..4a5ae642 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -18,6 +18,7 @@ from app.db.pagination import paginate from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink from app.models.approvals import Approval from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -269,6 +270,14 @@ async def delete_my_org( col(TaskFingerprint.board_id).in_(board_ids), commit=False, ) + await crud.delete_where( + session, + ApprovalTaskLink, + col(ApprovalTaskLink.approval_id).in_( + select(Approval.id).where(col(Approval.board_id).in_(board_ids)) + ), + commit=False, + ) await crud.delete_where( session, Approval, diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index d586ad47..4874eabd 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -29,6 +29,7 @@ from app.db.pagination import paginate from app.db.session import async_session_maker, get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink from app.models.approvals import Approval from app.models.boards import Board from app.models.task_dependencies import TaskDependency @@ -39,6 +40,7 @@ from app.schemas.errors import BlockedTaskError from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity +from app.services.approval_task_links import load_task_ids_by_approval from app.services.mentions import extract_mentions, matches_agent_mention from app.services.openclaw.gateway_dispatch import GatewayDispatchService from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig @@ -922,12 +924,26 @@ async def delete_task( col(TaskFingerprint.task_id) == task.id, commit=False, ) + + primary_approvals = list( + await Approval.objects.filter(col(Approval.task_id) == task.id).all(session), + ) await crud.delete_where( session, - Approval, - col(Approval.task_id) == task.id, + ApprovalTaskLink, + col(ApprovalTaskLink.task_id) == task.id, commit=False, ) + if primary_approvals: + primary_ids = [approval.id for approval in primary_approvals] + remaining_by_approval = await load_task_ids_by_approval(session, approval_ids=primary_ids) + for approval in primary_approvals: + remaining_task_ids = remaining_by_approval.get(approval.id, []) + if remaining_task_ids: + approval.task_id = remaining_task_ids[0] + session.add(approval) + continue + await session.delete(approval) await crud.delete_where( session, TaskDependency, diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 83371441..1ec5351e 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -13,6 +13,7 @@ from app.db import crud from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink from app.models.approvals import Approval from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -83,6 +84,14 @@ async def _delete_organization_tree( col(TaskFingerprint.board_id).in_(board_ids), commit=False, ) + await crud.delete_where( + session, + ApprovalTaskLink, + col(ApprovalTaskLink.approval_id).in_( + select(Approval.id).where(col(Approval.board_id).in_(board_ids)) + ), + commit=False, + ) await crud.delete_where( session, Approval, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2cde418e..c697c186 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink from app.models.approvals import Approval from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -22,6 +23,7 @@ from app.models.users import User __all__ = [ "ActivityEvent", "Agent", + "ApprovalTaskLink", "Approval", "BoardGroupMemory", "BoardMemory", diff --git a/backend/app/models/approval_task_links.py b/backend/app/models/approval_task_links.py new file mode 100644 index 00000000..9a7df374 --- /dev/null +++ b/backend/app/models/approval_task_links.py @@ -0,0 +1,32 @@ +"""Approval-task link model for many-to-many approval associations.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.base import QueryModel + +RUNTIME_ANNOTATION_TYPES = (datetime,) + + +class ApprovalTaskLink(QueryModel, table=True): + """Map an approval request to one task (many links per approval allowed).""" + + __tablename__ = "approval_task_links" # pyright: ignore[reportAssignmentType] + __table_args__ = ( + UniqueConstraint( + "approval_id", + "task_id", + name="uq_approval_task_links_approval_id_task_id", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + approval_id: UUID = Field(foreign_key="approvals.id", index=True) + task_id: UUID = Field(foreign_key="tasks.id", index=True) + created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/approvals.py b/backend/app/schemas/approvals.py index d0c95007..7dd26bd9 100644 --- a/backend/app/schemas/approvals.py +++ b/backend/app/schemas/approvals.py @@ -7,7 +7,7 @@ from typing import Literal, Self from uuid import UUID from pydantic import model_validator -from sqlmodel import SQLModel +from sqlmodel import Field, SQLModel ApprovalStatus = Literal["pending", "approved", "rejected"] STATUS_REQUIRED_ERROR = "status is required" @@ -19,11 +19,29 @@ class ApprovalBase(SQLModel): action_type: str task_id: UUID | None = None + task_ids: list[UUID] = Field(default_factory=list) payload: dict[str, object] | None = None confidence: int rubric_scores: dict[str, int] | None = None status: ApprovalStatus = "pending" + @model_validator(mode="after") + def normalize_task_links(self) -> Self: + """Keep task identifiers deduplicated and task_id aligned with task_ids.""" + deduped: list[UUID] = [] + seen: set[UUID] = set() + if self.task_id is not None: + deduped.append(self.task_id) + seen.add(self.task_id) + for task_id in self.task_ids: + if task_id in seen: + continue + seen.add(task_id) + deduped.append(task_id) + self.task_ids = deduped + self.task_id = deduped[0] if deduped else None + return self + class ApprovalCreate(ApprovalBase): """Payload for creating a new approval request.""" diff --git a/backend/app/services/approval_task_links.py b/backend/app/services/approval_task_links.py new file mode 100644 index 00000000..5ee684f6 --- /dev/null +++ b/backend/app/services/approval_task_links.py @@ -0,0 +1,190 @@ +"""Helpers for normalizing and querying approval-task associations.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import case, delete, exists, func +from sqlmodel import col, select + +from app.models.approval_task_links import ApprovalTaskLink +from app.models.approvals import Approval + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +TASK_ID_KEYS: tuple[str, ...] = ("task_id", "taskId", "taskID") +TASK_IDS_KEYS: tuple[str, ...] = ("task_ids", "taskIds", "taskIDs") + + +def _coerce_uuid(value: object) -> UUID | None: + if isinstance(value, UUID): + return value + if isinstance(value, str): + try: + return UUID(value) + except ValueError: + return None + return None + + +def extract_task_ids(payload: dict[str, object] | None) -> list[UUID]: + """Extract task UUIDs from approval payload aliases.""" + if not payload: + return [] + + collected: list[UUID] = [] + for key in TASK_IDS_KEYS: + raw = payload.get(key) + if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes, bytearray)): + for item in raw: + task_id = _coerce_uuid(item) + if task_id is not None: + collected.append(task_id) + for key in TASK_ID_KEYS: + task_id = _coerce_uuid(payload.get(key)) + if task_id is not None: + collected.append(task_id) + + deduped: list[UUID] = [] + seen: set[UUID] = set() + for task_id in collected: + if task_id in seen: + continue + seen.add(task_id) + deduped.append(task_id) + return deduped + + +def normalize_task_ids( + *, + task_id: UUID | None, + task_ids: Sequence[UUID], + payload: dict[str, object] | None, +) -> list[UUID]: + """Merge explicit and payload-provided task references into an ordered unique list.""" + merged: list[UUID] = [] + merged.extend(task_ids) + if task_id is not None: + merged.append(task_id) + merged.extend(extract_task_ids(payload)) + + deduped: list[UUID] = [] + seen: set[UUID] = set() + for value in merged: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +async def load_task_ids_by_approval( + session: AsyncSession, + *, + approval_ids: Iterable[UUID], +) -> dict[UUID, list[UUID]]: + """Return task ids grouped by approval id in insertion order.""" + ids = list({*approval_ids}) + if not ids: + return {} + + rows = list( + await session.exec( + select(col(ApprovalTaskLink.approval_id), col(ApprovalTaskLink.task_id)) + .where(col(ApprovalTaskLink.approval_id).in_(ids)) + .order_by(col(ApprovalTaskLink.created_at).asc()), + ), + ) + + mapping: dict[UUID, list[UUID]] = {approval_id: [] for approval_id in ids} + for approval_id, task_id in rows: + mapping.setdefault(approval_id, []).append(task_id) + return mapping + + +async def replace_approval_task_links( + session: AsyncSession, + *, + approval_id: UUID, + task_ids: Sequence[UUID], +) -> None: + """Replace approval-task link rows for an approval id.""" + await session.exec( + delete(ApprovalTaskLink).where( + col(ApprovalTaskLink.approval_id) == approval_id, + ), + ) + for task_id in task_ids: + session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task_id)) + + +async def task_counts_for_board( + session: AsyncSession, + *, + board_id: UUID, + task_ids: set[UUID] | None = None, +) -> dict[UUID, tuple[int, int]]: + """Compute total/pending approval counts per task across all linked tasks on a board.""" + + link_statement = ( + select( + col(ApprovalTaskLink.task_id), + func.count(col(Approval.id)).label("total"), + func.sum( + case( + (col(Approval.status) == "pending", 1), + else_=0, + ), + ).label("pending"), + ) + .join(Approval, col(Approval.id) == col(ApprovalTaskLink.approval_id)) + .where(col(Approval.board_id) == board_id) + ) + if task_ids is not None: + if not task_ids: + return {} + link_statement = link_statement.where(col(ApprovalTaskLink.task_id).in_(task_ids)) + link_statement = link_statement.group_by(col(ApprovalTaskLink.task_id)) + + counts: dict[UUID, tuple[int, int]] = {} + for task_id, total, pending in list(await session.exec(link_statement)): + counts[task_id] = (int(total or 0), int(pending or 0)) + + # Backward compatibility: include legacy rows that have task_id set but no link rows. + legacy_statement = ( + select( + col(Approval.task_id), + func.count(col(Approval.id)).label("total"), + func.sum( + case( + (col(Approval.status) == "pending", 1), + else_=0, + ), + ).label("pending"), + ) + .where(col(Approval.board_id) == board_id) + .where(col(Approval.task_id).is_not(None)) + .where( + ~exists( + select(1) + .where(col(ApprovalTaskLink.approval_id) == col(Approval.id)) + .correlate(Approval), + ), + ) + ) + if task_ids is not None: + legacy_statement = legacy_statement.where(col(Approval.task_id).in_(task_ids)) + legacy_statement = legacy_statement.group_by(col(Approval.task_id)) + + for legacy_task_id, total, pending in list(await session.exec(legacy_statement)): + if legacy_task_id is None: + continue + previous = counts.get(legacy_task_id, (0, 0)) + counts[legacy_task_id] = ( + previous[0] + int(total or 0), + previous[1] + int(pending or 0), + ) + return counts diff --git a/backend/app/services/board_lifecycle.py b/backend/app/services/board_lifecycle.py index 26fb2f6c..8527044a 100644 --- a/backend/app/services/board_lifecycle.py +++ b/backend/app/services/board_lifecycle.py @@ -14,6 +14,7 @@ from sqlmodel import col, select from app.db import crud from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink from app.models.approvals import Approval from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession @@ -73,6 +74,13 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse: ) # Approvals can reference tasks and agents, so delete before both. + approval_ids = select(Approval.id).where(col(Approval.board_id) == board.id) + await crud.delete_where( + session, + ApprovalTaskLink, + col(ApprovalTaskLink.approval_id).in_(approval_ids), + commit=False, + ) await crud.delete_where(session, Approval, col(Approval.board_id) == board.id) await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id) diff --git a/backend/app/services/board_snapshot.py b/backend/app/services/board_snapshot.py index aadce967..e235d5c0 100644 --- a/backend/app/services/board_snapshot.py +++ b/backend/app/services/board_snapshot.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from sqlalchemy import case, func +from sqlalchemy import func from sqlmodel import col, select from app.models.agents import Agent @@ -15,6 +15,7 @@ from app.schemas.approvals import ApprovalRead from app.schemas.board_memory import BoardMemoryRead from app.schemas.boards import BoardRead from app.schemas.view_models import BoardSnapshot, TaskCardRead +from app.services.approval_task_links import load_task_ids_by_approval, task_counts_for_board from app.services.openclaw.provisioning_db import AgentLifecycleService from app.services.task_dependencies import ( blocked_by_dependency_ids, @@ -34,8 +35,10 @@ def _memory_to_read(memory: BoardMemory) -> BoardMemoryRead: return BoardMemoryRead.model_validate(memory, from_attributes=True) -def _approval_to_read(approval: Approval) -> ApprovalRead: - return ApprovalRead.model_validate(approval, from_attributes=True) +def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead: + model = ApprovalRead.model_validate(approval, from_attributes=True) + primary_task_id = task_ids[0] if task_ids else None + return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids}) def _task_to_card( @@ -120,27 +123,23 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap .limit(200) .all(session) ) - approval_reads = [_approval_to_read(approval) for approval in approvals] - - counts_by_task_id: dict[UUID, tuple[int, int]] = {} - rows = list( - await session.exec( - select( - col(Approval.task_id), - func.count(col(Approval.id)).label("total"), - func.sum( - case((col(Approval.status) == "pending", 1), else_=0), - ).label("pending"), - ) - .where(col(Approval.board_id) == board.id) - .where(col(Approval.task_id).is_not(None)) - .group_by(col(Approval.task_id)), - ), + approval_ids = [approval.id for approval in approvals] + task_ids_by_approval = await load_task_ids_by_approval( + session, + approval_ids=approval_ids, ) - for task_id, total, pending in rows: - if task_id is None: - continue - counts_by_task_id[task_id] = (int(total or 0), int(pending or 0)) + approval_reads = [ + _approval_to_read( + approval, + task_ids=task_ids_by_approval.get( + approval.id, + [approval.task_id] if approval.task_id is not None else [], + ), + ) + for approval in approvals + ] + + counts_by_task_id = await task_counts_for_board(session, board_id=board.id) task_cards = [ _task_to_card( diff --git a/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py b/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py new file mode 100644 index 00000000..18d23dc7 --- /dev/null +++ b/backend/migrations/versions/f4d2b649e93a_add_approval_task_links.py @@ -0,0 +1,137 @@ +"""add approval task links + +Revision ID: f4d2b649e93a +Revises: c3b58a391f2e +Create Date: 2026-02-11 20:05:00.000000 + +""" + +from __future__ import annotations + +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f4d2b649e93a" +down_revision = "c3b58a391f2e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("approval_task_links"): + op.create_table( + "approval_task_links", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("approval_id", sa.Uuid(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["approval_id"], ["approvals.id"]), + sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "approval_id", + "task_id", + name="uq_approval_task_links_approval_id_task_id", + ), + ) + else: + target_unique_columns = ("approval_id", "task_id") + unique_constraints = inspector.get_unique_constraints("approval_task_links") + has_target_unique = False + for item in unique_constraints: + columns = tuple(item.get("column_names") or ()) + if columns == target_unique_columns: + has_target_unique = True + break + if not has_target_unique: + op.create_unique_constraint( + "uq_approval_task_links_approval_id_task_id", + "approval_task_links", + ["approval_id", "task_id"], + ) + + indexes = inspector.get_indexes("approval_task_links") + has_approval_id_index = any( + tuple(item.get("column_names") or ()) == ("approval_id",) for item in indexes + ) + has_task_id_index = any(tuple(item.get("column_names") or ()) == ("task_id",) for item in indexes) + if not has_approval_id_index: + op.create_index( + op.f("ix_approval_task_links_approval_id"), + "approval_task_links", + ["approval_id"], + unique=False, + ) + if not has_task_id_index: + op.create_index( + op.f("ix_approval_task_links_task_id"), + "approval_task_links", + ["task_id"], + unique=False, + ) + + link_table = sa.table( + "approval_task_links", + sa.column("id", sa.Uuid()), + sa.column("approval_id", sa.Uuid()), + sa.column("task_id", sa.Uuid()), + sa.column("created_at", sa.DateTime()), + ) + approvals_table = sa.table( + "approvals", + sa.column("id", sa.Uuid()), + sa.column("task_id", sa.Uuid()), + sa.column("created_at", sa.DateTime()), + ) + rows = list( + bind.execute( + sa.select( + approvals_table.c.id, + approvals_table.c.task_id, + approvals_table.c.created_at, + ) + .select_from(approvals_table) + .where(approvals_table.c.task_id.is_not(None)), + ), + ) + existing_links = { + (approval_id, task_id) + for approval_id, task_id in list( + bind.execute( + sa.select( + sa.column("approval_id"), + sa.column("task_id"), + ).select_from(sa.table("approval_task_links")), + ), + ) + } + missing_rows = [ + (approval_id, task_id, created_at) + for approval_id, task_id, created_at in rows + if (approval_id, task_id) not in existing_links + ] + if missing_rows: + op.bulk_insert( + link_table, + [ + { + "id": uuid4(), + "approval_id": approval_id, + "task_id": task_id, + "created_at": created_at, + } + for approval_id, task_id, created_at in missing_rows + ], + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_approval_task_links_task_id"), table_name="approval_task_links") + op.drop_index(op.f("ix_approval_task_links_approval_id"), table_name="approval_task_links") + op.drop_table("approval_task_links") diff --git a/backend/templates/AGENTS.md b/backend/templates/AGENTS.md index 8a4a7507..44c65edc 100644 --- a/backend/templates/AGENTS.md +++ b/backend/templates/AGENTS.md @@ -91,6 +91,9 @@ If you create cron jobs, track them in memory and delete them when no longer nee - Task comments: primary work log (markdown is OK; keep it structured and scannable). - Board chat: only for questions/decisions that require a human response. Keep it short. Do not spam. Do not post task status updates. - Approvals: use for explicit yes/no on external or risky actions. + - Approvals may be linked to one or more tasks. + - Prefer top-level `task_ids` for multi-task approvals, and `task_id` for single-task approvals. + - When adding task references in `payload`, keep `payload.task_ids`/`payload.task_id` consistent with top-level fields. - `TASK_SOUL.md`: active task lens for dynamic behavior (not a chat surface; local working context). ## Collaboration (mandatory) diff --git a/backend/templates/HEARTBEAT_AGENT.md b/backend/templates/HEARTBEAT_AGENT.md index da7c848a..ddbaf1ed 100644 --- a/backend/templates/HEARTBEAT_AGENT.md +++ b/backend/templates/HEARTBEAT_AGENT.md @@ -31,6 +31,7 @@ If any required input is missing, stop and request a provisioning update. - Do not claim a new task if you already have one in progress. - Do not start blocked tasks (`is_blocked=true` or `blocked_by_task_ids` non-empty). - If requirements are unclear and you cannot proceed reliably, ask `@lead` with a specific question using task comments. +- If you ask `@lead` for an approval request, include explicit task scope: use `task_id` (single task) or `task_ids` (multi-task scope). ## Task mentions - If you receive TASK MENTION or are @mentioned in a task, reply in that task. diff --git a/backend/templates/HEARTBEAT_LEAD.md b/backend/templates/HEARTBEAT_LEAD.md index 1d95c668..6e14b165 100644 --- a/backend/templates/HEARTBEAT_LEAD.md +++ b/backend/templates/HEARTBEAT_LEAD.md @@ -281,13 +281,15 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} - If the task depends on other tasks, always set `depends_on_task_ids`. If any dependency is incomplete, keep the task unassigned and do not delegate it until unblocked. - If confidence < 70 or the action is risky/external, request approval instead: POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals + - Use `task_ids` when an approval applies to multiple tasks; use `task_id` when only one task applies. + - Keep `payload.task_ids`/`payload.task_id` aligned with top-level `task_ids`/`task_id`. 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}} + {"action_type":"task.create","task_ids":["TASK_ID_1","TASK_ID_2"],"confidence":60,"payload":{"title":"...","description":"...","task_ids":["TASK_ID_1","TASK_ID_2"]},"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. -- Before requesting any approval, check existing approvals + board memory to ensure you are not duplicating an in-flight request for the same TASK_ID/action. +- Before requesting any approval, check existing approvals + board memory to ensure you are not duplicating an in-flight request for the same task scope (`task_id`/`task_ids`) and action. - 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. @@ -296,7 +298,7 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} - 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}} + {"action_type":"task.complete","task_ids":["TASK_ID_1","TASK_ID_2"],"confidence":60,"payload":{"task_ids":["TASK_ID_1","TASK_ID_2"],"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): @@ -305,7 +307,7 @@ Body: {"depends_on_task_ids":["DEP_TASK_ID_1","DEP_TASK_ID_2"]} - 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}} + {"action_type":"task.rework","task_ids":["TASK_ID_1","TASK_ID_2"],"confidence":60,"payload":{"task_ids":["TASK_ID_1","TASK_ID_2"],"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). diff --git a/backend/tests/test_approval_task_links.py b/backend/tests/test_approval_task_links.py new file mode 100644 index 00000000..084309b8 --- /dev/null +++ b/backend/tests/test_approval_task_links.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.approval_task_links import ApprovalTaskLink +from app.models.approvals import Approval +from app.models.boards import Board +from app.models.organizations import Organization +from app.models.tasks import Task +from app.services.approval_task_links import ( + load_task_ids_by_approval, + normalize_task_ids, + task_counts_for_board, +) + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as conn, conn.begin(): + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +async def _make_session(engine: AsyncEngine) -> AsyncSession: + return AsyncSession(engine, expire_on_commit=False) + + +async def _seed_board(session: AsyncSession) -> tuple[UUID, UUID, UUID, UUID]: + org_id = uuid4() + board_id = uuid4() + task_a = uuid4() + task_b = uuid4() + task_c = uuid4() + + session.add(Organization(id=org_id, name=f"org-{org_id}")) + session.add(Board(id=board_id, organization_id=org_id, name="b", slug="b")) + session.add(Task(id=task_a, board_id=board_id, title="a")) + session.add(Task(id=task_b, board_id=board_id, title="b")) + session.add(Task(id=task_c, board_id=board_id, title="c")) + await session.commit() + return board_id, task_a, task_b, task_c + + +def test_normalize_task_ids_dedupes_and_merges_sources() -> None: + task_a = uuid4() + task_b = uuid4() + task_c = uuid4() + + payload = { + "task_id": str(task_a), + "task_ids": [str(task_b), str(task_a)], + "taskIds": [str(task_c), "not-a-uuid"], + } + result = normalize_task_ids( + task_id=task_b, + task_ids=[task_a], + payload=payload, + ) + + assert result == [task_a, task_b, task_c] + + +@pytest.mark.asyncio +async def test_task_counts_for_board_supports_multi_task_links_and_legacy_rows() -> None: + engine = await _make_engine() + async with await _make_session(engine) as session: + board_id, task_a, task_b, task_c = await _seed_board(session) + + approval_pending_multi = Approval( + board_id=board_id, + task_id=task_a, + action_type="task.update", + confidence=80, + status="pending", + ) + approval_approved = Approval( + board_id=board_id, + task_id=task_a, + action_type="task.complete", + confidence=90, + status="approved", + ) + approval_pending_two = Approval( + board_id=board_id, + task_id=task_b, + action_type="task.assign", + confidence=75, + status="pending", + ) + approval_legacy = Approval( + board_id=board_id, + task_id=task_c, + action_type="task.comment", + confidence=65, + status="pending", + ) + session.add(approval_pending_multi) + session.add(approval_approved) + session.add(approval_pending_two) + session.add(approval_legacy) + await session.flush() + + session.add( + ApprovalTaskLink(approval_id=approval_pending_multi.id, task_id=task_a), + ) + session.add( + ApprovalTaskLink(approval_id=approval_pending_multi.id, task_id=task_b), + ) + session.add(ApprovalTaskLink(approval_id=approval_approved.id, task_id=task_a)) + session.add(ApprovalTaskLink(approval_id=approval_pending_two.id, task_id=task_b)) + session.add(ApprovalTaskLink(approval_id=approval_pending_two.id, task_id=task_c)) + await session.commit() + + counts = await task_counts_for_board(session, board_id=board_id) + + assert counts[task_a] == (2, 1) + assert counts[task_b] == (2, 2) + assert counts[task_c] == (2, 2) + + +@pytest.mark.asyncio +async def test_load_task_ids_by_approval_preserves_insert_order() -> None: + engine = await _make_engine() + async with await _make_session(engine) as session: + board_id, task_a, task_b, task_c = await _seed_board(session) + + approval = Approval( + board_id=board_id, + task_id=task_a, + action_type="task.update", + confidence=88, + status="pending", + ) + session.add(approval) + await session.flush() + session.add(ApprovalTaskLink(approval_id=approval.id, task_id=task_a)) + session.add(ApprovalTaskLink(approval_id=approval.id, task_id=task_b)) + session.add(ApprovalTaskLink(approval_id=approval.id, task_id=task_c)) + await session.commit() + + mapping = await load_task_ids_by_approval(session, approval_ids=[approval.id]) + assert mapping[approval.id] == [task_a, task_b, task_c] diff --git a/backend/tests/test_approvals_lead_notifications.py b/backend/tests/test_approvals_lead_notifications.py index e79d399f..92674ce4 100644 --- a/backend/tests/test_approvals_lead_notifications.py +++ b/backend/tests/test_approvals_lead_notifications.py @@ -10,7 +10,7 @@ from app.api import approvals from app.models.agents import Agent from app.models.approvals import Approval from app.models.boards import Board -from app.schemas.approvals import ApprovalUpdate +from app.schemas.approvals import ApprovalRead, ApprovalUpdate from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig @@ -120,6 +120,25 @@ async def test_update_approval_notifies_lead_when_approved( _fake_try_send_agent_message, ) + async def _fake_load_task_ids_by_approval( + _session: object, + *, + approval_ids: list[UUID], + ) -> dict[UUID, list[UUID]]: + _ = approval_ids + return {approval.id: []} + + monkeypatch.setattr(approvals, "load_task_ids_by_approval", _fake_load_task_ids_by_approval) + + async def _fake_reads(_session: object, _approvals: list[Approval]) -> list[ApprovalRead]: + return [ApprovalRead.model_validate(approval, from_attributes=True)] + + monkeypatch.setattr( + approvals, + "_approval_reads", + _fake_reads, + ) + updated = await approvals.update_approval( approval_id=str(approval.id), payload=ApprovalUpdate(status="approved"), @@ -155,6 +174,15 @@ async def test_update_approval_skips_notify_when_status_not_resolved( monkeypatch.setattr(approvals, "_notify_lead_on_approval_resolution", _fake_notify) + async def _fake_reads(_session: object, _approvals: list[Approval]) -> list[ApprovalRead]: + return [ApprovalRead.model_validate(approval, from_attributes=True)] + + monkeypatch.setattr( + approvals, + "_approval_reads", + _fake_reads, + ) + updated = await approvals.update_approval( approval_id=str(approval.id), payload=ApprovalUpdate(status="pending"), diff --git a/backend/tests/test_organizations_delete_api.py b/backend/tests/test_organizations_delete_api.py index 90f6d515..77a47f75 100644 --- a/backend/tests/test_organizations_delete_api.py +++ b/backend/tests/test_organizations_delete_api.py @@ -56,6 +56,7 @@ async def test_delete_my_org_cleans_dependents_before_organization_delete() -> N "activity_events", "task_dependencies", "task_fingerprints", + "approval_task_links", "approvals", "board_memory", "board_onboarding_sessions", diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index f775827a..66b3b0d2 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -1113,11 +1113,17 @@ export default function BoardDetailPage() { try { const payload = JSON.parse(data) as { approval?: ApprovalRead; - task_counts?: { - task_id?: string; - approvals_count?: number; - approvals_pending_count?: number; - }; + task_counts?: + | { + task_id?: string; + approvals_count?: number; + approvals_pending_count?: number; + } + | Array<{ + task_id?: string; + approvals_count?: number; + approvals_pending_count?: number; + }>; pending_approvals_count?: number; }; if (payload.approval) { @@ -1137,23 +1143,30 @@ export default function BoardDetailPage() { return next; }); } - if (payload.task_counts?.task_id) { - const taskId = payload.task_counts.task_id; + const taskCounts = Array.isArray(payload.task_counts) + ? payload.task_counts + : payload.task_counts + ? [payload.task_counts] + : []; + if (taskCounts.length > 0) { setTasks((prev) => { - const index = prev.findIndex((task) => task.id === taskId); - if (index === -1) return prev; - const next = [...prev]; - const current = next[index]; - next[index] = { - ...current, - approvals_count: - payload.task_counts?.approvals_count ?? - current.approvals_count, - approvals_pending_count: - payload.task_counts?.approvals_pending_count ?? - current.approvals_pending_count, - }; - return next; + const countsByTaskId = new Map( + taskCounts + .filter((row) => Boolean(row.task_id)) + .map((row) => [row.task_id as string, row]), + ); + return prev.map((task) => { + const counts = countsByTaskId.get(task.id); + if (!counts) return task; + return { + ...task, + approvals_count: + counts.approvals_count ?? task.approvals_count, + approvals_pending_count: + counts.approvals_pending_count ?? + task.approvals_pending_count, + }; + }); }); } } catch { @@ -1721,7 +1734,37 @@ export default function BoardDetailPage() { const taskApprovals = useMemo(() => { if (!selectedTask) return []; const taskId = selectedTask.id; - return approvals.filter((approval) => approval.task_id === taskId); + const taskIdsForApproval = (approval: Approval) => { + const payload = approval.payload ?? {}; + const payloadValue = (key: string) => { + const value = (payload as Record)[key]; + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return null; + }; + const payloadArray = (key: string) => { + const value = (payload as Record)[key]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); + }; + const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null }) + .task_ids; + const singleTaskId = + approval.task_id ?? + payloadValue("task_id") ?? + payloadValue("taskId") ?? + payloadValue("taskID"); + const merged = [ + ...(Array.isArray(linkedTaskIds) ? linkedTaskIds : []), + ...payloadArray("task_ids"), + ...payloadArray("taskIds"), + ...payloadArray("taskIDs"), + ...(singleTaskId ? [singleTaskId] : []), + ]; + return [...new Set(merged)]; + }; + return approvals.filter((approval) => taskIdsForApproval(approval).includes(taskId)); }, [approvals, selectedTask]); const workingAgentIds = useMemo(() => { @@ -2169,13 +2212,45 @@ export default function BoardDetailPage() { return null; }; - const approvalRows = (approval: Approval) => { + const approvalPayloadValues = (payload: Approval["payload"], key: string) => { + if (!payload || typeof payload !== "object") return []; + const value = (payload as Record)[key]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); + }; + + const approvalTaskIds = (approval: Approval) => { const payload = approval.payload ?? {}; - const taskId = + const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null }) + .task_ids; + const singleTaskId = approval.task_id ?? approvalPayloadValue(payload, "task_id") ?? approvalPayloadValue(payload, "taskId") ?? approvalPayloadValue(payload, "taskID"); + const manyTaskIds = [ + ...approvalPayloadValues(payload, "task_ids"), + ...approvalPayloadValues(payload, "taskIds"), + ...approvalPayloadValues(payload, "taskIDs"), + ]; + const merged = [ + ...(Array.isArray(linkedTaskIds) ? linkedTaskIds : []), + ...manyTaskIds, + ...(singleTaskId ? [singleTaskId] : []), + ]; + const deduped: string[] = []; + const seen = new Set(); + merged.forEach((value) => { + if (seen.has(value)) return; + seen.add(value); + deduped.push(value); + }); + return deduped; + }; + + const approvalRows = (approval: Approval) => { + const payload = approval.payload ?? {}; + const taskIds = approvalTaskIds(approval); const assignedAgentId = approvalPayloadValue(payload, "assigned_agent_id") ?? approvalPayloadValue(payload, "assignedAgentId"); @@ -2183,7 +2258,8 @@ export default function BoardDetailPage() { const role = approvalPayloadValue(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 (taskIds.length === 1) rows.push({ label: "Task", value: taskIds[0] }); + if (taskIds.length > 1) rows.push({ label: "Tasks", value: taskIds.join(", ") }); if (isAssign) { rows.push({ label: "Assignee", diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx index 0b005b32..85c00ddf 100644 --- a/frontend/src/components/BoardApprovalsPanel.tsx +++ b/frontend/src/components/BoardApprovalsPanel.tsx @@ -149,13 +149,36 @@ const payloadValue = (payload: Approval["payload"], key: string) => { return null; }; -const approvalSummary = (approval: Approval, boardLabel?: string | null) => { +const payloadValues = (payload: Approval["payload"], key: string) => { + if (!payload) return []; + const value = payload[key as keyof typeof payload]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +}; + +const approvalTaskIds = (approval: Approval) => { const payload = approval.payload ?? {}; - const taskId = + const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null }) + .task_ids; + const singleTaskId = approval.task_id ?? payloadValue(payload, "task_id") ?? payloadValue(payload, "taskId") ?? payloadValue(payload, "taskID"); + const merged = [ + ...(Array.isArray(linkedTaskIds) ? linkedTaskIds : []), + ...payloadValues(payload, "task_ids"), + ...payloadValues(payload, "taskIds"), + ...payloadValues(payload, "taskIDs"), + ...(singleTaskId ? [singleTaskId] : []), + ]; + return [...new Set(merged)]; +}; + +const approvalSummary = (approval: Approval, boardLabel?: string | null) => { + const payload = approval.payload ?? {}; + const taskIds = approvalTaskIds(approval); + const taskId = taskIds[0] ?? null; const assignedAgentId = payloadValue(payload, "assigned_agent_id") ?? payloadValue(payload, "assignedAgentId"); @@ -166,7 +189,8 @@ const approvalSummary = (approval: Approval, boardLabel?: string | null) => { const isAssign = approval.action_type.includes("assign"); const rows: Array<{ label: string; value: string }> = []; if (boardLabel) rows.push({ label: "Board", value: boardLabel }); - if (taskId) rows.push({ label: "Task", value: taskId }); + if (taskIds.length === 1) rows.push({ label: "Task", value: taskIds[0] }); + if (taskIds.length > 1) rows.push({ label: "Tasks", value: taskIds.join(", ") }); if (isAssign) { rows.push({ label: "Assignee",