mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
perf(panel): kanban virtualization + row memoization + scorecards batch endpoint (#594)
* perf(panel): kanban virtualization + row memoization + scorecards batch endpoint The audit's remaining phases: the kanban card lists render through @tanstack/react-virtual windows (columns are the dnd drop targets, cards only drag — no sortable conflict) with memoized columns/cards and a stabilized handleAction; the task table's desktop row and mobile card are extracted and memoized (the table itself already client-paginates to 100). Backend: the per-member scorecard N+1 (~20 requests x 3 queries per poll) collapses into GET /dashboard/metrics/members backed by get_all_member_scorecards with grouped rollup/overlay SQL shared with the single-agent path. * test(metrics): shared-DB-safe scorecard tests — unique seeds, delta assertions The two new batch-scorecard tests assumed a private DB: fixed ceo/system slugs collided with other tests' seeds (ix_agents_slug) and a global exactly-one CEO lookup + exact roster count broke in the one-process suite. Unique slugs, subset/disjoint assertions, dead count constant dropped. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -452,6 +452,52 @@ async def test_org_scorecard_endpoint(dashboard_client: AsyncClient) -> None:
|
||||
assert set(body) >= {"member_count", "tasks_completed", "first_pass_yield"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_member_scorecards_endpoint(
|
||||
dashboard_client: AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""The batch scorecard route (N+1 fix) returns one MemberScorecard per
|
||||
non-CEO/non-system agent — the CEO seeded by the fixture is excluded."""
|
||||
dev = AgentTable(
|
||||
id=uuid4(),
|
||||
name="be-dev-1",
|
||||
slug=f"be-dev-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="dev",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(dev)
|
||||
await db_session.flush()
|
||||
|
||||
resp = await dashboard_client.get("/api/dashboard/metrics/members", headers=_HDR)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
ids = {card["id"] for card in body}
|
||||
assert str(dev.id) in ids
|
||||
# The shared per-run DB can hold several CEO rows (other tests seed
|
||||
# their own); every one of them must be excluded from the batch.
|
||||
ceo_ids = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(AgentTable.id).where(AgentTable.role == AgentRole.CEO)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert ceo_ids
|
||||
assert ids.isdisjoint({str(cid) for cid in ceo_ids})
|
||||
for card in body:
|
||||
assert card["scope"] == "member"
|
||||
assert card["member_kind"] == "agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_metrics_404_for_missing_task(
|
||||
dashboard_client: AsyncClient,
|
||||
|
||||
@@ -293,6 +293,162 @@ async def test_member_scorecard_404_and_guards(
|
||||
assert card.utilization is None
|
||||
|
||||
|
||||
_DEV2_OVERLAY_TURNS = 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_member_scorecards_matches_single_agent_and_excludes_ceo_system(
|
||||
svc: MetricsService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""get_all_member_scorecards (the N+1 batch fix) must return the exact
|
||||
same derived numbers get_member_scorecard would for the same agent, plus:
|
||||
CEO/SYSTEM excluded, and a rollup-less agent still appears zeroed instead
|
||||
of silently dropped."""
|
||||
# Unique slugs throughout: the suite shares one DB per run, so a fixed
|
||||
# "ceo"/"system" slug collides with other tests' seeds (ix_agents_slug).
|
||||
dev1 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
|
||||
dev2 = _agent(AgentRole.QA, f"be-qa-{uuid4().hex[:6]}")
|
||||
dev3 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}") # no rollup rows
|
||||
ceo = _agent(AgentRole.CEO, f"ceo-{uuid4().hex[:6]}")
|
||||
system = _agent(AgentRole.SYSTEM, f"system-{uuid4().hex[:6]}")
|
||||
db_session.add_all([dev1, dev2, dev3, ceo, system])
|
||||
await db_session.flush()
|
||||
db_session.add_all(
|
||||
[
|
||||
_daily(
|
||||
dev1.slug,
|
||||
tasks_completed=2,
|
||||
tasks_first_pass=1,
|
||||
active_runtime_seconds=1800,
|
||||
turns=6,
|
||||
qa_reviews_total=3,
|
||||
qa_reviews_passed=2,
|
||||
idle_seconds=600,
|
||||
),
|
||||
_daily(dev2.slug, tasks_completed=1, tasks_first_pass=1),
|
||||
]
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
cards = await svc.get_all_member_scorecards(team=Team.BACKEND, days=30)
|
||||
by_id = {c.id: c for c in cards}
|
||||
# Delta assertions, not a global count: the one-process suite shares
|
||||
# this DB, so other tests' agents may be on the roster too.
|
||||
for seeded in (dev1, dev2, dev3):
|
||||
assert str(seeded.id) in by_id
|
||||
assert str(ceo.id) not in by_id
|
||||
assert str(system.id) not in by_id
|
||||
|
||||
single = await svc.get_member_scorecard(cast("UUID", dev1.id), days=30)
|
||||
assert single is not None
|
||||
batch_dev1 = by_id[str(dev1.id)]
|
||||
assert batch_dev1.tasks_completed == single.tasks_completed
|
||||
assert batch_dev1.turns == single.turns
|
||||
assert batch_dev1.first_pass_yield == single.first_pass_yield
|
||||
assert batch_dev1.qa_pass_rate == single.qa_pass_rate
|
||||
assert batch_dev1.utilization == single.utilization
|
||||
|
||||
# A roster member with no rollup rows still appears (zeroed), not dropped.
|
||||
zeroed = by_id[str(dev3.id)]
|
||||
assert zeroed.tasks_completed == 0
|
||||
assert zeroed.first_pass_yield is None
|
||||
assert zeroed.utilization is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_member_scorecards_attributes_live_overlay_per_agent(
|
||||
svc: MetricsService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""The batched overlay query must attribute each agent's OPEN spawn
|
||||
session to that agent alone — not merge every in-flight agent's effort
|
||||
into one bucket."""
|
||||
dev1 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
|
||||
dev2 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
|
||||
db_session.add_all([dev1, dev2])
|
||||
await db_session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="P",
|
||||
slug=f"p-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=dev1.id,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.flush()
|
||||
db_session.add_all(
|
||||
[
|
||||
_daily(dev1.slug, tasks_completed=1, active_runtime_seconds=100),
|
||||
_daily(dev2.slug, tasks_completed=1, active_runtime_seconds=200),
|
||||
]
|
||||
)
|
||||
|
||||
def _inflight(owner_id: UUID) -> TaskTable:
|
||||
return TaskTable(
|
||||
id=uuid4(),
|
||||
title="t",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
team=Team.BACKEND,
|
||||
project_id=project.id,
|
||||
created_by=owner_id,
|
||||
assigned_to=owner_id,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
started_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
|
||||
t1 = _inflight(cast("UUID", dev1.id))
|
||||
t2 = _inflight(cast("UUID", dev2.id))
|
||||
db_session.add_all([t1, t2])
|
||||
await db_session.flush()
|
||||
now = datetime.now(UTC)
|
||||
db_session.add_all(
|
||||
[
|
||||
AgentSpawnSessionTable(
|
||||
id=uuid4(),
|
||||
agent_slug=dev1.slug,
|
||||
team="backend",
|
||||
role="developer",
|
||||
model="claude",
|
||||
task_id=str(t1.id),
|
||||
started_at=now - timedelta(seconds=200),
|
||||
ended_at=None,
|
||||
turns=_OVERLAY_TURNS,
|
||||
tool_calls=4,
|
||||
tokens_input=10,
|
||||
tokens_output=5,
|
||||
estimated_cost_usd=0.2,
|
||||
),
|
||||
AgentSpawnSessionTable(
|
||||
id=uuid4(),
|
||||
agent_slug=dev2.slug,
|
||||
team="backend",
|
||||
role="developer",
|
||||
model="claude",
|
||||
task_id=str(t2.id),
|
||||
started_at=now - timedelta(seconds=500),
|
||||
ended_at=None,
|
||||
turns=_DEV2_OVERLAY_TURNS,
|
||||
tool_calls=9,
|
||||
tokens_input=20,
|
||||
tokens_output=8,
|
||||
estimated_cost_usd=0.5,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
cards = {c.id: c for c in await svc.get_all_member_scorecards(days=30)}
|
||||
c1, c2 = cards[str(dev1.id)], cards[str(dev2.id)]
|
||||
assert c1.includes_live_inflight is True
|
||||
assert c2.includes_live_inflight is True
|
||||
assert c1.turns == _OVERLAY_TURNS
|
||||
assert c2.turns == _DEV2_OVERLAY_TURNS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_scorecard_aggregates_members(
|
||||
svc: MetricsService, db_session: AsyncSession
|
||||
|
||||
Reference in New Issue
Block a user