mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""Scales (Board Program) engine route coverage — CEO-only list/approve/
|
|||
|
|
reject. Approving EXECUTES the item against the live target task. Mirrors
|
||
|
|
test_pest_control_routes.py."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from http import HTTPStatus
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
from uuid import UUID, uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
from roboco.api.deps import get_agent_context, get_db
|
||
|
|
from roboco.api.routes.scales import router as scales_router
|
||
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||
|
|
from roboco.foundation import identity as _foundation
|
||
|
|
from roboco.foundation.policy.content import markers
|
||
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
||
|
|
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
|
||
|
|
from roboco.models.permissions import AgentContext
|
||
|
|
from roboco.services.task import SCALES_SOURCE
|
||
|
|
|
||
|
|
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||
|
|
_UNTOUCHED_PRIORITY = 2
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from collections.abc import AsyncIterator
|
||
|
|
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
|
||
|
|
agent = AgentTable(
|
||
|
|
id=uuid4(),
|
||
|
|
name=slug,
|
||
|
|
slug=f"{slug}-{uuid4().hex[:6]}",
|
||
|
|
role=role,
|
||
|
|
team=None,
|
||
|
|
status=AgentStatus.ACTIVE,
|
||
|
|
model_config={},
|
||
|
|
system_prompt="x",
|
||
|
|
capabilities=[],
|
||
|
|
permissions={},
|
||
|
|
metrics={},
|
||
|
|
)
|
||
|
|
session.add(agent)
|
||
|
|
await session.flush()
|
||
|
|
return agent
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed_ceo(session: AsyncSession) -> None:
|
||
|
|
"""The CEO row matching ``CEO_UUID`` — approving an item stamps this id
|
||
|
|
as the actor on the audit row."""
|
||
|
|
if await session.get(AgentTable, CEO_UUID) is not None:
|
||
|
|
return
|
||
|
|
session.add(
|
||
|
|
AgentTable(
|
||
|
|
id=CEO_UUID,
|
||
|
|
name="ceo",
|
||
|
|
slug=f"ceo-{uuid4().hex[:6]}",
|
||
|
|
role=AgentRole.CEO,
|
||
|
|
team=None,
|
||
|
|
status=AgentStatus.ACTIVE,
|
||
|
|
model_config={},
|
||
|
|
system_prompt="x",
|
||
|
|
capabilities=[],
|
||
|
|
permissions={},
|
||
|
|
metrics={},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.flush()
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, TaskTable]:
|
||
|
|
"""Returns (exploration cycle task, live target task the item mutates)."""
|
||
|
|
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||
|
|
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
||
|
|
await _seed_ceo(session)
|
||
|
|
project = ProjectTable(
|
||
|
|
id=uuid4(),
|
||
|
|
name="Backend Service",
|
||
|
|
slug=f"backend-svc-{uuid4().hex[:6]}",
|
||
|
|
git_url="https://example.com/backend-svc.git",
|
||
|
|
assigned_cell=Team.BACKEND,
|
||
|
|
created_by=system.id,
|
||
|
|
)
|
||
|
|
session.add(project)
|
||
|
|
await session.flush()
|
||
|
|
target = TaskTable(
|
||
|
|
id=uuid4(),
|
||
|
|
title="Stale onboarding polish",
|
||
|
|
description="A live backlog task the rebalance plan targets",
|
||
|
|
acceptance_criteria=["done"],
|
||
|
|
status=TaskStatus.BACKLOG,
|
||
|
|
priority=2,
|
||
|
|
task_type=TaskType.CODE,
|
||
|
|
nature=TaskNature.TECHNICAL,
|
||
|
|
estimated_complexity=Complexity.LOW,
|
||
|
|
created_by=system.id,
|
||
|
|
project_id=project.id,
|
||
|
|
team=Team.BACKEND,
|
||
|
|
)
|
||
|
|
session.add(target)
|
||
|
|
await session.flush()
|
||
|
|
task = TaskTable(
|
||
|
|
id=uuid4(),
|
||
|
|
title="Scales portfolio-rebalance cycle",
|
||
|
|
description="Review the live backlog and propose a rebalance.",
|
||
|
|
acceptance_criteria=["propose_rebalance() called once"],
|
||
|
|
status=TaskStatus.PENDING,
|
||
|
|
priority=2,
|
||
|
|
task_type=TaskType.ADMINISTRATIVE,
|
||
|
|
nature=TaskNature.NON_TECHNICAL,
|
||
|
|
estimated_complexity=Complexity.LOW,
|
||
|
|
created_by=system.id,
|
||
|
|
assigned_to=po.id,
|
||
|
|
team=Team.BOARD,
|
||
|
|
source=SCALES_SOURCE,
|
||
|
|
confirmed_by_human=False,
|
||
|
|
)
|
||
|
|
session.add(task)
|
||
|
|
await session.flush()
|
||
|
|
markers.set_rebalance_plan(
|
||
|
|
task,
|
||
|
|
{
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"id": "item-0",
|
||
|
|
"task_ref": str(target.id)[:8],
|
||
|
|
"target_task_id": str(target.id),
|
||
|
|
"target_task_title": target.title,
|
||
|
|
"action": "reprioritize",
|
||
|
|
"new_priority": 0,
|
||
|
|
"rationale": (
|
||
|
|
"Onboarding friction is this quarter's top charter goal"
|
||
|
|
),
|
||
|
|
"status": "proposed",
|
||
|
|
"reject_reason": None,
|
||
|
|
"executed_detail": None,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
await session.flush()
|
||
|
|
return task, target
|
||
|
|
|
||
|
|
|
||
|
|
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
|
||
|
|
app = FastAPI()
|
||
|
|
app.include_router(scales_router, prefix="/api/scales")
|
||
|
|
|
||
|
|
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||
|
|
yield db_session
|
||
|
|
|
||
|
|
async def _override_agent() -> AgentContext:
|
||
|
|
return AgentContext(agent_id=agent_id, role=role, team=None)
|
||
|
|
|
||
|
|
app.dependency_overrides[get_db] = _override_db
|
||
|
|
app.dependency_overrides[get_agent_context] = _override_agent
|
||
|
|
return app
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
||
|
|
await _seed_ceo(db_session)
|
||
|
|
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
|
||
|
|
transport = ASGITransport(app=app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||
|
|
yield client
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_cycles_returns_authored_cycle(
|
||
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||
|
|
) -> None:
|
||
|
|
task, _target = await _seed_cycle(db_session)
|
||
|
|
resp = await ceo_client.get("/api/scales/cycles")
|
||
|
|
assert resp.status_code == HTTPStatus.OK
|
||
|
|
body = resp.json()
|
||
|
|
assert len(body) == 1
|
||
|
|
assert body[0]["task_id"] == str(task.id)
|
||
|
|
assert len(body[0]["items"]) == 1
|
||
|
|
assert body[0]["items"][0]["rationale"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_approve_reprioritize_item_changes_target_priority(
|
||
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||
|
|
) -> None:
|
||
|
|
task, target = await _seed_cycle(db_session)
|
||
|
|
resp = await ceo_client.post(f"/api/scales/cycles/{task.id}/items/item-0/approve")
|
||
|
|
assert resp.status_code == HTTPStatus.OK
|
||
|
|
body = resp.json()
|
||
|
|
assert body["status"] == "approved"
|
||
|
|
assert body["executed_detail"] == "priority changed to P0"
|
||
|
|
|
||
|
|
refreshed_target = await db_session.get(TaskTable, target.id)
|
||
|
|
assert refreshed_target is not None
|
||
|
|
assert refreshed_target.priority == 0
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_approve_cancel_item_cancels_target(
|
||
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||
|
|
) -> None:
|
||
|
|
system = await _seed_agent(db_session, AgentRole.SYSTEM, "system2")
|
||
|
|
po = await _seed_agent(db_session, AgentRole.PRODUCT_OWNER, "po2")
|
||
|
|
project = ProjectTable(
|
||
|
|
id=uuid4(),
|
||
|
|
name="Frontend App",
|
||
|
|
slug=f"frontend-app-{uuid4().hex[:6]}",
|
||
|
|
git_url="https://example.com/frontend-app.git",
|
||
|
|
assigned_cell=Team.FRONTEND,
|
||
|
|
created_by=system.id,
|
||
|
|
)
|
||
|
|
db_session.add(project)
|
||
|
|
await db_session.flush()
|
||
|
|
target = TaskTable(
|
||
|
|
id=uuid4(),
|
||
|
|
title="Dead weight experiment",
|
||
|
|
description="A live backlog task the rebalance plan targets",
|
||
|
|
acceptance_criteria=["done"],
|
||
|
|
status=TaskStatus.BACKLOG,
|
||
|
|
priority=2,
|
||
|
|
task_type=TaskType.CODE,
|
||
|
|
nature=TaskNature.TECHNICAL,
|
||
|
|
estimated_complexity=Complexity.LOW,
|
||
|
|
created_by=system.id,
|
||
|
|
project_id=project.id,
|
||
|
|
team=Team.FRONTEND,
|
||
|
|
)
|
||
|
|
db_session.add(target)
|
||
|
|
await db_session.flush()
|
||
|
|
task = TaskTable(
|
||
|
|
id=uuid4(),
|
||
|
|
title="Scales portfolio-rebalance cycle",
|
||
|
|
description="Review the live backlog and propose a rebalance.",
|
||
|
|
acceptance_criteria=["propose_rebalance() called once"],
|
||
|
|
status=TaskStatus.PENDING,
|
||
|
|
priority=2,
|
||
|
|
task_type=TaskType.ADMINISTRATIVE,
|
||
|
|
nature=TaskNature.NON_TECHNICAL,
|
||
|
|
estimated_complexity=Complexity.LOW,
|
||
|
|
created_by=system.id,
|
||
|
|
assigned_to=po.id,
|
||
|
|
team=Team.BOARD,
|
||
|
|
source=SCALES_SOURCE,
|
||
|
|
confirmed_by_human=False,
|
||
|
|
)
|
||
|
|
db_session.add(task)
|
||
|
|
await db_session.flush()
|
||
|
|
markers.set_rebalance_plan(
|
||
|
|
task,
|
||
|
|
{
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"id": "item-0",
|
||
|
|
"task_ref": str(target.id)[:8],
|
||
|
|
"target_task_id": str(target.id),
|
||
|
|
"target_task_title": target.title,
|
||
|
|
"action": "cancel",
|
||
|
|
"new_priority": None,
|
||
|
|
"rationale": (
|
||
|
|
"Superseded by the new dashboard; no longer on the roadmap"
|
||
|
|
),
|
||
|
|
"status": "proposed",
|
||
|
|
"reject_reason": None,
|
||
|
|
"executed_detail": None,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
resp = await ceo_client.post(f"/api/scales/cycles/{task.id}/items/item-0/approve")
|
||
|
|
assert resp.status_code == HTTPStatus.OK
|
||
|
|
body = resp.json()
|
||
|
|
assert body["status"] == "approved"
|
||
|
|
assert body["executed_detail"] == "cancelled"
|
||
|
|
|
||
|
|
refreshed_target = await db_session.get(TaskTable, target.id)
|
||
|
|
assert refreshed_target is not None
|
||
|
|
assert refreshed_target.status == TaskStatus.CANCELLED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_approve_unknown_item_is_404(
|
||
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||
|
|
) -> None:
|
||
|
|
task, _target = await _seed_cycle(db_session)
|
||
|
|
resp = await ceo_client.post(
|
||
|
|
f"/api/scales/cycles/{task.id}/items/no-such-item/approve"
|
||
|
|
)
|
||
|
|
assert resp.status_code == HTTPStatus.NOT_FOUND
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_reject_item_records_reason(
|
||
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||
|
|
) -> None:
|
||
|
|
task, target = await _seed_cycle(db_session)
|
||
|
|
resp = await ceo_client.post(
|
||
|
|
f"/api/scales/cycles/{task.id}/items/item-0/reject",
|
||
|
|
json={"reason": "still worth doing at current priority"},
|
||
|
|
)
|
||
|
|
assert resp.status_code == HTTPStatus.OK
|
||
|
|
body = resp.json()
|
||
|
|
assert body["status"] == "rejected"
|
||
|
|
|
||
|
|
refreshed = await db_session.get(TaskTable, task.id)
|
||
|
|
assert refreshed is not None
|
||
|
|
payload = markers.get_rebalance_plan(refreshed)
|
||
|
|
assert payload is not None
|
||
|
|
assert (
|
||
|
|
payload["items"][0]["reject_reason"] == "still worth doing at current priority"
|
||
|
|
)
|
||
|
|
|
||
|
|
refreshed_target = await db_session.get(TaskTable, target.id)
|
||
|
|
assert refreshed_target is not None
|
||
|
|
assert refreshed_target.priority == _UNTOUCHED_PRIORITY # untouched
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
||
|
|
await _seed_cycle(db_session)
|
||
|
|
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
||
|
|
transport = ASGITransport(app=app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||
|
|
list_resp = await client.get("/api/scales/cycles")
|
||
|
|
assert list_resp.status_code == HTTPStatus.FORBIDDEN
|
||
|
|
app.dependency_overrides.clear()
|