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

View File

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

View File

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

View File

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

View File

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

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 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."""

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

View File

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

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

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

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 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 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**):
- 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 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.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"),

View File

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

View File

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

View File

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