From a5af6159d7b0e0b18d91b754792d7754681ea664 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 3 Jun 2026 08:27:10 +0200 Subject: [PATCH] fix(gateway): evidence surfaces the task's board/PM handoff journal entries journal_highlights_for_task was a Phase-1 stub returning [], so evidence() never surfaced the upstream Product Owner / Head of Marketing review. The Main PM picked up a board-reviewed coordination task, saw no handoff, and re-researched from scratch (duplicated work). Query the task's decision/reflection/note entries across authors (slug + role), oldest first, so the Main PM builds on the board's analysis instead of redoing it. --- roboco/services/gateway/evidence_repo.py | 51 +++++++++++++++++++++++- tests/unit/gateway/test_evidence_repo.py | 49 ++++++++++++++++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/roboco/services/gateway/evidence_repo.py b/roboco/services/gateway/evidence_repo.py index 4125f799..965bd4a9 100644 --- a/roboco/services/gateway/evidence_repo.py +++ b/roboco/services/gateway/evidence_repo.py @@ -42,5 +42,52 @@ class EvidenceRepo: return [] async def journal_highlights_for_task(self, task_id: UUID) -> list[dict[str, Any]]: - del task_id - return [] + """The task's upstream handoff: every author's decision / reflection / + note journal entry tied to this task, oldest first. + + This is what lets a downstream owner — e.g. the Main PM picking up a + board-reviewed coordination task — read the Product Owner / Head of + Marketing analysis instead of re-deriving it. Each row carries the + author (slug + role) so the reader knows whose handoff it is. Learning + and struggle entries are personal and excluded. Ownership is enforced by + the caller (``evidence`` only serves the task's assignee), so private + task-scoped entries are surfaced to the owner who needs the full handoff. + """ + from sqlalchemy import select + + from roboco.db.tables import AgentTable, JournalEntryTable, JournalTable + from roboco.models.base import JournalEntryType + + handoff_types = ( + JournalEntryType.DECISION_LOG, + JournalEntryType.TASK_REFLECTION, + JournalEntryType.GENERAL, + ) + query = ( + select( + JournalEntryTable.type, + JournalEntryTable.title, + JournalEntryTable.content, + JournalEntryTable.timestamp, + AgentTable.slug, + AgentTable.role, + ) + .join(JournalTable, JournalEntryTable.journal_id == JournalTable.id) + .join(AgentTable, JournalTable.agent_id == AgentTable.id) + .where(JournalEntryTable.task_id == task_id) + .where(JournalEntryTable.type.in_(handoff_types)) + .order_by(JournalEntryTable.timestamp.asc()) + .limit(50) + ) + result = await self._db.execute(query) + return [ + { + "author": row.slug, + "author_role": str(row.role), + "type": str(row.type), + "title": row.title, + "content": row.content, + "timestamp": row.timestamp.isoformat() if row.timestamp else None, + } + for row in result.all() + ] diff --git a/tests/unit/gateway/test_evidence_repo.py b/tests/unit/gateway/test_evidence_repo.py index b8857b35..76fb71aa 100644 --- a/tests/unit/gateway/test_evidence_repo.py +++ b/tests/unit/gateway/test_evidence_repo.py @@ -1,13 +1,15 @@ -"""roboco.services.gateway.evidence_repo coverage — Phase 1 stubs. +"""roboco.services.gateway.evidence_repo coverage. -EvidenceRepo currently returns empty lists for every method (Phase 1). -Tests confirm the contract and the constructor stores the session — when -Phase 2+ wires real queries, these tests will need real-DB integration. +Most methods are still Phase 1 stubs returning empty lists; +``journal_highlights_for_task`` is wired to a real query, so it is tested +against a mocked ``execute`` result that stands in for the DB rows. """ from __future__ import annotations -from unittest.mock import MagicMock +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest @@ -63,7 +65,42 @@ async def test_blockers_in_lane_returns_empty(repo: EvidenceRepo) -> None: assert out == [] +def _repo_with_rows(rows: list[object]) -> EvidenceRepo: + """EvidenceRepo whose db.execute() yields a result with the given rows.""" + db = MagicMock() + result = MagicMock() + result.all.return_value = rows + db.execute = AsyncMock(return_value=result) + return EvidenceRepo(db) + + @pytest.mark.asyncio -async def test_journal_highlights_for_task_returns_empty(repo: EvidenceRepo) -> None: +async def test_journal_highlights_for_task_empty_when_no_entries() -> None: + repo = _repo_with_rows([]) out = await repo.journal_highlights_for_task(uuid4()) assert out == [] + + +@pytest.mark.asyncio +async def test_journal_highlights_for_task_maps_rows_with_author() -> None: + ts = datetime(2026, 6, 3, 5, 30, tzinfo=UTC) + row = SimpleNamespace( + type="decision_log", + title="PO review", + content="Approve with scope amendments.", + timestamp=ts, + slug="product-owner", + role="product_owner", + ) + repo = _repo_with_rows([row]) + out = await repo.journal_highlights_for_task(uuid4()) + assert out == [ + { + "author": "product-owner", + "author_role": "product_owner", + "type": "decision_log", + "title": "PO review", + "content": "Approve with scope amendments.", + "timestamp": ts.isoformat(), + } + ]