feat: add board rule toggles for approval and review requirements
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user