Merge remote-tracking branch 'origin/master' into perf/activity-events-eventtype-createdat

This commit is contained in:
Abhimanyu Saharan
2026-02-13 11:01:56 +00:00
18 changed files with 203 additions and 22 deletions

View File

@@ -3,8 +3,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from typing import cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status

View File

@@ -4,11 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from enum import Enum
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from enum import Enum
from typing import cast from typing import TYPE_CHECKING, cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status

View File

@@ -3,8 +3,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal, cast
from typing import cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status

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

@@ -41,7 +41,9 @@ def _is_missing_gateway_agent_error(exc: OpenClawGatewayError) -> bool:
message = str(exc).lower() message = str(exc).lower()
if not message: if not message:
return False return False
if any(marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")): if any(
marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")
):
return True return True
return "agent" in message and "not found" in message return "agent" in message and "not found" in message

View File

@@ -77,7 +77,9 @@ def _is_missing_agent_error(exc: OpenClawGatewayError) -> bool:
message = str(exc).lower() message = str(exc).lower()
if not message: if not message:
return False return False
if any(marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")): if any(
marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")
):
return True return True
return "agent" in message and "not found" in message return "agent" in message and "not found" in message

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

@@ -10,8 +10,8 @@ from uuid import uuid4
import pytest import pytest
from app.api import boards
import app.services.board_lifecycle as board_lifecycle import app.services.board_lifecycle as board_lifecycle
from app.api import boards
from app.models.boards import Board from app.models.boards import Board
from app.services.openclaw.gateway_rpc import OpenClawGatewayError from app.services.openclaw.gateway_rpc import OpenClawGatewayError

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

@@ -18,6 +18,6 @@ def test_heartbeat_templates_fit_in_injected_context_limit() -> None:
) )
for name in targets: for name in targets:
size = (TEMPLATES_DIR / name).stat().st_size size = (TEMPLATES_DIR / name).stat().st_size
assert size <= HEARTBEAT_CONTEXT_LIMIT, ( assert (
f"{name} is {size} chars (limit {HEARTBEAT_CONTEXT_LIMIT})" size <= HEARTBEAT_CONTEXT_LIMIT
) ), f"{name} is {size} chars (limit {HEARTBEAT_CONTEXT_LIMIT})"

View File

@@ -24,6 +24,7 @@ export interface BoardRead {
require_approval_for_done?: boolean; require_approval_for_done?: boolean;
require_review_before_done?: boolean; require_review_before_done?: boolean;
block_status_changes_with_pending_approval?: boolean; block_status_changes_with_pending_approval?: boolean;
only_lead_can_change_status?: boolean;
id: string; id: string;
organization_id: string; organization_id: string;
created_at: string; created_at: string;

View File

@@ -24,4 +24,5 @@ export interface BoardUpdate {
require_approval_for_done?: boolean | null; require_approval_for_done?: boolean | null;
require_review_before_done?: boolean | null; require_review_before_done?: boolean | null;
block_status_changes_with_pending_approval?: boolean | null; block_status_changes_with_pending_approval?: boolean | null;
only_lead_can_change_status?: boolean | null;
} }

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