feat: add approval-task links model and related functionality for task associations

This commit is contained in:
Abhimanyu Saharan
2026-02-11 20:27:04 +05:30
parent 3dfdfa3c3e
commit af8a263c27
19 changed files with 870 additions and 129 deletions

View File

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 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 sqlmodel import col, select
from sse_starlette.sse import EventSourceResponse 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.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.activity_log import record_activity 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 from app.services.openclaw.gateway_dispatch import GatewayDispatchService
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -42,7 +48,6 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"]) router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"])
logger = get_logger(__name__) logger = get_logger(__name__)
TASK_ID_KEYS: tuple[str, ...] = ("task_id", "taskId", "taskID")
STREAM_POLL_SECONDS = 2 STREAM_POLL_SECONDS = 2
STATUS_FILTER_QUERY = Query(default=None, alias="status") STATUS_FILTER_QUERY = Query(default=None, alias="status")
SINCE_QUERY = Query(default=None) SINCE_QUERY = Query(default=None)
@@ -53,21 +58,6 @@ SESSION_DEP = Depends(get_session)
ACTOR_DEP = Depends(require_admin_or_agent) 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: def _parse_since(value: str | None) -> datetime | None:
if not value: if not value:
return None return None
@@ -88,17 +78,47 @@ def _approval_updated_at(approval: Approval) -> datetime:
return approval.resolved_at or approval.created_at return approval.resolved_at or approval.created_at
def _serialize_approval(approval: Approval) -> dict[str, object]: async def _approval_task_ids_map(
return ApprovalRead.model_validate( session: AsyncSession,
approval, approvals: Sequence[Approval],
from_attributes=True, ) -> dict[UUID, list[UUID]]:
).model_dump(mode="json") 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( def _approval_resolution_message(
*, *,
board: Board, board: Board,
approval: Approval, approval: Approval,
task_ids: Sequence[UUID] | None = None,
) -> str: ) -> str:
status_text = "approved" if approval.status == "approved" else "rejected" status_text = "approved" if approval.status == "approved" else "rejected"
lines = [ lines = [
@@ -109,8 +129,13 @@ def _approval_resolution_message(
f"Decision: {status_text}", f"Decision: {status_text}",
f"Confidence: {approval.confidence}", f"Confidence: {approval.confidence}",
] ]
if approval.task_id is not None: normalized_task_ids = list(task_ids or [])
lines.append(f"Task ID: {approval.task_id}") 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("")
lines.append("Take action: continue execution using the final approval decision.") lines.append("Take action: continue execution using the final approval decision.")
return "\n".join(lines) return "\n".join(lines)
@@ -145,7 +170,12 @@ async def _notify_lead_on_approval_resolution(
if config is None: if config is None:
return 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( error = await dispatch.try_send_agent_message(
session_key=lead.openclaw_session_id, session_key=lead.openclaw_session_id,
config=config, config=config,
@@ -202,7 +232,17 @@ async def list_approvals(
if status_filter: if status_filter:
statement = statement.filter(col(Approval.status) == status_filter) statement = statement.filter(col(Approval.status) == status_filter)
statement = statement.order_by(col(Approval.created_at).desc()) 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") @router.get("/stream")
@@ -223,6 +263,7 @@ async def stream_approvals(
break break
async with async_session_maker() as session: async with async_session_maker() as session:
approvals = await _fetch_approval_events(session, board.id, last_seen) approvals = await _fetch_approval_events(session, board.id, last_seen)
approval_reads = await _approval_reads(session, approvals)
pending_approvals_count = int( pending_approvals_count = int(
( (
await session.exec( await session.exec(
@@ -233,50 +274,36 @@ async def stream_approvals(
).one(), ).one(),
) )
task_ids = { 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]] = {} counts_by_task_id = await task_counts_for_board(
if task_ids: session,
rows = list( board_id=board.id,
await session.exec( task_ids=task_ids,
select( )
col(Approval.task_id), for approval, approval_read in zip(approvals, approval_reads, strict=True):
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:
updated_at = _approval_updated_at(approval) updated_at = _approval_updated_at(approval)
last_seen = max(updated_at, last_seen) last_seen = max(updated_at, last_seen)
payload: dict[str, object] = { payload: dict[str, object] = {
"approval": _serialize_approval(approval), "approval": _serialize_approval(approval_read),
"pending_approvals_count": pending_approvals_count, "pending_approvals_count": pending_approvals_count,
} }
if approval.task_id is not None: task_counts = [
counts = counts_by_task_id.get(approval.task_id) {
if counts is not None: "task_id": str(task_id),
total, pending = counts "approvals_count": total,
payload["task_counts"] = { "approvals_pending_count": pending,
"task_id": str(approval.task_id), }
"approvals_count": total, for task_id in approval_read.task_ids
"approvals_pending_count": pending, 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)} yield {"event": "approval", "data": json.dumps(payload)}
await asyncio.sleep(STREAM_POLL_SECONDS) await asyncio.sleep(STREAM_POLL_SECONDS)
@@ -289,9 +316,14 @@ async def create_approval(
board: Board = BOARD_WRITE_DEP, board: Board = BOARD_WRITE_DEP,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP, _actor: ActorContext = ACTOR_DEP,
) -> Approval: ) -> ApprovalRead:
"""Create an approval for a board.""" """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( approval = Approval(
board_id=board.id, board_id=board.id,
task_id=task_id, task_id=task_id,
@@ -303,9 +335,15 @@ async def create_approval(
status=payload.status, status=payload.status,
) )
session.add(approval) 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.commit()
await session.refresh(approval) await session.refresh(approval)
return approval return _approval_to_read(approval, task_ids=task_ids)
@router.patch("/{approval_id}", response_model=ApprovalRead) @router.patch("/{approval_id}", response_model=ApprovalRead)
@@ -314,7 +352,7 @@ async def update_approval(
payload: ApprovalUpdate, payload: ApprovalUpdate,
board: Board = BOARD_USER_WRITE_DEP, board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
) -> Approval: ) -> ApprovalRead:
"""Update an approval's status and resolution timestamp.""" """Update an approval's status and resolution timestamp."""
approval = await Approval.objects.by_id(approval_id).first(session) approval = await Approval.objects.by_id(approval_id).first(session)
if approval is None or approval.board_id != board.id: if approval is None or approval.board_id != board.id:
@@ -342,4 +380,5 @@ async def update_approval(
approval.id, approval.id,
approval.status, approval.status,
) )
return approval reads = await _approval_reads(session, [approval])
return reads[0]

View File

@@ -18,6 +18,7 @@ from app.db.pagination import paginate
from app.db.session import get_session from app.db.session import get_session
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.board_group_memory import BoardGroupMemory from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup from app.models.board_groups import BoardGroup
@@ -269,6 +270,14 @@ async def delete_my_org(
col(TaskFingerprint.board_id).in_(board_ids), col(TaskFingerprint.board_id).in_(board_ids),
commit=False, 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( await crud.delete_where(
session, session,
Approval, Approval,

View File

@@ -29,6 +29,7 @@ from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session from app.db.session import async_session_maker, get_session
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.boards import Board from app.models.boards import Board
from app.models.task_dependencies import TaskDependency 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.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity 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.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.gateway_dispatch import GatewayDispatchService from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
@@ -922,12 +924,26 @@ async def delete_task(
col(TaskFingerprint.task_id) == task.id, col(TaskFingerprint.task_id) == task.id,
commit=False, commit=False,
) )
primary_approvals = list(
await Approval.objects.filter(col(Approval.task_id) == task.id).all(session),
)
await crud.delete_where( await crud.delete_where(
session, session,
Approval, ApprovalTaskLink,
col(Approval.task_id) == task.id, col(ApprovalTaskLink.task_id) == task.id,
commit=False, 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( await crud.delete_where(
session, session,
TaskDependency, TaskDependency,

View File

@@ -13,6 +13,7 @@ from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.board_group_memory import BoardGroupMemory from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup from app.models.board_groups import BoardGroup
@@ -83,6 +84,14 @@ async def _delete_organization_tree(
col(TaskFingerprint.board_id).in_(board_ids), col(TaskFingerprint.board_id).in_(board_ids),
commit=False, 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( await crud.delete_where(
session, session,
Approval, Approval,

View File

@@ -2,6 +2,7 @@
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.board_group_memory import BoardGroupMemory from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup from app.models.board_groups import BoardGroup
@@ -22,6 +23,7 @@ from app.models.users import User
__all__ = [ __all__ = [
"ActivityEvent", "ActivityEvent",
"Agent", "Agent",
"ApprovalTaskLink",
"Approval", "Approval",
"BoardGroupMemory", "BoardGroupMemory",
"BoardMemory", "BoardMemory",

View File

@@ -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)

View File

@@ -7,7 +7,7 @@ from typing import Literal, Self
from uuid import UUID from uuid import UUID
from pydantic import model_validator from pydantic import model_validator
from sqlmodel import SQLModel from sqlmodel import Field, SQLModel
ApprovalStatus = Literal["pending", "approved", "rejected"] ApprovalStatus = Literal["pending", "approved", "rejected"]
STATUS_REQUIRED_ERROR = "status is required" STATUS_REQUIRED_ERROR = "status is required"
@@ -19,11 +19,29 @@ class ApprovalBase(SQLModel):
action_type: str action_type: str
task_id: UUID | None = None task_id: UUID | None = None
task_ids: list[UUID] = Field(default_factory=list)
payload: dict[str, object] | None = None payload: dict[str, object] | None = None
confidence: int confidence: int
rubric_scores: dict[str, int] | None = None rubric_scores: dict[str, int] | None = None
status: ApprovalStatus = "pending" 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): class ApprovalCreate(ApprovalBase):
"""Payload for creating a new approval request.""" """Payload for creating a new approval request."""

View File

@@ -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

View File

@@ -14,6 +14,7 @@ from sqlmodel import col, select
from app.db import crud from app.db import crud
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approval_task_links import ApprovalTaskLink
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.board_memory import BoardMemory from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession 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. # 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, Approval, col(Approval.board_id) == board.id)
await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id) await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import case, func from sqlalchemy import func
from sqlmodel import col, select from sqlmodel import col, select
from app.models.agents import Agent 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.board_memory import BoardMemoryRead
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.view_models import BoardSnapshot, TaskCardRead 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.openclaw.provisioning_db import AgentLifecycleService
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
@@ -34,8 +35,10 @@ def _memory_to_read(memory: BoardMemory) -> BoardMemoryRead:
return BoardMemoryRead.model_validate(memory, from_attributes=True) return BoardMemoryRead.model_validate(memory, from_attributes=True)
def _approval_to_read(approval: Approval) -> ApprovalRead: def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead:
return ApprovalRead.model_validate(approval, from_attributes=True) 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( def _task_to_card(
@@ -120,27 +123,23 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap
.limit(200) .limit(200)
.all(session) .all(session)
) )
approval_reads = [_approval_to_read(approval) for approval in approvals] approval_ids = [approval.id for approval in approvals]
task_ids_by_approval = await load_task_ids_by_approval(
counts_by_task_id: dict[UUID, tuple[int, int]] = {} session,
rows = list( approval_ids=approval_ids,
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)),
),
) )
for task_id, total, pending in rows: approval_reads = [
if task_id is None: _approval_to_read(
continue approval,
counts_by_task_id[task_id] = (int(total or 0), int(pending or 0)) 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_cards = [
_task_to_card( _task_to_card(

View File

@@ -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")

View File

@@ -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). - 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. - 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: 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). - `TASK_SOUL.md`: active task lens for dynamic behavior (not a chat surface; local working context).
## Collaboration (mandatory) ## Collaboration (mandatory)

View File

@@ -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 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). - 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 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 ## Task mentions
- If you receive TASK MENTION or are @mentioned in a task, reply in that task. - If you receive TASK MENTION or are @mentioned in a task, reply in that task.

View File

@@ -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 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: - If confidence < 70 or the action is risky/external, request approval instead:
POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals 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: 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 followup questions, still create the task and add a comment on that task with the questions. You are allowed to comment on tasks you created. - If you have followup questions, still create the task and add a comment on that task with the questions. You are allowed to comment on tasks you created.
8) Review handling (when a task reaches **review**): 8) Review handling (when a task reaches **review**):
- Read all comments before deciding. - 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: - 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. - 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. - 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: - If confidence < 70 or risky/external, request approval:
POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals
Body example: 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: - If the work is **not** done correctly:
- Add a **review feedback comment** on the task describing what is missing or wrong. - 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): - 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: - If confidence < 70 or risky/external, request approval to move it back:
POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals POST $BASE_URL/api/v1/agent/boards/$BOARD_ID/approvals
Body example: 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. - Assign or create the next agent who should handle the rework.
- That agent must read **all comments** before starting the task. - That agent must read **all comments** before starting the task.
- If the work reveals more to do, **create one or more followup tasks** (and assign/create agents as needed). - If the work reveals more to do, **create one or more followup tasks** (and assign/create agents as needed).

View File

@@ -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]

View File

@@ -10,7 +10,7 @@ from app.api import approvals
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.boards import Board 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 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, _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( updated = await approvals.update_approval(
approval_id=str(approval.id), approval_id=str(approval.id),
payload=ApprovalUpdate(status="approved"), 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) 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( updated = await approvals.update_approval(
approval_id=str(approval.id), approval_id=str(approval.id),
payload=ApprovalUpdate(status="pending"), payload=ApprovalUpdate(status="pending"),

View File

@@ -56,6 +56,7 @@ async def test_delete_my_org_cleans_dependents_before_organization_delete() -> N
"activity_events", "activity_events",
"task_dependencies", "task_dependencies",
"task_fingerprints", "task_fingerprints",
"approval_task_links",
"approvals", "approvals",
"board_memory", "board_memory",
"board_onboarding_sessions", "board_onboarding_sessions",

View File

@@ -1113,11 +1113,17 @@ export default function BoardDetailPage() {
try { try {
const payload = JSON.parse(data) as { const payload = JSON.parse(data) as {
approval?: ApprovalRead; approval?: ApprovalRead;
task_counts?: { task_counts?:
task_id?: string; | {
approvals_count?: number; task_id?: string;
approvals_pending_count?: number; approvals_count?: number;
}; approvals_pending_count?: number;
}
| Array<{
task_id?: string;
approvals_count?: number;
approvals_pending_count?: number;
}>;
pending_approvals_count?: number; pending_approvals_count?: number;
}; };
if (payload.approval) { if (payload.approval) {
@@ -1137,23 +1143,30 @@ export default function BoardDetailPage() {
return next; return next;
}); });
} }
if (payload.task_counts?.task_id) { const taskCounts = Array.isArray(payload.task_counts)
const taskId = payload.task_counts.task_id; ? payload.task_counts
: payload.task_counts
? [payload.task_counts]
: [];
if (taskCounts.length > 0) {
setTasks((prev) => { setTasks((prev) => {
const index = prev.findIndex((task) => task.id === taskId); const countsByTaskId = new Map(
if (index === -1) return prev; taskCounts
const next = [...prev]; .filter((row) => Boolean(row.task_id))
const current = next[index]; .map((row) => [row.task_id as string, row]),
next[index] = { );
...current, return prev.map((task) => {
approvals_count: const counts = countsByTaskId.get(task.id);
payload.task_counts?.approvals_count ?? if (!counts) return task;
current.approvals_count, return {
approvals_pending_count: ...task,
payload.task_counts?.approvals_pending_count ?? approvals_count:
current.approvals_pending_count, counts.approvals_count ?? task.approvals_count,
}; approvals_pending_count:
return next; counts.approvals_pending_count ??
task.approvals_pending_count,
};
});
}); });
} }
} catch { } catch {
@@ -1721,7 +1734,37 @@ export default function BoardDetailPage() {
const taskApprovals = useMemo(() => { const taskApprovals = useMemo(() => {
if (!selectedTask) return []; if (!selectedTask) return [];
const taskId = selectedTask.id; 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<string, unknown>)[key];
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return null;
};
const payloadArray = (key: string) => {
const value = (payload as Record<string, unknown>)[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]); }, [approvals, selectedTask]);
const workingAgentIds = useMemo(() => { const workingAgentIds = useMemo(() => {
@@ -2169,13 +2212,45 @@ export default function BoardDetailPage() {
return null; return null;
}; };
const approvalRows = (approval: Approval) => { const approvalPayloadValues = (payload: Approval["payload"], key: string) => {
if (!payload || typeof payload !== "object") return [];
const value = (payload as Record<string, unknown>)[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 payload = approval.payload ?? {};
const taskId = const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
.task_ids;
const singleTaskId =
approval.task_id ?? approval.task_id ??
approvalPayloadValue(payload, "task_id") ?? approvalPayloadValue(payload, "task_id") ??
approvalPayloadValue(payload, "taskId") ?? approvalPayloadValue(payload, "taskId") ??
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<string>();
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 = const assignedAgentId =
approvalPayloadValue(payload, "assigned_agent_id") ?? approvalPayloadValue(payload, "assigned_agent_id") ??
approvalPayloadValue(payload, "assignedAgentId"); approvalPayloadValue(payload, "assignedAgentId");
@@ -2183,7 +2258,8 @@ export default function BoardDetailPage() {
const role = approvalPayloadValue(payload, "role"); const role = approvalPayloadValue(payload, "role");
const isAssign = approval.action_type.includes("assign"); const isAssign = approval.action_type.includes("assign");
const rows: Array<{ label: string; value: string }> = []; 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) { if (isAssign) {
rows.push({ rows.push({
label: "Assignee", label: "Assignee",

View File

@@ -149,13 +149,36 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
return null; 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 payload = approval.payload ?? {};
const taskId = const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
.task_ids;
const singleTaskId =
approval.task_id ?? approval.task_id ??
payloadValue(payload, "task_id") ?? payloadValue(payload, "task_id") ??
payloadValue(payload, "taskId") ?? payloadValue(payload, "taskId") ??
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 = const assignedAgentId =
payloadValue(payload, "assigned_agent_id") ?? payloadValue(payload, "assigned_agent_id") ??
payloadValue(payload, "assignedAgentId"); payloadValue(payload, "assignedAgentId");
@@ -166,7 +189,8 @@ const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
const isAssign = approval.action_type.includes("assign"); const isAssign = approval.action_type.includes("assign");
const rows: Array<{ label: string; value: string }> = []; const rows: Array<{ label: string; value: string }> = [];
if (boardLabel) rows.push({ label: "Board", value: boardLabel }); 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) { if (isAssign) {
rows.push({ rows.push({
label: "Assignee", label: "Assignee",