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

This commit is contained in:
Abhimanyu Saharan
2026-02-13 15:07:15 +05:30
committed by GitHub
105 changed files with 7175 additions and 611 deletions

View File

@@ -0,0 +1,11 @@
from app.core.version import APP_NAME, APP_VERSION
def test_app_name_constant() -> None:
assert APP_NAME == "mission-control"
def test_app_version_semver_format() -> None:
parts = APP_VERSION.split(".")
assert len(parts) == 3
assert all(part.isdigit() for part in parts)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from uuid import UUID, uuid4
import pytest
@@ -345,3 +346,92 @@ async def test_control_plane_upsert_agent_handles_already_exists(monkeypatch):
assert calls[0][0] == "agents.create"
assert calls[1][0] == "agents.update"
def test_is_missing_agent_error_matches_gateway_agent_not_found() -> None:
assert agent_provisioning._is_missing_agent_error(
agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found'),
)
assert not agent_provisioning._is_missing_agent_error(
agent_provisioning.OpenClawGatewayError("dial tcp: connection refused"),
)
@pytest.mark.asyncio
async def test_delete_agent_lifecycle_ignores_missing_gateway_agent(monkeypatch) -> None:
class _ControlPlaneStub:
def __init__(self) -> None:
self.deleted_sessions: list[str] = []
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
_ = (agent_id, delete_files)
raise agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found')
async def delete_agent_session(self, session_key: str) -> None:
self.deleted_sessions.append(session_key)
gateway = _GatewayStub(
id=uuid4(),
name="Acme",
url="ws://gateway.example/ws",
token=None,
workspace_root="/tmp/openclaw",
)
agent = SimpleNamespace(
id=uuid4(),
name="Worker",
board_id=uuid4(),
openclaw_session_id=None,
is_board_lead=False,
)
control_plane = _ControlPlaneStub()
monkeypatch.setattr(agent_provisioning, "_control_plane_for_gateway", lambda _g: control_plane)
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
agent=agent, # type: ignore[arg-type]
gateway=gateway, # type: ignore[arg-type]
delete_files=True,
delete_session=True,
)
assert len(control_plane.deleted_sessions) == 1
@pytest.mark.asyncio
async def test_delete_agent_lifecycle_raises_on_non_missing_agent_error(monkeypatch) -> None:
class _ControlPlaneStub:
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
_ = (agent_id, delete_files)
raise agent_provisioning.OpenClawGatewayError("gateway timeout")
async def delete_agent_session(self, session_key: str) -> None:
_ = session_key
raise AssertionError("delete_agent_session should not be called")
gateway = _GatewayStub(
id=uuid4(),
name="Acme",
url="ws://gateway.example/ws",
token=None,
workspace_root="/tmp/openclaw",
)
agent = SimpleNamespace(
id=uuid4(),
name="Worker",
board_id=uuid4(),
openclaw_session_id=None,
is_board_lead=False,
)
monkeypatch.setattr(
agent_provisioning,
"_control_plane_for_gateway",
lambda _g: _ControlPlaneStub(),
)
with pytest.raises(agent_provisioning.OpenClawGatewayError):
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
agent=agent, # type: ignore[arg-type]
gateway=gateway, # type: ignore[arg-type]
delete_files=True,
delete_session=True,
)

View File

