feat: add lead-only status change rule for boards and update related logic

This commit is contained in:
Abhimanyu Saharan
2026-02-13 16:21:54 +05:30
parent 366f5231ab
commit ebb9c659d2
9 changed files with 187 additions and 9 deletions

View File

@@ -1061,9 +1061,7 @@ async def update_task(
board_id=board_id, board_id=board_id,
previous_status=previous_status, previous_status=previous_status,
previous_assigned=previous_assigned, previous_assigned=previous_assigned,
status_requested=( status_requested=(requested_status is not None and requested_status != previous_status),
requested_status is not None and requested_status != previous_status
),
updates=updates, updates=updates,
comment=comment, comment=comment,
depends_on_task_ids=depends_on_task_ids, depends_on_task_ids=depends_on_task_ids,
@@ -1678,6 +1676,18 @@ async def _apply_non_lead_agent_task_rules(
): ):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in update.updates: if "status" in update.updates:
only_lead_can_change_status = (
await session.exec(
select(col(Board.only_lead_can_change_status)).where(
col(Board.id) == update.board_id,
),
)
).first()
if only_lead_can_change_status:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only board leads can change task status.",
)
status_value = _required_status_value(update.updates["status"]) status_value = _required_status_value(update.updates["status"])
if status_value != "inbox": if status_value != "inbox":
dep_ids = await _task_dep_ids( dep_ids = await _task_dep_ids(

View File

@@ -42,5 +42,6 @@ class Board(TenantScoped, table=True):
require_approval_for_done: bool = Field(default=True) require_approval_for_done: bool = Field(default=True)
require_review_before_done: bool = Field(default=False) require_review_before_done: bool = Field(default=False)
block_status_changes_with_pending_approval: bool = Field(default=False) block_status_changes_with_pending_approval: bool = Field(default=False)
only_lead_can_change_status: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow) created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -32,6 +32,7 @@ class BoardBase(SQLModel):
require_approval_for_done: bool = True require_approval_for_done: bool = True
require_review_before_done: bool = False require_review_before_done: bool = False
block_status_changes_with_pending_approval: bool = False block_status_changes_with_pending_approval: bool = False
only_lead_can_change_status: bool = False
class BoardCreate(BoardBase): class BoardCreate(BoardBase):
@@ -74,6 +75,7 @@ class BoardUpdate(SQLModel):
require_approval_for_done: bool | None = None require_approval_for_done: bool | None = None
require_review_before_done: bool | None = None require_review_before_done: bool | None = None
block_status_changes_with_pending_approval: bool | None = None block_status_changes_with_pending_approval: bool | None = None
only_lead_can_change_status: bool | None = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_gateway_id(self) -> Self: def validate_gateway_id(self) -> Self:

View File

@@ -0,0 +1,43 @@
"""add lead-only status change board rule
Revision ID: 1a7b2c3d4e5f
Revises: c2e9f1a6d4b8
Create Date: 2026-02-13 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1a7b2c3d4e5f"
down_revision = "fa6e83f8d9a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
board_columns = {column["name"] for column in inspector.get_columns("boards")}
if "only_lead_can_change_status" not in board_columns:
op.add_column(
"boards",
sa.Column(
"only_lead_can_change_status",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
board_columns = {column["name"] for column in inspector.get_columns("boards")}
if "only_lead_can_change_status" in board_columns:
op.drop_column("boards", "only_lead_can_change_status")

View File

@@ -87,7 +87,7 @@ If you create cron jobs, track them in memory and delete them when no longer nee
## Collaboration (mandatory) ## Collaboration (mandatory)
- You are one of multiple agents on a board. Act like a team, not a silo. - You are one of multiple agents on a board. Act like a team, not a silo.
- The assigned agent is the DRI for a task. Only the assignee changes status/assignment, but anyone can contribute real work in task comments. - The assigned agent is the DRI for a task. Anyone can contribute real work in task comments.
- Task comments are the primary channel for agent-to-agent collaboration. - Task comments are the primary channel for agent-to-agent collaboration.
- Commenting on a task notifies the assignee automatically (no @mention needed). - Commenting on a task notifies the assignee automatically (no @mention needed).
- Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work). - Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work).

View File

@@ -66,7 +66,6 @@ jq -r '
## Task mentions ## Task mentions
- If you receive TASK MENTION or are @mentioned in a task, reply in that task. - If you receive TASK MENTION or are @mentioned in a task, reply in that task.
- If you are not assigned, do not change task status or assignment.
- If a non-lead peer posts a task update and you are not mentioned, only reply when you add net-new value. - If a non-lead peer posts a task update and you are not mentioned, only reply when you add net-new value.
## Board chat messages ## Board chat messages

View File

@@ -87,15 +87,18 @@ def test_board_rule_toggles_have_expected_defaults() -> None:
assert created.require_approval_for_done is True assert created.require_approval_for_done is True
assert created.require_review_before_done is False assert created.require_review_before_done is False
assert created.block_status_changes_with_pending_approval is False assert created.block_status_changes_with_pending_approval is False
assert created.only_lead_can_change_status is False
updated = BoardUpdate( updated = BoardUpdate(
require_approval_for_done=False, require_approval_for_done=False,
require_review_before_done=True, require_review_before_done=True,
block_status_changes_with_pending_approval=True, block_status_changes_with_pending_approval=True,
only_lead_can_change_status=True,
) )
assert updated.require_approval_for_done is False assert updated.require_approval_for_done is False
assert updated.require_review_before_done is True assert updated.require_review_before_done is True
assert updated.block_status_changes_with_pending_approval is True assert updated.block_status_changes_with_pending_approval is True
assert updated.only_lead_can_change_status is True
def test_onboarding_confirm_requires_goal_fields() -> None: def test_onboarding_confirm_requires_goal_fields() -> None:

View File

@@ -18,7 +18,7 @@ from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.organizations import Organization from app.models.organizations import Organization
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.tasks import TaskUpdate from app.schemas.tasks import TaskRead, TaskUpdate
async def _make_engine() -> AsyncEngine: async def _make_engine() -> AsyncEngine:
@@ -39,6 +39,8 @@ async def _seed_board_task_and_agent(
require_approval_for_done: bool = True, require_approval_for_done: bool = True,
require_review_before_done: bool = False, require_review_before_done: bool = False,
block_status_changes_with_pending_approval: bool = False, block_status_changes_with_pending_approval: bool = False,
only_lead_can_change_status: bool = False,
agent_is_board_lead: bool = False,
) -> tuple[Board, Task, Agent]: ) -> tuple[Board, Task, Agent]:
organization_id = uuid4() organization_id = uuid4()
gateway = Gateway( gateway = Gateway(
@@ -57,6 +59,7 @@ async def _seed_board_task_and_agent(
require_approval_for_done=require_approval_for_done, require_approval_for_done=require_approval_for_done,
require_review_before_done=require_review_before_done, require_review_before_done=require_review_before_done,
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval, block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
only_lead_can_change_status=only_lead_can_change_status,
) )
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status) task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
agent = Agent( agent = Agent(
@@ -65,7 +68,7 @@ async def _seed_board_task_and_agent(
gateway_id=gateway.id, gateway_id=gateway.id,
name="agent", name="agent",
status="online", status="online",
is_board_lead=False, is_board_lead=agent_is_board_lead,
) )
session.add(Organization(id=organization_id, name=f"org-{organization_id}")) session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
@@ -97,8 +100,8 @@ async def _update_task_status(
task: Task, task: Task,
agent: Agent, agent: Agent,
status: Literal["inbox", "in_progress", "review", "done"], status: Literal["inbox", "in_progress", "review", "done"],
) -> None: ) -> TaskRead:
await tasks_api.update_task( return await tasks_api.update_task(
payload=TaskUpdate(status=status), payload=TaskUpdate(status=status),
task=task, task=task,
session=session, session=session,
@@ -356,6 +359,81 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
await engine.dispose() await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_rejects_non_lead_status_change_when_only_lead_rule_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,
only_lead_can_change_status=True,
)
with pytest.raises(HTTPException) as exc:
await _update_task_status(
session,
task=task,
agent=agent,
status="in_progress",
)
assert exc.value.status_code == 403
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_allows_non_lead_status_change_when_only_lead_rule_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,
only_lead_can_change_status=False,
)
updated = await _update_task_status(
session,
task=task,
agent=agent,
status="in_progress",
)
assert updated.status == "in_progress"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_update_task_lead_can_still_change_status_when_only_lead_rule_enabled() -> None:
engine = await _make_engine()
try:
async with await _make_session(engine) as session:
_board, task, lead_agent = await _seed_board_task_and_agent(
session,
task_status="review",
require_approval_for_done=False,
require_review_before_done=False,
only_lead_can_change_status=True,
agent_is_board_lead=True,
)
updated = await tasks_api.update_task(
payload=TaskUpdate(status="inbox"),
task=task,
session=session,
actor=ActorContext(actor_type="agent", agent=lead_agent),
)
assert updated.status == "inbox"
finally:
await engine.dispose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_task_allows_dependency_change_with_pending_approval() -> None: async def test_update_task_allows_dependency_change_with_pending_approval() -> None:
engine = await _make_engine() engine = await _make_engine()

View File

@@ -231,6 +231,9 @@ export default function EditBoardPage() {
blockStatusChangesWithPendingApproval, blockStatusChangesWithPendingApproval,
setBlockStatusChangesWithPendingApproval, setBlockStatusChangesWithPendingApproval,
] = useState<boolean | undefined>(undefined); ] = useState<boolean | undefined>(undefined);
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
boolean | undefined
>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>( const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
undefined, undefined,
); );
@@ -425,6 +428,8 @@ export default function EditBoardPage() {
blockStatusChangesWithPendingApproval ?? blockStatusChangesWithPendingApproval ??
baseBoard?.block_status_changes_with_pending_approval ?? baseBoard?.block_status_changes_with_pending_approval ??
false; false;
const resolvedOnlyLeadCanChangeStatus =
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
const resolvedSuccessMetrics = const resolvedSuccessMetrics =
successMetrics ?? successMetrics ??
(baseBoard?.success_metrics (baseBoard?.success_metrics
@@ -498,6 +503,7 @@ export default function EditBoardPage() {
setBlockStatusChangesWithPendingApproval( setBlockStatusChangesWithPendingApproval(
updated.block_status_changes_with_pending_approval ?? false, updated.block_status_changes_with_pending_approval ?? false,
); );
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
setSuccessMetrics( setSuccessMetrics(
updated.success_metrics updated.success_metrics
? JSON.stringify(updated.success_metrics, null, 2) ? JSON.stringify(updated.success_metrics, null, 2)
@@ -559,6 +565,7 @@ export default function EditBoardPage() {
require_review_before_done: resolvedRequireReviewBeforeDone, require_review_before_done: resolvedRequireReviewBeforeDone,
block_status_changes_with_pending_approval: block_status_changes_with_pending_approval:
resolvedBlockStatusChangesWithPendingApproval, resolvedBlockStatusChangesWithPendingApproval,
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
target_date: target_date:
resolvedBoardType === "general" resolvedBoardType === "general"
@@ -924,6 +931,41 @@ export default function EditBoardPage() {
</span> </span>
</span> </span>
</div> </div>
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedOnlyLeadCanChangeStatus}
aria-label="Only lead can change status"
onClick={() =>
setOnlyLeadCanChangeStatus(
!resolvedOnlyLeadCanChangeStatus,
)
}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedOnlyLeadCanChangeStatus
? "border-emerald-600 bg-emerald-600"
: "border-slate-300 bg-slate-200"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
resolvedOnlyLeadCanChangeStatus
? "translate-x-5"
: "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-slate-900">
Only lead can change status
</span>
<span className="block text-xs text-slate-600">
Restrict status changes to the board lead.
</span>
</span>
</div>
</section> </section>
{gateways.length === 0 ? ( {gateways.length === 0 ? (