[4cfd99c2] Backend: docs-divergence engine, feature flag, release seam, and compose wiring (#507) (#513)

* [fe5c049b] Register docs-sync feature flag and compose wiring (#505)

* [fe5c049b] Register docs-sync feature flag and compose wiring

* [fe5c049b] feat(config): wire ROBOCO_DOCS_SYNC_ENABLED flag and compose defaults

* [fe5c049b] docs(config): document ROBOCO_DOCS_SYNC_ENABLED flag and compose defaults

---------




* [687574d2] Implement docs-sync engine and release-proposal seam (#506)

* [687574d2] Add docs-sync engine and release-proposal publish seam

* [687574d2] Restore task.py safeguards deleted by docs-sync engine commit and filter docs_sync version in SQL

* [687574d2] docs(map): add engine-docs-sync architecture map and cross-references

* [687574d2] docs(config): update docs-sync flag, cap settings, and changelog entry

---------




* [3e7cd5a8] Fix task.py regressions from docs-sync PR (#509)

* [3e7cd5a8] fix(task): restore deleted auditor alerts and revert descendant cast form in task.py

* [3e7cd5a8] docs(task-service): restore auditor alerts and cast notes in map and changelog

---------




* [e6e23c1f] Enforce docs_sync_max_per_cycle cap in docs_sync_engine.py (#510)

* [e6e23c1f] Enforce docs_sync_max_per_cycle cap in DocsSyncEngine

* [e6e23c1f] docs(docs-sync): document docs_sync_max_per_cycle enforcement in engine map, README, and docstring

---------




* [e4b7dd0f] Revert task.py cast regressions from docs-sync PR (#511)

* [e4b7dd0f] fix(task): revert cast regressions in supersede and descendants

* [e4b7dd0f] docs(map): correct PR #511 cast regression entry in task-service slice map

* [e4b7dd0f] docs(backend): add SQLAlchemy UUID cast pattern note and inline comments in task.py

---------




* [1fdfe711] Fix Python quality gate on docs-sync PR (#512)

* [1fdfe711] Fix ruff formatting in task.py and add coverage tests for docs-sync surface

* [1fdfe711] fix(task): use generic JSON .as_string() accessor in list_open_docs_sync_tasks and correct test patch targets

* [1fdfe711] docs(task-service): record docs-sync JSON accessor fix and list_open_docs_sync_tasks map entry

---------




---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
This commit is contained in:
Renzo F
2026-07-14 02:22:37 +02:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter Backend Developer 2
parent 09b797fe9c
commit f03859c64c
26 changed files with 1225 additions and 12 deletions
@@ -0,0 +1,259 @@
"""DocsSyncEngine — originate one docs-update task per release, bounded + deduped.
Mirrors the dep-update engine unit-test style: mocked TaskService/ProjectService
so the engine's logic can be exercised without a real Postgres + pgvector setup.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.docs_sync_engine import DocsSyncEngine
def _project(project_id: Any, slug: str, git_url: str) -> SimpleNamespace:
return SimpleNamespace(id=project_id, slug=slug, git_url=git_url)
def _task(task_id: Any, project_id: Any, version: str | None = None) -> SimpleNamespace:
markers: dict[str, Any] = {}
if version is not None:
markers["docs_sync_release_version"] = version
return SimpleNamespace(
id=task_id,
project_id=project_id,
orchestration_markers=markers,
)
def _make_engine(project_svc: Any, task_svc: Any) -> tuple[DocsSyncEngine, list[Any]]:
session = MagicMock()
session.flush = AsyncMock(return_value=None)
engine = DocsSyncEngine(session)
patchers = [
patch(
"roboco.services.docs_sync_engine.get_project_service",
return_value=project_svc,
),
patch(
"roboco.services.docs_sync_engine.get_task_service",
return_value=task_svc,
),
]
for p in patchers:
p.start()
return engine, patchers
@pytest.fixture
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "docs_sync_enabled", True)
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 3)
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
@pytest.mark.asyncio
async def test_enabled_opens_one_docs_update_task(_enabled: None) -> None:
project_id = uuid4()
project = _project(
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
)
created = _task(uuid4(), project_id, "0.23.0")
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=project)
task_svc = MagicMock()
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[])
task_svc.create = AsyncMock(return_value=created)
engine, patchers = _make_engine(project_svc, task_svc)
try:
result = await engine.originate_docs_update(
version="0.23.0",
changelog="## [0.23.0]\n\n### Added\n- docs-sync engine\n",
)
finally:
for p in patchers:
p.stop()
assert result is not None
assert result.id == created.id
assert result.orchestration_markers is not None
assert result.orchestration_markers.get("docs_sync_release_version") == "0.23.0"
task_svc.create.assert_awaited_once()
req = task_svc.create.await_args.args[0]
assert req.project_id == project_id
assert req.source == "docs_sync"
assert "docs-sync engine" in req.description
assert "Divergence checklist" in req.description
@pytest.mark.asyncio
async def test_same_version_is_deduped(_enabled: None) -> None:
project_id = uuid4()
project = _project(
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
)
open_task = _task(uuid4(), project_id, "0.23.0")
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=project)
task_svc = MagicMock()
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
task_svc.create = AsyncMock()
engine, patchers = _make_engine(project_svc, task_svc)
try:
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
finally:
for p in patchers:
p.stop()
assert result is None
task_svc.create.assert_not_awaited()
@pytest.mark.asyncio
async def test_different_versions_open_distinct_tasks(_enabled: None) -> None:
project_id = uuid4()
project = _project(
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
)
open_task = _task(uuid4(), project_id, "0.23.0")
new_task = _task(uuid4(), project_id, "0.24.0")
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=project)
task_svc = MagicMock()
task_svc.list_open_docs_sync_tasks = AsyncMock(
side_effect=lambda version=None: (
[open_task]
if version == "0.23.0"
else ([] if version == "0.24.0" else [open_task])
)
)
task_svc.create = AsyncMock(return_value=new_task)
engine, patchers = _make_engine(project_svc, task_svc)
try:
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
finally:
for p in patchers:
p.stop()
assert result is not None
assert result.id == new_task.id
task_svc.create.assert_awaited_once()
@pytest.mark.asyncio
async def test_disabled_is_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "docs_sync_enabled", False)
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock()
task_svc = MagicMock()
task_svc.create = AsyncMock()
engine, patchers = _make_engine(project_svc, task_svc)
try:
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
finally:
for p in patchers:
p.stop()
assert result is None
assert project_svc.get_by_slug.await_count == 0
assert task_svc.create.await_count == 0
@pytest.mark.asyncio
async def test_missing_project_warns_and_returns_none(
_enabled: None, caplog: pytest.LogCaptureFixture
) -> None:
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=None)
task_svc = MagicMock()
task_svc.create = AsyncMock()
engine, patchers = _make_engine(project_svc, task_svc)
try:
with caplog.at_level("WARNING", logger="roboco.services.docs_sync_engine"):
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
finally:
for p in patchers:
p.stop()
assert result is None
assert "roboco-website" in caplog.text
assert "not registered" in caplog.text
assert task_svc.create.await_count == 0
@pytest.mark.asyncio
async def test_open_task_cap_is_enforced(
_enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 1)
project_id = uuid4()
project = _project(
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
)
open_task = _task(uuid4(), project_id, "0.23.0")
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=project)
task_svc = MagicMock()
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
task_svc.create = AsyncMock()
engine, patchers = _make_engine(project_svc, task_svc)
try:
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
finally:
for p in patchers:
p.stop()
assert result is None
task_svc.create.assert_not_awaited()
@pytest.mark.asyncio
async def test_per_cycle_cap_is_enforced(
_enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Once the per-cycle cap is reached, further calls on the same engine no-op."""
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
project_id = uuid4()
project = _project(
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
)
first_task = _task(uuid4(), project_id, "0.23.0")
second_task = _task(uuid4(), project_id, "0.24.0")
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=project)
task_svc = MagicMock()
task_svc.list_open_docs_sync_tasks = AsyncMock(
side_effect=lambda version=None: [first_task] if version is None else []
)
task_svc.create = AsyncMock(side_effect=[first_task, second_task])
engine, patchers = _make_engine(project_svc, task_svc)
try:
first = await engine.originate_docs_update(version="0.23.0", changelog="x")
second = await engine.originate_docs_update(version="0.24.0", changelog="y")
finally:
for p in patchers:
p.stop()
assert first is not None
assert first.id == first_task.id
assert second is None
task_svc.create.assert_awaited_once()
@@ -0,0 +1,139 @@
"""DOCS_SYNC_SOURCE + list_open_docs_sync_tasks — the dedupe + open-cap basis.
Open docs_sync tasks count toward the cap and block a duplicate per release
version; terminal ones and tasks from other sources do not.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.task import DOCS_SYNC_SOURCE, get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
_VERSION = "0.23.0"
_TWO = 2
_ONE = 1
async def _get_or_create_agent(
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
) -> None:
if await db.get(AgentTable, agent_id) is None:
db.add(
AgentTable(
id=agent_id,
name=slug,
slug=f"{slug}-{uuid4().hex[:8]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await db.flush()
async def _seed_project(db: AsyncSession) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name="RoboCo Website",
slug=f"website-{uuid4().hex[:8]}",
git_url="https://github.com/rennf93/roboco-website.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
db.add(project)
await db.flush()
return project
async def _make_task(
db: AsyncSession,
project: ProjectTable,
*,
source: str = DOCS_SYNC_SOURCE,
terminal: bool = False,
version: str | None = None,
) -> None:
markers_dict: dict[str, object] = {}
if version is not None:
markers_dict[markers.DOCS_SYNC_RELEASE_VERSION] = version
task = await get_task_service(db).create(
TaskCreateRequest(
title=f"Update docs for v{version or 'unknown'}",
description="Refresh published docs to match the shipped release.",
acceptance_criteria=["docs refreshed", "gate green"],
team=Team.MAIN_PM,
assigned_to=MAIN_PM_UUID,
created_by=SYSTEM_UUID,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=source,
confirmed_by_human=True,
)
)
if markers_dict:
task.orchestration_markers = markers_dict
if terminal:
task.status = TaskStatus.COMPLETED
await db.flush()
@pytest.fixture(autouse=True)
async def _agents(db_session: AsyncSession) -> None:
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
@pytest.mark.asyncio
async def test_lists_only_open_docs_sync_tasks(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session)
await _make_task(db_session, proj, version=_VERSION)
await _make_task(db_session, proj, terminal=True, version=_VERSION)
await _make_task(db_session, proj, source="manual", version=_VERSION)
open_tasks = await get_task_service(db_session).list_open_docs_sync_tasks()
assert len(open_tasks) == _ONE
assert open_tasks[0].source == DOCS_SYNC_SOURCE
assert open_tasks[0].status != TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_version_scoping(db_session: AsyncSession) -> None:
proj = await _seed_project(db_session)
await _make_task(db_session, proj, version="0.23.0")
await _make_task(db_session, proj, version="0.24.0")
svc = get_task_service(db_session)
assert len(await svc.list_open_docs_sync_tasks()) == _TWO
scoped = await svc.list_open_docs_sync_tasks(version="0.23.0")
assert len(scoped) == _ONE
assert markers.get_docs_sync_release_version(scoped[0]) == "0.23.0"