@@ -51,22 +51,25 @@ async def test_create_approval_rejects_duplicate_pending_for_same_task() -> None
async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=1)
task_id = task_ids[0]
await approvals_api.create_approval(
created = await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_id,
payload={"reason": "Initial execution needs confirmation."},
confidence=80,
status="pending",
),
board=board,
session=session,
)
assert created.task_titles == [f"task-{task_id}"]
with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.retry",
task_id=task_id,
payload={"reason": "Retry should still be gated."},
confidence=77,
status="pending",
),
@@ -91,22 +94,25 @@ async def test_create_approval_rejects_pending_conflict_from_linked_task_ids() -
async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=2)
task_a, task_b = task_ids
await approvals_api.create_approval(
created = await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.batch_execute",
task_ids=[task_a, task_b],
payload={"reason": "Batch operation requires sign-off."},
confidence=85,
status="pending",
),
board=board,
session=session,
)
assert created.task_titles == [f"task-{task_a}", f"task-{task_b}"]
with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval(
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_b,
payload={"reason": "Single task overlaps with pending batch."},
confidence=70,
status="pending",
),
@@ -135,6 +141,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate(
action_type="task.execute",
task_id=task_id,
payload={"reason": "Primary pending approval is active."},
confidence=83,
status="pending",
),
@@ -145,6 +152,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate(
action_type="task.review",
task_id=task_id,
payload={"reason": "Review decision completed earlier."},
confidence=90,
status="approved",
),

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas.approvals import ApprovalCreate
def test_approval_create_requires_confidence_score() -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Missing confidence should fail."},
},
)
@pytest.mark.parametrize("confidence", [-1.0, 101.0])
def test_approval_create_rejects_out_of_range_confidence(confidence: float) -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Confidence must be in range."},
"confidence": confidence,
},
)
def test_approval_create_requires_lead_reasoning() -> None:
with pytest.raises(ValidationError, match="lead reasoning is required"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
},
)
def test_approval_create_accepts_nested_decision_reason() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
"payload": {"decision": {"reason": "Needs manual approval."}},
},
)
assert model.payload == {"decision": {"reason": "Needs manual approval."}}
def test_approval_create_accepts_float_confidence() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 88.75,
"payload": {"reason": "Fractional confidence should be preserved."},
},
)
assert model.confidence == 88.75

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,282 @@
# ruff: noqa: INP001
"""Integration tests for board webhook ingestion behavior."""
from __future__ import annotations
from uuid import UUID, uuid4
import pytest
from fastapi import APIRouter, Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api import board_webhooks
from app.api.board_webhooks import router as board_webhooks_router
from app.api.deps import get_board_or_404
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organizations import Organization
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
def _build_test_app(
session_maker: async_sessionmaker[AsyncSession],
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(board_webhooks_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
async def _override_get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
) -> Board:
board = await Board.objects.by_id(UUID(board_id)).first(session)
if board is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return board
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[get_board_or_404] = _override_get_board_or_404
return app
async def _seed_webhook(
session: AsyncSession,
*,
enabled: bool,
) -> tuple[Board, BoardWebhook]:
organization_id = uuid4()
gateway_id = uuid4()
board_id = uuid4()
webhook_id = uuid4()
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
session.add(
Gateway(
id=gateway_id,
organization_id=organization_id,
name="gateway",
url="https://gateway.example.local",
workspace_root="/tmp/workspace",
),
)
board = Board(
id=board_id,
organization_id=organization_id,
gateway_id=gateway_id,
name="Launch board",
slug="launch-board",
description="Board for launch automation.",
)
session.add(board)
session.add(
Agent(
id=uuid4(),
board_id=board_id,
gateway_id=gateway_id,
name="Lead Agent",
status="online",
openclaw_session_id="lead:session:key",
is_board_lead=True,
),
)
webhook = BoardWebhook(
id=webhook_id,
board_id=board_id,
description="Triage payload and create tasks for impacted services.",
enabled=enabled,
)
session.add(webhook)
await session.commit()
return board, webhook
@pytest.mark.asyncio
async def test_ingest_board_webhook_stores_payload_and_notifies_lead(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
app = _build_test_app(session_maker)
sent_messages: list[dict[str, str]] = []
async with session_maker() as session:
board, webhook = await _seed_webhook(session, enabled=True)
async def _fake_optional_gateway_config_for_board(
self: board_webhooks.GatewayDispatchService,
_board: Board,
) -> object:
return object()
async def _fake_try_send_agent_message(
self: board_webhooks.GatewayDispatchService,
*,
session_key: str,
config: object,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
del self, config, deliver
sent_messages.append(
{
"session_key": session_key,
"agent_name": agent_name,
"message": message,
},
)
return None
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"optional_gateway_config_for_board",
_fake_optional_gateway_config_for_board,
)
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"try_send_agent_message",
_fake_try_send_agent_message,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.post(
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
json={"event": "deploy", "service": "api"},
headers={"X-Signature": "sha256=abc123"},
)
assert response.status_code == 202
body = response.json()
payload_id = UUID(body["payload_id"])
assert body["board_id"] == str(board.id)
assert body["webhook_id"] == str(webhook.id)
async with session_maker() as session:
payloads = (
await session.exec(
select(BoardWebhookPayload).where(col(BoardWebhookPayload.id) == payload_id),
)
).all()
assert len(payloads) == 1
assert payloads[0].payload == {"event": "deploy", "service": "api"}
assert payloads[0].headers is not None
assert payloads[0].headers.get("x-signature") == "sha256=abc123"
assert payloads[0].headers.get("content-type") == "application/json"
memory_items = (
await session.exec(
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
)
).all()
assert len(memory_items) == 1
assert memory_items[0].source == "webhook"
assert memory_items[0].tags is not None
assert f"webhook:{webhook.id}" in memory_items[0].tags
assert f"payload:{payload_id}" in memory_items[0].tags
assert f"Payload ID: {payload_id}" in memory_items[0].content
assert len(sent_messages) == 1
assert sent_messages[0]["session_key"] == "lead:session:key"
assert "WEBHOOK EVENT RECEIVED" in sent_messages[0]["message"]
assert str(payload_id) in sent_messages[0]["message"]
assert webhook.description in sent_messages[0]["message"]
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_ingest_board_webhook_rejects_disabled_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
app = _build_test_app(session_maker)
sent_messages: list[str] = []
async with session_maker() as session:
board, webhook = await _seed_webhook(session, enabled=False)
async def _fake_try_send_agent_message(
self: board_webhooks.GatewayDispatchService,
*,
session_key: str,
config: object,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
del self, session_key, config, agent_name, deliver
sent_messages.append(message)
return None
monkeypatch.setattr(
board_webhooks.GatewayDispatchService,
"try_send_agent_message",
_fake_try_send_agent_message,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.post(
f"/api/v1/boards/{board.id}/webhooks/{webhook.id}",
json={"event": "deploy"},
)
assert response.status_code == 410
assert response.json() == {"detail": "Webhook is disabled."}
async with session_maker() as session:
stored_payloads = (
await session.exec(
select(BoardWebhookPayload).where(
col(BoardWebhookPayload.board_id) == board.id
),
)
).all()
assert stored_payloads == []
stored_memory = (
await session.exec(
select(BoardMemory).where(col(BoardMemory.board_id) == board.id),
)
).all()
assert stored_memory == []
assert sent_messages == []
finally:
await engine.dispose()

View File

@@ -4,13 +4,16 @@
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from app.api import boards
import app.services.board_lifecycle as board_lifecycle
from app.models.boards import Board
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
_NO_EXEC_RESULTS_ERROR = "No more exec_results left for session.exec"
@@ -63,3 +66,91 @@ async def test_delete_board_cleans_org_board_access_rows() -> None:
assert "organization_invite_board_access" in deleted_table_names
assert board in session.deleted
assert session.committed == 1
@pytest.mark.asyncio
async def test_delete_board_cleans_tag_assignments_before_tasks() -> None:
"""Deleting a board should remove task-tag links before deleting tasks."""
session: Any = _FakeSession(exec_results=[[], [uuid4()]])
board = Board(
id=uuid4(),
organization_id=uuid4(),
name="Demo Board",
slug="demo-board",
gateway_id=None,
)
await boards.delete_board(
session=session,
board=board,
)
deleted_table_names = [statement.table.name for statement in session.executed]
assert "tag_assignments" in deleted_table_names
assert deleted_table_names.index("tag_assignments") < deleted_table_names.index("tasks")
@pytest.mark.asyncio
async def test_delete_board_ignores_missing_gateway_agent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Deleting a board should continue when gateway reports agent not found."""
session: Any = _FakeSession(exec_results=[[]])
board = Board(
id=uuid4(),
organization_id=uuid4(),
name="Demo Board",
slug="demo-board",
gateway_id=uuid4(),
)
agent = SimpleNamespace(id=uuid4(), board_id=board.id)
gateway = SimpleNamespace(url="ws://gateway.example/ws", token=None, workspace_root="/tmp")
called = {"delete_agent_lifecycle": 0}
async def _fake_all(_session: object) -> list[object]:
return [agent]
async def _fake_require_gateway_for_board(
_session: object,
_board: object,
*,
require_workspace_root: bool,
) -> object:
_ = require_workspace_root
return gateway
async def _fake_delete_agent_lifecycle(
_self: object,
*,
agent: object,
gateway: object,
delete_files: bool = True,
delete_session: bool = True,
) -> str | None:
_ = (agent, gateway, delete_files, delete_session)
called["delete_agent_lifecycle"] += 1
raise OpenClawGatewayError('agent "mc-worker" not found')
monkeypatch.setattr(
board_lifecycle.Agent,
"objects",
SimpleNamespace(filter_by=lambda **_kwargs: SimpleNamespace(all=_fake_all)),
)
monkeypatch.setattr(
board_lifecycle,
"require_gateway_for_board",
_fake_require_gateway_for_board,
)
monkeypatch.setattr(board_lifecycle, "gateway_client_config", lambda _gateway: None)
monkeypatch.setattr(
board_lifecycle.OpenClawGatewayProvisioner,
"delete_agent_lifecycle",
_fake_delete_agent_lifecycle,
)
await boards.delete_board(
session=session,
board=board,
)
assert called["delete_agent_lifecycle"] == 1
assert board in session.deleted
assert session.committed == 1

View File

@@ -15,6 +15,7 @@ from app.core.error_handling import (
_error_payload,
_get_request_id,
_http_exception_exception_handler,
_json_safe,
_request_validation_exception_handler,
_response_validation_exception_handler,
_request_validation_handler,
@@ -41,6 +42,31 @@ def test_request_validation_error_includes_request_id():
assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"]
def test_request_validation_error_handles_bytes_input_without_500():
class Payload(BaseModel):
content: str
app = FastAPI()
install_error_handling(app)
@app.put("/needs-object")
def needs_object(payload: Payload) -> dict[str, str]:
return {"content": payload.content}
client = TestClient(app, raise_server_exceptions=False)
resp = client.put(
"/needs-object",
content=b"plain-text-body",
headers={"content-type": "text/plain"},
)
assert resp.status_code == 422
body = resp.json()
assert isinstance(body.get("detail"), list)
assert isinstance(body.get("request_id"), str) and body["request_id"]
assert resp.headers.get(REQUEST_ID_HEADER) == body["request_id"]
def test_http_exception_includes_request_id():
app = FastAPI()
install_error_handling(app)
@@ -187,6 +213,20 @@ def test_error_payload_omits_request_id_when_none() -> None:
assert _error_payload(detail="x", request_id=None) == {"detail": "x"}
def test_json_safe_handles_binary_inputs() -> None:
assert _json_safe(b"\xf0\x9f\x92\xa1") == "💡"
assert _json_safe(bytearray(b"hello")) == "hello"
assert _json_safe(memoryview(b"world")) == "world"
def test_json_safe_falls_back_to_string_for_unknown_objects() -> None:
class Weird:
def __str__(self) -> str:
return "weird-value"
assert _json_safe(Weird()) == "weird-value"
@pytest.mark.asyncio
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
req = Request({"type": "http", "headers": [], "state": {}})

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.api import metrics as metrics_api
class _FakeSession:
def __init__(self, exec_result: list[object]) -> None:
self._exec_result = exec_result
async def exec(self, _statement: object) -> list[object]:
return self._exec_result
@pytest.mark.asyncio
async def test_resolve_dashboard_board_ids_returns_requested_board(
monkeypatch: pytest.MonkeyPatch,
) -> None:
board_id = uuid4()
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
return [board_id]
monkeypatch.setattr(
metrics_api,
"list_accessible_board_ids",
_accessible,
)
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
resolved = await metrics_api._resolve_dashboard_board_ids(
_FakeSession([]),
ctx=ctx,
board_id=board_id,
group_id=None,
)
assert resolved == [board_id]
@pytest.mark.asyncio
async def test_resolve_dashboard_board_ids_rejects_inaccessible_board(
monkeypatch: pytest.MonkeyPatch,
) -> None:
accessible_board_id = uuid4()
requested_board_id = uuid4()
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
return [accessible_board_id]
monkeypatch.setattr(
metrics_api,
"list_accessible_board_ids",
_accessible,
)
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
with pytest.raises(HTTPException) as exc_info:
await metrics_api._resolve_dashboard_board_ids(
_FakeSession([]),
ctx=ctx,
board_id=requested_board_id,
group_id=None,
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_resolve_dashboard_board_ids_filters_by_group(
monkeypatch: pytest.MonkeyPatch,
) -> None:
board_a = uuid4()
board_b = uuid4()
group_id = uuid4()
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
return [board_a, board_b]
monkeypatch.setattr(
metrics_api,
"list_accessible_board_ids",
_accessible,
)
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
session = _FakeSession([board_b])
resolved = await metrics_api._resolve_dashboard_board_ids(
session,
ctx=ctx,
board_id=None,
group_id=group_id,
)
assert resolved == [board_b]
@pytest.mark.asyncio
async def test_resolve_dashboard_board_ids_returns_empty_when_board_not_in_group(
monkeypatch: pytest.MonkeyPatch,
) -> None:
board_id = uuid4()
group_id = uuid4()
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
return [board_id]
monkeypatch.setattr(
metrics_api,
"list_accessible_board_ids",
_accessible,
)
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
session = _FakeSession([])
resolved = await metrics_api._resolve_dashboard_board_ids(
session,
ctx=ctx,
board_id=board_id,
group_id=group_id,
)
assert resolved == []

View File

@@ -0,0 +1,80 @@
# ruff: noqa: S101
"""OpenAPI role-tag coverage for agent-facing endpoint discovery."""
from __future__ import annotations
from app.main import app
def _op_tags(schema: dict[str, object], *, path: str, method: str) -> set[str]:
op = schema["paths"][path][method]
return set(op.get("tags", []))
def _op_description(schema: dict[str, object], *, path: str, method: str) -> str:
op = schema["paths"][path][method]
return str(op.get("description", "")).strip()
def test_openapi_agent_role_tags_are_exposed() -> None:
"""Role tags should be queryable without path-based heuristics."""
schema = app.openapi()
assert "agent-lead" in _op_tags(
schema,
path="/api/v1/agent/boards/{board_id}/tasks",
method="post",
)
assert "agent-worker" in _op_tags(
schema,
path="/api/v1/agent/boards/{board_id}/tasks",
method="get",
)
assert "agent-main" in _op_tags(
schema,
path="/api/v1/agent/gateway/leads/broadcast",
method="post",
)
assert "agent-worker" in _op_tags(
schema,
path="/api/v1/boards/{board_id}/group-memory",
method="get",
)
assert "agent-lead" in _op_tags(
schema,
path="/api/v1/boards/{board_id}/group-snapshot",
method="get",
)
heartbeat_tags = _op_tags(schema, path="/api/v1/agent/heartbeat", method="post")
assert {"agent-lead", "agent-worker", "agent-main"} <= heartbeat_tags
def test_openapi_agent_role_endpoint_descriptions_exist() -> None:
"""Agent-role endpoints should provide human-readable operation guidance."""
schema = app.openapi()
assert _op_description(
schema,
path="/api/v1/agent/boards/{board_id}/tasks",
method="post",
)
assert _op_description(
schema,
path="/api/v1/agent/boards/{board_id}/tasks/{task_id}",
method="patch",
)
assert _op_description(
schema,
path="/api/v1/agent/heartbeat",
method="post",
)
assert _op_description(
schema,
path="/api/v1/boards/{board_id}/group-memory",
method="get",
)
assert _op_description(
schema,
path="/api/v1/boards/{board_id}/group-snapshot",
method="get",
)

View File

@@ -59,6 +59,8 @@ async def test_delete_my_org_cleans_dependents_before_organization_delete() -> N
"approval_task_links",
"approvals",
"board_memory",
"board_webhook_payloads",
"board_webhooks",
"board_onboarding_sessions",
"organization_board_access",
"organization_invite_board_access",

View File

@@ -0,0 +1,401 @@
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()

View File

@@ -0,0 +1,23 @@
# ruff: noqa: S101
"""Template size guardrails for injected heartbeat context."""
from __future__ import annotations
from pathlib import Path
HEARTBEAT_CONTEXT_LIMIT = 20_000
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates"
def test_heartbeat_templates_fit_in_injected_context_limit() -> None:
"""Heartbeat templates must stay under gateway injected-context truncation limit."""
targets = (
"HEARTBEAT_LEAD.md",
"HEARTBEAT_AGENT.md",
"MAIN_HEARTBEAT.md",
)
for name in targets:
size = (TEMPLATES_DIR / name).stat().st_size
assert size <= HEARTBEAT_CONTEXT_LIMIT, (
f"{name} is {size} chars (limit {HEARTBEAT_CONTEXT_LIMIT})"
)