feat: add board rule toggles for approval and review requirements

This commit is contained in:
Abhimanyu Saharan
2026-02-12 23:05:33 +05:30
parent 8ff75f4c56
commit 855885afaf
12 changed files with 965 additions and 46 deletions

View File

@@ -86,6 +86,41 @@ def _parse_draft_lead_agent(
return None
def _normalize_autonomy_token(value: object) -> str | None:
if not isinstance(value, str):
return None
text = value.strip().lower()
if not text:
return None
return text.replace("_", "-")
def _is_fully_autonomous_choice(value: object) -> bool:
token = _normalize_autonomy_token(value)
if token is None:
return False
if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
return True
return "autonom" in token and "fully" in token
def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
"""Enable done-approval gate unless onboarding selected fully autonomous mode."""
if not isinstance(draft_goal, dict):
return True
raw_lead = draft_goal.get("lead_agent")
if not isinstance(raw_lead, dict):
return True
if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
return False
raw_identity_profile = raw_lead.get("identity_profile")
if isinstance(raw_identity_profile, dict):
for key in ("autonomy_level", "autonomy", "mode"):
if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
return False
return True
def _apply_user_profile(
auth: AuthContext,
profile: BoardOnboardingUserProfile | None,
@@ -408,6 +443,9 @@ async def confirm_onboarding(
board.target_date = payload.target_date
board.goal_confirmed = True
board.goal_source = "lead_agent_onboarding"
board.require_approval_for_done = _require_approval_for_done_from_draft(
onboarding.draft_goal,
)
onboarding.status = "confirmed"
onboarding.updated_at = utcnow()

View File

@@ -42,7 +42,10 @@ 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.approval_task_links import (
load_task_ids_by_approval,
pending_approval_conflicts_by_task,
)
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
@@ -113,6 +116,151 @@ def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException:
)
def _approval_required_for_done_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task can only be marked done when a linked approval has been approved."
),
"blocked_by_task_ids": [],
},
)
def _review_required_for_done_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task can only be marked done from review when the board rule is enabled."
),
"blocked_by_task_ids": [],
},
)
def _pending_approval_blocks_status_change_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task status cannot be changed while a linked approval is pending."
),
"blocked_by_task_ids": [],
},
)
async def _task_has_approved_linked_approval(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
) -> bool:
linked_approval_ids = select(col(ApprovalTaskLink.approval_id)).where(
col(ApprovalTaskLink.task_id) == task_id,
)
statement = (
select(col(Approval.id))
.where(col(Approval.board_id) == board_id)
.where(col(Approval.status) == "approved")
.where(
or_(
col(Approval.task_id) == task_id,
col(Approval.id).in_(linked_approval_ids),
),
)
.limit(1)
)
return (await session.exec(statement)).first() is not None
async def _task_has_pending_linked_approval(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
) -> bool:
conflicts = await pending_approval_conflicts_by_task(
session,
board_id=board_id,
task_ids=[task_id],
)
return task_id in conflicts
async def _require_approved_linked_approval_for_done(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
previous_status: str,
target_status: str,
) -> None:
if previous_status == "done" or target_status != "done":
return
requires_approval = (
await session.exec(
select(col(Board.require_approval_for_done)).where(col(Board.id) == board_id),
)
).first()
if requires_approval is False:
return
if not await _task_has_approved_linked_approval(
session,
board_id=board_id,
task_id=task_id,
):
raise _approval_required_for_done_error()
async def _require_review_before_done_when_enabled(
session: AsyncSession,
*,
board_id: UUID,
previous_status: str,
target_status: str,
) -> None:
if previous_status == "done" or target_status != "done":
return
requires_review = (
await session.exec(
select(col(Board.require_review_before_done)).where(col(Board.id) == board_id),
)
).first()
if requires_review and previous_status != "review":
raise _review_required_for_done_error()
async def _require_no_pending_approval_for_status_change_when_enabled(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
previous_status: str,
target_status: str,
status_requested: bool,
) -> None:
if not status_requested or previous_status == target_status:
return
blocks_status_change = (
await session.exec(
select(col(Board.block_status_changes_with_pending_approval)).where(
col(Board.id) == board_id,
),
)
).first()
if not blocks_status_change:
return
if await _task_has_pending_linked_approval(
session,
board_id=board_id,
task_id=task_id,
):
raise _pending_approval_blocks_status_change_error()
def _truncate_snippet(value: str) -> str:
text = value.strip()
if len(text) <= TASK_SNIPPET_MAX_LEN:
@@ -1447,6 +1595,27 @@ async def _apply_lead_task_update(
else:
await _lead_apply_assignment(session, update=update)
_lead_apply_status(update)
await _require_no_pending_approval_for_status_change_when_enabled(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
status_requested="status" in update.updates,
)
await _require_review_before_done_when_enabled(
session,
board_id=update.board_id,
previous_status=update.previous_status,
target_status=update.task.status,
)
await _require_approved_linked_approval_for_done(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
)
if normalized_tag_ids is not None:
await replace_tags(
@@ -1701,6 +1870,27 @@ async def _finalize_updated_task(
) -> TaskRead:
for key, value in update.updates.items():
setattr(update.task, key, value)
await _require_no_pending_approval_for_status_change_when_enabled(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
status_requested="status" in update.updates,
)
await _require_review_before_done_when_enabled(
session,
board_id=update.board_id,
previous_status=update.previous_status,
target_status=update.task.status,
)
await _require_approved_linked_approval_for_done(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
)
update.task.updated_at = utcnow()
status_raw = update.updates.get("status")

View File

@@ -39,5 +39,8 @@ class Board(TenantScoped, table=True):
target_date: datetime | None = None
goal_confirmed: bool = Field(default=False)
goal_source: str | None = None
require_approval_for_done: bool = Field(default=True)
require_review_before_done: bool = Field(default=False)
block_status_changes_with_pending_approval: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -29,6 +29,9 @@ class BoardBase(SQLModel):
target_date: datetime | None = None
goal_confirmed: bool = False
goal_source: str | None = None
require_approval_for_done: bool = True
require_review_before_done: bool = False
block_status_changes_with_pending_approval: bool = False
class BoardCreate(BoardBase):
@@ -68,6 +71,9 @@ class BoardUpdate(SQLModel):
target_date: datetime | None = None
goal_confirmed: bool | None = None
goal_source: str | None = None
require_approval_for_done: bool | None = None
require_review_before_done: bool | None = None
block_status_changes_with_pending_approval: bool | None = None
@model_validator(mode="after")
def validate_gateway_id(self) -> Self:

View File

@@ -0,0 +1,55 @@
"""add board rule toggles
Revision ID: c2e9f1a6d4b8
Revises: e2f9c6b4a1d3
Create Date: 2026-02-12 23:55:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c2e9f1a6d4b8"
down_revision = "e2f9c6b4a1d3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"boards",
sa.Column(
"require_approval_for_done",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
"boards",
sa.Column(
"require_review_before_done",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"boards",
sa.Column(
"block_status_changes_with_pending_approval",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("boards", "block_status_changes_with_pending_approval")
op.drop_column("boards", "require_review_before_done")
op.drop_column("boards", "require_approval_for_done")

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from app.api.board_onboarding import _require_approval_for_done_from_draft
def test_require_approval_for_done_defaults_true_without_lead_agent_draft() -> None:
assert _require_approval_for_done_from_draft(None) is True
assert _require_approval_for_done_from_draft({}) is True
assert _require_approval_for_done_from_draft({"lead_agent": "invalid"}) is True
def test_require_approval_for_done_stays_enabled_for_non_fully_autonomous_modes() -> None:
assert (
_require_approval_for_done_from_draft(
{"lead_agent": {"autonomy_level": "ask_first"}},
)
is True
)
assert (
_require_approval_for_done_from_draft(
{"lead_agent": {"autonomy_level": "balanced"}},
)
is True
)
def test_require_approval_for_done_disables_for_fully_autonomous_choices() -> None:
assert (
_require_approval_for_done_from_draft(
{"lead_agent": {"autonomy_level": "autonomous"}},
)
is False
)
assert (
_require_approval_for_done_from_draft(
{"lead_agent": {"autonomy_level": "fully-autonomous"}},
)
is False
)
assert (
_require_approval_for_done_from_draft(
{"lead_agent": {"identity_profile": {"autonomy_level": "fully autonomous"}}},
)
is False
)

View File

@@ -76,6 +76,28 @@ def test_board_update_rejects_empty_description_patch() -> None:
BoardUpdate(description=" ")
def test_board_rule_toggles_have_expected_defaults() -> None:
"""Boards should default to approval-gated done and optional review gating."""
created = BoardCreate(
name="Ops Board",
slug="ops-board",
description="Operations workflow board.",
gateway_id=uuid4(),
)
assert created.require_approval_for_done is True
assert created.require_review_before_done is False
assert created.block_status_changes_with_pending_approval is False
updated = BoardUpdate(
require_approval_for_done=False,
require_review_before_done=True,
block_status_changes_with_pending_approval=True,
)
assert updated.require_approval_for_done is False
assert updated.require_review_before_done is True
assert updated.block_status_changes_with_pending_approval is True
def test_onboarding_confirm_requires_goal_fields() -> None:
"""Onboarding confirm should enforce goal fields for goal board types."""
with pytest.raises(

View File

@@ -0,0 +1,395 @@
from __future__ import annotations
from typing import Literal
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api import tasks as tasks_api
from app.api.deps import ActorContext
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.gateways import Gateway
from app.models.organizations import Organization
from app.models.tasks import Task
from app.schemas.tasks import TaskUpdate
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_task_and_agent(
session: AsyncSession,
*,
task_status: str = "review",
require_approval_for_done: bool = True,
require_review_before_done: bool = False,
block_status_changes_with_pending_approval: bool = False,
) -> tuple[Board, Task, Agent]:
organization_id = uuid4()
gateway = Gateway(
id=uuid4(),
organization_id=organization_id,
name="gateway",
url="https://gateway.local",
workspace_root="/tmp/workspace",
)
board = Board(
id=uuid4(),
organization_id=organization_id,
gateway_id=gateway.id,
name="board",
slug=f"board-{uuid4()}",
require_approval_for_done=require_approval_for_done,
require_review_before_done=require_review_before_done,
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
)
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
agent = Agent(
id=uuid4(),
board_id=board.id,
gateway_id=gateway.id,
name="agent",
status="online",
is_board_lead=False,
)
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
session.add(gateway)
session.add(board)
session.add(task)
session.add(agent)
await session.commit()
return board, task, agent
async def _update_task_to_done(
session: AsyncSession,
*,
task: Task,
agent: Agent,
) -> None:
await _update_task_status(
session,
task=task,
agent=agent,
status="done",
)
async def _update_task_status(
session: AsyncSession,
*,
task: Task,
agent: Agent,
status: Literal["inbox", "in_progress", "review", "done"],
) -> None:
await tasks_api.update_task(
payload=TaskUpdate(status=status),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
@pytest.mark.asyncio
async def test_update_task_rejects_done_without_approved_linked_approval() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(session)
session.add(
Approval(
id=uuid4(),
board_id=board.id,
task_id=task.id,
action_type="task.review",
confidence=65,
status="pending",
),
)
await session.commit()
with pytest.raises(HTTPException) as exc:
await _update_task_to_done(session, task=task, agent=agent)
assert exc.value.status_code == 409
detail = exc.value.detail
assert isinstance(detail, dict)
assert detail["message"] == (
"Task can only be marked done when a linked approval has been approved."
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_done_with_approved_primary_task_approval() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(session)
session.add(
Approval(
id=uuid4(),
board_id=board.id,
task_id=task.id,
action_type="task.review",
confidence=92,
status="approved",
),
)
await session.commit()
updated = await tasks_api.update_task(
payload=TaskUpdate(status="done"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
assert updated.status == "done"
assert updated.assigned_agent_id == agent.id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_done_with_approved_multi_task_link() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(session)
primary_task_id = uuid4()
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
approval_id = uuid4()
session.add(
Approval(
id=approval_id,
board_id=board.id,
task_id=primary_task_id,
action_type="task.batch_review",
confidence=88,
status="approved",
),
)
await session.commit()
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
await session.commit()
updated = await tasks_api.update_task(
payload=TaskUpdate(status="done"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
assert updated.status == "done"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_done_without_approval_when_board_toggle_disabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
_board, task, agent = await _seed_board_task_and_agent(
session,
require_approval_for_done=False,
)
updated = await tasks_api.update_task(
payload=TaskUpdate(status="done"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
assert updated.status == "done"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_rejects_done_from_in_progress_when_review_toggle_enabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
_board, task, agent = await _seed_board_task_and_agent(
session,
task_status="in_progress",
require_approval_for_done=False,
require_review_before_done=True,
)
with pytest.raises(HTTPException) as exc:
await _update_task_to_done(session, task=task, agent=agent)
assert exc.value.status_code == 409
detail = exc.value.detail
assert isinstance(detail, dict)
assert detail["message"] == (
"Task can only be marked done from review when the board rule is enabled."
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_done_from_review_when_review_toggle_enabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
_board, task, agent = await _seed_board_task_and_agent(
session,
task_status="review",
require_approval_for_done=False,
require_review_before_done=True,
)
updated = await tasks_api.update_task(
payload=TaskUpdate(status="done"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
assert updated.status == "done"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(
session,
task_status="inbox",
require_approval_for_done=False,
block_status_changes_with_pending_approval=True,
)
session.add(
Approval(
id=uuid4(),
board_id=board.id,
task_id=task.id,
action_type="task.execute",
confidence=70,
status="pending",
),
)
await session.commit()
with pytest.raises(HTTPException) as exc:
await _update_task_status(
session,
task=task,
agent=agent,
status="in_progress",
)
assert exc.value.status_code == 409
detail = exc.value.detail
assert isinstance(detail, dict)
assert detail["message"] == (
"Task status cannot be changed while a linked approval is pending."
)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(
session,
task_status="inbox",
require_approval_for_done=False,
block_status_changes_with_pending_approval=False,
)
session.add(
Approval(
id=uuid4(),
board_id=board.id,
task_id=task.id,
action_type="task.execute",
confidence=70,
status="pending",
),
)
await session.commit()
updated = await tasks_api.update_task(
payload=TaskUpdate(status="in_progress"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=agent),
)
assert updated.status == "in_progress"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
board, task, agent = await _seed_board_task_and_agent(
session,
task_status="inbox",
require_approval_for_done=False,
block_status_changes_with_pending_approval=True,
)
primary_task_id = uuid4()
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
approval_id = uuid4()
session.add(
Approval(
id=approval_id,
board_id=board.id,
task_id=primary_task_id,
action_type="task.batch_execute",
confidence=73,
status="pending",
),
)
await session.commit()
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
await session.commit()
with pytest.raises(HTTPException) as exc:
await _update_task_status(
session,
task=task,
agent=agent,
status="in_progress",
)
assert exc.value.status_code == 409
finally:
await engine.dispose()