feat: add approval-task links model and related functionality for task associations
This commit is contained in:
@@ -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"),
|
||||
counts_by_task_id = await task_counts_for_board(
|
||||
session,
|
||||
board_id=board.id,
|
||||
task_ids=task_ids,
|
||||
)
|
||||
.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:
|
||||
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),
|
||||
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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
backend/app/models/approval_task_links.py
Normal file
32
backend/app/models/approval_task_links.py
Normal 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)
|
||||
@@ -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."""
|
||||
|
||||
190
backend/app/services/approval_task_links.py
Normal file
190
backend/app/services/approval_task_links.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
approval_ids = [approval.id for approval in approvals]
|
||||
task_ids_by_approval = await load_task_ids_by_approval(
|
||||
session,
|
||||
approval_ids=approval_ids,
|
||||
)
|
||||
.where(col(Approval.board_id) == board.id)
|
||||
.where(col(Approval.task_id).is_not(None))
|
||||
.group_by(col(Approval.task_id)),
|
||||
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 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_cards = [
|
||||
_task_to_card(
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
147
backend/tests/test_approval_task_links.py
Normal file
147
backend/tests/test_approval_task_links.py
Normal 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]
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1113,11 +1113,17 @@ export default function BoardDetailPage() {
|
||||
try {
|
||||
const payload = JSON.parse(data) as {
|
||||
approval?: ApprovalRead;
|
||||
task_counts?: {
|
||||
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,
|
||||
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:
|
||||
payload.task_counts?.approvals_count ??
|
||||
current.approvals_count,
|
||||
counts.approvals_count ?? task.approvals_count,
|
||||
approvals_pending_count:
|
||||
payload.task_counts?.approvals_pending_count ??
|
||||
current.approvals_pending_count,
|
||||
counts.approvals_pending_count ??
|
||||
task.approvals_pending_count,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
} 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<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]);
|
||||
|
||||
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<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 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<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 =
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user