2026-07-05 13:37:17 +02:00
|
|
|
"""Scenario: the video-generation pipeline, render loop through CEO approval.
|
|
|
|
|
|
|
|
|
|
Regression coverage for the video engine's cross-layer wiring (Phase H): a
|
|
|
|
|
completed ``source=video`` authoring task is rendered by the orchestrator's
|
|
|
|
|
render loop into a held ``source=video_post`` draft, which the CEO approves
|
|
|
|
|
through ``VideoPostService``. Exercises the REAL dispatcher skip-predicates
|
|
|
|
|
(``video_post`` must never reach a dev/PM dispatcher — the authoring source
|
|
|
|
|
itself is the contrast case, since it dispatches normally), the REAL
|
|
|
|
|
``propose_video`` do-tool via the REAL do_server registry (mirrors
|
|
|
|
|
``test_feature_spotlight.py``'s guard against a verb wired at
|
|
|
|
|
role_config/content_actions but dropped from ``do_server._TOOLS``), the REAL
|
2026-07-07 10:09:23 +02:00
|
|
|
render -> materialize chain (only the video-renderer sidecar client + the
|
2026-07-05 13:37:17 +02:00
|
|
|
workspace read-clone are mocked — the external-I/O boundary), and the REAL
|
|
|
|
|
``VideoPostService.approve`` (only the X-v2 + TikTok posters are mocked)
|
|
|
|
|
including its already-posted idempotency.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
|
|
|
|
|
|
from roboco.runtime.orchestrator import _is_held_ceo_source, _is_non_dev_dispatch_source
|
|
|
|
|
from roboco.services.heartbeat_mutex import HeartbeatMutex
|
|
|
|
|
from roboco.services.video_post_service import (
|
|
|
|
|
TikTokPoster,
|
|
|
|
|
TikTokUploadResult,
|
|
|
|
|
XVideoPoster,
|
|
|
|
|
XVideoPostResult,
|
|
|
|
|
)
|
|
|
|
|
from tests.e2e_smoke.arcs import seed_company, seed_project, seed_task
|
|
|
|
|
from tests.e2e_smoke.harness import ScriptedAgent, expect_error
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from tests.e2e_smoke.arcs import Company
|
|
|
|
|
from tests.e2e_smoke.harness import E2EStack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_video_agents(stack: E2EStack) -> None:
|
|
|
|
|
"""Seed ``system`` / ``secretary-1`` / ``ux-dev-1`` / ``ux-dev-2`` at their
|
|
|
|
|
FIXED foundation UUIDs — the video engine writes ``created_by`` /
|
|
|
|
|
``assigned_to`` straight from the static identity registry (not a
|
|
|
|
|
role-keyed DB lookup), so those exact ids must exist as real agent rows
|
|
|
|
|
for the FK to resolve. Idempotent (safe if ever called more than once
|
|
|
|
|
against the same stack), mirroring
|
|
|
|
|
``test_feature_spotlight._seed_system_and_secretary``.
|
|
|
|
|
"""
|
|
|
|
|
from roboco.db.tables import AgentTable
|
|
|
|
|
from roboco.foundation import identity as _foundation
|
|
|
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> None:
|
|
|
|
|
for agent_uuid, slug, role, team in (
|
|
|
|
|
(_foundation.AGENTS["system"].uuid, "system", AgentRole.SYSTEM, None),
|
|
|
|
|
(
|
|
|
|
|
_foundation.AGENTS["secretary-1"].uuid,
|
|
|
|
|
"secretary-1",
|
|
|
|
|
AgentRole.SECRETARY,
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
_foundation.AGENTS["ux-dev-1"].uuid,
|
|
|
|
|
"ux-dev-1",
|
|
|
|
|
AgentRole.DEVELOPER,
|
|
|
|
|
Team.UX_UI,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
_foundation.AGENTS["ux-dev-2"].uuid,
|
|
|
|
|
"ux-dev-2",
|
|
|
|
|
AgentRole.DEVELOPER,
|
|
|
|
|
Team.UX_UI,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
if await session.get(AgentTable, agent_uuid) is not None:
|
|
|
|
|
continue
|
|
|
|
|
session.add(
|
|
|
|
|
AgentTable(
|
|
|
|
|
id=agent_uuid,
|
|
|
|
|
name=slug,
|
|
|
|
|
slug=slug,
|
|
|
|
|
role=role,
|
|
|
|
|
team=team,
|
|
|
|
|
status=AgentStatus.ACTIVE,
|
|
|
|
|
model_config={},
|
|
|
|
|
system_prompt=slug,
|
|
|
|
|
capabilities=[],
|
|
|
|
|
permissions={},
|
|
|
|
|
metrics={},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
stack.run_db(_run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_completed_authoring_task(stack: E2EStack, project_id: Any) -> UUID:
|
|
|
|
|
"""A completed ``source=video`` authoring task carrying a proposed
|
|
|
|
|
composition — the render loop's scan basis. Mirrors the shape a real
|
|
|
|
|
``VideoEngine.open_video_task`` + ``propose_video`` call would leave
|
|
|
|
|
behind, seeded directly (the harness's own convention for mid-flight
|
|
|
|
|
setup — see ``arcs.seed_hierarchy``); the render/approve/gate wiring
|
|
|
|
|
under test doesn't depend on how the authoring task got here.
|
|
|
|
|
"""
|
|
|
|
|
from roboco.foundation import identity as _foundation
|
|
|
|
|
from roboco.foundation.policy.content import markers as _markers
|
|
|
|
|
from roboco.models import Team
|
|
|
|
|
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
|
|
|
|
|
from roboco.services.task import VIDEO_SOURCE
|
|
|
|
|
|
|
|
|
|
draft: dict[str, Any] = {
|
|
|
|
|
"occasion": "e2e pipeline test",
|
|
|
|
|
"script": "Here's what shipped",
|
|
|
|
|
"brief": "Announce the e2e video pipeline",
|
|
|
|
|
"composition_id": "Intro",
|
|
|
|
|
"input_props": {"title": "hello"},
|
|
|
|
|
"x_caption": "Check out our new release!",
|
|
|
|
|
"tiktok_caption": "New release, check it out",
|
|
|
|
|
"platforms": ["x", "tiktok"],
|
|
|
|
|
}
|
|
|
|
|
task_id: UUID = seed_task(
|
|
|
|
|
stack,
|
|
|
|
|
title="Video: e2e pipeline test",
|
|
|
|
|
description="Announce the e2e video pipeline",
|
|
|
|
|
acceptance_criteria=["Both 9:16 and 1:1 cuts render"],
|
|
|
|
|
task_type=TaskType.CODE,
|
|
|
|
|
nature=TaskNature.TECHNICAL,
|
|
|
|
|
estimated_complexity=Complexity.LOW,
|
|
|
|
|
team=Team.UX_UI,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
created_by=_foundation.AGENTS["system"].uuid,
|
|
|
|
|
assigned_to=_foundation.AGENTS["ux-dev-1"].uuid,
|
|
|
|
|
status=TaskStatus.COMPLETED,
|
|
|
|
|
source=VIDEO_SOURCE,
|
|
|
|
|
confirmed_by_human=True,
|
|
|
|
|
orchestration_markers={_markers.VIDEO_DRAFT: draft},
|
|
|
|
|
)
|
|
|
|
|
return task_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeRenderer:
|
2026-07-07 10:09:23 +02:00
|
|
|
"""Stands in for the video-renderer sidecar: returns a deterministic
|
2026-07-05 13:37:17 +02:00
|
|
|
path per orientation, no tar/HTTP anywhere."""
|
|
|
|
|
|
|
|
|
|
async def render(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
source_dir: str,
|
|
|
|
|
composition_id: str,
|
|
|
|
|
input_props: dict[str, Any],
|
|
|
|
|
orientation: str,
|
|
|
|
|
render_key: str,
|
|
|
|
|
) -> str:
|
|
|
|
|
_ = (source_dir, input_props)
|
|
|
|
|
return f"/fake-out/{render_key}-{composition_id}-{orientation}.mp4"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_completed_task(stack: E2EStack, task_id: UUID) -> None:
|
|
|
|
|
"""Drive the REAL orchestrator render step against the completed
|
|
|
|
|
authoring task — only the sidecar client + workspace read-clone are
|
|
|
|
|
mocked (the render step's external-I/O boundary)."""
|
|
|
|
|
from pathlib import Path as _Path
|
|
|
|
|
|
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
workspace = SimpleNamespace(
|
|
|
|
|
ensure_read_clone=AsyncMock(return_value=_Path("/fake-clone"))
|
|
|
|
|
)
|
|
|
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> None:
|
|
|
|
|
row = (
|
|
|
|
|
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
|
|
|
|
).scalar_one()
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
2026-07-07 10:09:23 +02:00
|
|
|
"roboco.services.video_renderer_client.get_video_renderer",
|
2026-07-05 13:37:17 +02:00
|
|
|
_FakeRenderer,
|
|
|
|
|
),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.get_workspace_service",
|
|
|
|
|
lambda _db: workspace,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await orch._render_video_task(session, row)
|
|
|
|
|
|
|
|
|
|
stack.run_db(_run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _task_dict(stack: E2EStack, task_id: UUID) -> dict[str, Any]:
|
|
|
|
|
"""The (source, confirmed_by_human) shape a dispatcher reads off a task
|
|
|
|
|
— real committed values, not a hand-crafted stand-in."""
|
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> dict[str, Any]:
|
|
|
|
|
row = (
|
|
|
|
|
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
|
|
|
|
).scalar_one()
|
|
|
|
|
return {
|
|
|
|
|
"source": row.source,
|
|
|
|
|
"confirmed_by_human": row.confirmed_by_human,
|
|
|
|
|
"status": str(row.status),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result: dict[str, Any] = stack.run_db(_run)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_video_post_draft(stack: E2EStack, source_task_id: UUID) -> dict[str, Any]:
|
|
|
|
|
"""The held video_post draft the render step materialized for
|
|
|
|
|
``source_task_id`` — located via the marker's own back-reference
|
|
|
|
|
(``_originate_video_post`` stamps ``source_task_id``), robust against any
|
|
|
|
|
other video_post rows in this session-scoped shared test DB."""
|
|
|
|
|
from roboco.foundation.policy.content import markers as _markers
|
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> dict[str, Any]:
|
|
|
|
|
drafts = await get_task_service(session).list_open_video_post_drafts()
|
|
|
|
|
match = next(
|
|
|
|
|
t
|
|
|
|
|
for t in drafts
|
|
|
|
|
if (_markers.get_video_draft(t) or {}).get("source_task_id")
|
|
|
|
|
== str(source_task_id)
|
|
|
|
|
)
|
|
|
|
|
draft = _markers.get_video_draft(match) or {}
|
|
|
|
|
return {
|
|
|
|
|
"id": match.id,
|
|
|
|
|
"source": match.source,
|
|
|
|
|
"confirmed_by_human": match.confirmed_by_human,
|
|
|
|
|
"status": str(match.status),
|
|
|
|
|
"mp4_paths": dict(draft.get("mp4_paths") or {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result: dict[str, Any] = stack.run_db(_run)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeXPoster(XVideoPoster):
|
|
|
|
|
@property
|
|
|
|
|
def configured(self) -> bool:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def post_video(self, *, mp4_path: str, caption: str) -> XVideoPostResult:
|
|
|
|
|
_ = (mp4_path, caption)
|
|
|
|
|
return XVideoPostResult(posted=True, video_id="e2e-x-vid", detail="posted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeTikTokPoster(TikTokPoster):
|
|
|
|
|
@property
|
|
|
|
|
def configured(self) -> bool:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def upload_to_inbox(
|
|
|
|
|
self, *, mp4_path: str, caption: str
|
|
|
|
|
) -> TikTokUploadResult:
|
|
|
|
|
_ = (mp4_path, caption)
|
|
|
|
|
return TikTokUploadResult(
|
|
|
|
|
uploaded=True, publish_id="e2e-tt-pub", detail="uploaded"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# No real Redis in this harness (and the root conftest's autouse fixture
|
|
|
|
|
# points settings.redis_url at an unreachable port for every test regardless)
|
|
|
|
|
# — mocked the same way tests/integration/test_video_routes.py does.
|
|
|
|
|
_LOCKED = (
|
|
|
|
|
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="e2e-lock-token")),
|
|
|
|
|
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _approve(stack: E2EStack, draft_id: UUID) -> dict[str, Any]:
|
|
|
|
|
from roboco.services.video_post_service import get_video_post_service
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> dict[str, Any]:
|
|
|
|
|
svc = get_video_post_service(
|
|
|
|
|
session, x_poster=_FakeXPoster(), tiktok_poster=_FakeTikTokPoster()
|
|
|
|
|
)
|
|
|
|
|
result = await svc.approve(draft_id)
|
|
|
|
|
assert result is not None
|
|
|
|
|
return {"status": result.status, "posted": dict(result.posted)}
|
|
|
|
|
|
|
|
|
|
with _LOCKED[0], _LOCKED[1]:
|
|
|
|
|
outcome: dict[str, Any] = stack.run_db(_run)
|
|
|
|
|
return outcome
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_video_pipeline_render_and_approve(e2e_stack: E2EStack) -> None:
|
|
|
|
|
stack = e2e_stack
|
|
|
|
|
company: Company = seed_company(stack)
|
|
|
|
|
_seed_video_agents(stack)
|
|
|
|
|
project_id, _project_slug = seed_project(stack, company)
|
|
|
|
|
|
|
|
|
|
task_id = _seed_completed_authoring_task(stack, project_id)
|
|
|
|
|
|
|
|
|
|
# The exact bug class test_feature_spotlight.py guards against: a verb
|
|
|
|
|
# granted (role_config) + implemented (ContentActions) + routed
|
|
|
|
|
# (api/v1/do) but missing from do_server's _TOOLS/_REGISTERED_TOOLS is
|
|
|
|
|
# silently uncallable over MCP no matter what the gateway layers say.
|
|
|
|
|
dev = ScriptedAgent(stack, company.dev_id, "be-dev-1", "developer")
|
|
|
|
|
do_module = dev._module("roboco.mcp.do_server")
|
|
|
|
|
assert "propose_video" in do_module._TOOLS, (
|
|
|
|
|
"propose_video missing from do_server._TOOLS — no role could ever "
|
|
|
|
|
"call it over MCP"
|
|
|
|
|
)
|
|
|
|
|
assert "propose_video" in do_module._REGISTERED_TOOLS, (
|
|
|
|
|
"propose_video is granted to developer in role_config but absent "
|
|
|
|
|
"from this agent's _register_tools() output"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# propose_video is granted to every developer role (be/fe/ux-dev share
|
|
|
|
|
# Role.DEVELOPER) — the runtime TEAM gate is the real enforcement, so a
|
|
|
|
|
# be-dev's call must be rejected even though the tool is on their manifest.
|
|
|
|
|
env = dev.do(
|
|
|
|
|
"propose_video",
|
|
|
|
|
composition_id="Intro",
|
|
|
|
|
x_caption="Check it out",
|
|
|
|
|
tiktok_caption="Check it out on TikTok",
|
|
|
|
|
platforms=["x"],
|
|
|
|
|
)
|
|
|
|
|
expect_error(env, "not_authorized", "be-dev propose_video team gate")
|
|
|
|
|
|
|
|
|
|
# Contrast case: the authoring task's own source is normal delivery work
|
|
|
|
|
# (confirmed_by_human=True) — neither dispatcher treats it as held.
|
|
|
|
|
before = _task_dict(stack, task_id)
|
|
|
|
|
assert before["status"] == "completed", before
|
|
|
|
|
assert _is_non_dev_dispatch_source(before) is False, before
|
|
|
|
|
assert _is_held_ceo_source(before) is False, before
|
|
|
|
|
|
|
|
|
|
# The render loop: only the sidecar client + workspace read-clone mocked.
|
|
|
|
|
_render_completed_task(stack, task_id)
|
|
|
|
|
|
|
|
|
|
draft = _find_video_post_draft(stack, task_id)
|
|
|
|
|
assert draft["source"] == "video_post", draft
|
|
|
|
|
assert draft["confirmed_by_human"] is False, draft
|
|
|
|
|
assert draft["status"] == "pending", draft # held, awaiting the CEO
|
|
|
|
|
assert set(draft["mp4_paths"]) == {"vertical", "square"}, draft
|
|
|
|
|
|
|
|
|
|
# The key wiring: video_post is skipped by BOTH dispatchers.
|
|
|
|
|
held_shape = {
|
|
|
|
|
"source": draft["source"],
|
|
|
|
|
"confirmed_by_human": draft["confirmed_by_human"],
|
|
|
|
|
}
|
|
|
|
|
assert _is_non_dev_dispatch_source(held_shape) is True, held_shape
|
|
|
|
|
assert _is_held_ceo_source(held_shape) is True, held_shape
|
|
|
|
|
|
|
|
|
|
# VideoPostService.approve posts via mocked X-v2 + TikTok posters, then is
|
|
|
|
|
# idempotent on a second call (no re-post, same ids returned).
|
|
|
|
|
first = _approve(stack, draft["id"])
|
|
|
|
|
assert first["status"] == "posted", first
|
|
|
|
|
assert first["posted"] == {"x": "e2e-x-vid", "tiktok": "e2e-tt-pub"}, first
|
|
|
|
|
|
|
|
|
|
second = _approve(stack, draft["id"])
|
|
|
|
|
assert second["status"] == "already_posted", second
|
|
|
|
|
assert second["posted"] == first["posted"], second
|