mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe get_latest_ci_conclusion defaults to the ladder's head rung, so wait_for_ci searched slave for a release commit that lives on master and timed out after 40 minutes with the run already green. The wait now passes the prod branch explicitly. Also fixes the react/no-unescaped-entities error that turned master's Panel CI red. * fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's click handler on the Tooltip root, which renders no DOM — the agents Spawn item and the KB Reindex-All / Delete-index confirms were dead. Tooltips now wrap the triggers. The video renderer accepts interior single dots in composition ids (release-0.25.0) with '..' still unrepresentable, and propose_video refuses an unrenderable id at authoring time. * fix(dispatch): restart-safe PM review turns A leaf task in awaiting_pm_review had no periodic pickup: the closure dispatcher bailed on childless tasks and skipped PR-bearing review tasks as already-promoted, assuming the submit-time PM session was still alive — an assumption every restart breaks. Proven live on the docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked its sibling dev task. Childless awaiting_pm_review tasks now flow to the PM's review turn, and the merge turn respawns its PM when none is active. * feat(video): verify the rendered artifact, not the source The 14s release-0.25.0 cut shipped with only one of four scenes visibly registering: the dev authored DOM, the smoke asserted DOM, QA read code — nobody consumed the rendered MP4 before the CEO did. Close that loop, and the reject loop behind it: - sidecar frames mode: POST /render with frames=1..32 renders the cut, ffprobes the REAL duration, extracts midpoint-sampled keyframe PNGs (timestamps in filenames), streams a tar.gz back with X-Video-Duration - request_render do-verb (developer/QA, request_sandbox's shape): renders the caller's ACTUAL composition — dev's own worktree (head_sha/dirty provenance), QA a read-only git-archive export of the assembled branch — extracts frames to the container-shared .previews/ path, stamps the render_preview marker, returns the paths as envelope evidence - gate: i_am_done on a source=video task refuses without a stamped render_preview (Requirement.RENDER_VERIFIED; canonical source string moved to foundation as markers.VIDEO_TASK_SOURCE; mirrored in the possibilities-matrix fast path so it cannot bypass the check) - QA claim_review evidence carries video_context (composition id, the dev's preview, a re-render instruction) so review checks output - dev spawn prompt block + a 4th authoring AC order Read-every-frame verification before submitting - reject -> re-author: a CEO reject with a reason opens a fresh authoring task carrying the verbatim feedback + a revise-in-place pointer at the existing composition (best-effort, never fails the reject) — rejection feedback no longer dies on the cancelled draft E2E: rendered the committed release-0.25.0 composition through the new frames mode locally — the returned keyframes show exactly the reported failure (blank frame at 5.8s, only 'Env ladder' by 12.8s), the check the fleet was missing. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
486 lines
16 KiB
Python
486 lines
16 KiB
Python
"""ContentActions.request_render — render a video composition to preview
|
|
frames so an agent verifies the RENDERED artifact, not just its source.
|
|
|
|
Guard matrix (flag off / no active task / non-video source / renderer
|
|
unconfigured / missing composition_id / bad composition_id-orientation-
|
|
frame_count / missing composition dir on disk / role gate), the dev and QA
|
|
success paths (frame extraction + marker payload shape), and a
|
|
VideoRendererError surfacing as a retryable rejection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import tarfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.config import settings
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
from roboco.services.video_renderer_client import VideoRendererError
|
|
|
|
|
|
def _make_actions(
|
|
*,
|
|
task_obj: MagicMock | None,
|
|
role: str = "developer",
|
|
team: str | None = "ux_ui",
|
|
workspace: MagicMock | None = None,
|
|
) -> tuple[ContentActions, MagicMock]:
|
|
task = AsyncMock()
|
|
task.get_active_task_for_agent.return_value = task_obj
|
|
task.session = MagicMock()
|
|
task.session.flush = AsyncMock()
|
|
task.heartbeat = AsyncMock()
|
|
agent = MagicMock()
|
|
agent.role = role
|
|
agent.team = team
|
|
task.agent_for = AsyncMock(return_value=agent)
|
|
deps = ContentActionsDeps(
|
|
task=task,
|
|
git=MagicMock(),
|
|
a2a=MagicMock(),
|
|
journal=MagicMock(),
|
|
workspace=workspace or MagicMock(),
|
|
notifications=MagicMock(),
|
|
)
|
|
return ContentActions(deps), task
|
|
|
|
|
|
def _task(
|
|
*,
|
|
project_id: object | None = uuid4(),
|
|
source: str = markers.VIDEO_TASK_SOURCE,
|
|
branch_name: str | None = "feature/ux_ui/ABCD1234",
|
|
draft: dict[str, object] | None = None,
|
|
) -> MagicMock:
|
|
t = MagicMock()
|
|
t.id = uuid4()
|
|
t.project_id = project_id
|
|
t.status = "in_progress"
|
|
t.source = source
|
|
t.branch_name = branch_name
|
|
t.orchestration_markers = {"video_draft": draft} if draft else None
|
|
return t
|
|
|
|
|
|
def _stub_project(
|
|
monkeypatch: pytest.MonkeyPatch, *, slug: str = "demo-project"
|
|
) -> MagicMock:
|
|
project = MagicMock(slug=slug, git_url="https://example.invalid/demo.git")
|
|
project_service = MagicMock()
|
|
project_service.get = AsyncMock(return_value=project)
|
|
monkeypatch.setattr(
|
|
"roboco.services.project.get_project_service", lambda _s: project_service
|
|
)
|
|
return project
|
|
|
|
|
|
def _stub_renderer(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
frames_tar_gz: bytes = b"",
|
|
duration: float = 2.5,
|
|
error: Exception | None = None,
|
|
) -> MagicMock:
|
|
renderer = MagicMock()
|
|
if error is not None:
|
|
renderer.render_frames = AsyncMock(side_effect=error)
|
|
else:
|
|
renderer.render_frames = AsyncMock(return_value=(frames_tar_gz, duration))
|
|
monkeypatch.setattr(
|
|
"roboco.services.video_renderer_client.get_video_renderer",
|
|
lambda: renderer,
|
|
)
|
|
return renderer
|
|
|
|
|
|
def _make_frames_tar(names: list[str]) -> bytes:
|
|
"""An in-memory tar.gz of a few frame files, matching what render_frames
|
|
returns on success — the render loop extracts this straight to disk."""
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
for name in names:
|
|
data = f"fake-png-bytes-{name}".encode()
|
|
info = tarfile.TarInfo(name=name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
return buf.getvalue()
|
|
|
|
|
|
def _arm(
|
|
monkeypatch: pytest.MonkeyPatch, *, renderer_url: str = "http://sidecar:3001"
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "video_engine_enabled", True)
|
|
monkeypatch.setattr(settings, "video_renderer_base_url", renderer_url)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# guard matrix
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flag_off_refuses_before_task_lookup(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "video_engine_enabled", False)
|
|
actions, task_svc = _make_actions(task_obj=None)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
task_svc.get_active_task_for_agent.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_active_task_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "video_engine_enabled", True)
|
|
actions, _task_svc = _make_actions(task_obj=None)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_video_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "video_engine_enabled", True)
|
|
t = _task(source="chore")
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "video-authoring" in (env.message or "") + (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_renderer_unconfigured_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "video_engine_enabled", True)
|
|
monkeypatch.setattr(settings, "video_renderer_base_url", "")
|
|
t = _task()
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "ROBOCO_VIDEO_RENDERER_BASE_URL" in (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_composition_id_is_incomplete_input(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
t = _task(draft=None)
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "incomplete_input"
|
|
assert "composition_id" in (env.missing or [])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bad_composition_id_regex_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
t = _task()
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
env = await actions.request_render(agent_id=uuid4(), composition_id="bad id!")
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "not renderable" in (env.message or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bad_orientation_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_arm(monkeypatch)
|
|
t = _task()
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
env = await actions.request_render(
|
|
agent_id=uuid4(), composition_id="release-v1", orientation="landscape"
|
|
)
|
|
|
|
assert env.error == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_frame_count_out_of_range_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
t = _task()
|
|
actions, _task_svc = _make_actions(task_obj=t)
|
|
|
|
too_low = await actions.request_render(
|
|
agent_id=uuid4(), composition_id="release-v1", frame_count=0
|
|
)
|
|
too_high = await actions.request_render(
|
|
agent_id=uuid4(), composition_id="release-v1", frame_count=33
|
|
)
|
|
|
|
assert too_low.error == "invalid_state"
|
|
assert too_high.error == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_composition_dir_on_disk_rejected(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch)
|
|
clone_root = tmp_path / "clone"
|
|
clone_root.mkdir(parents=True) # no motion/ tree at all
|
|
workspace = MagicMock()
|
|
workspace.get_clone_root_path.return_value = clone_root
|
|
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
|
|
|
|
t = _task(draft={"composition_id": "release-v1"})
|
|
actions, _task_svc = _make_actions(task_obj=t, workspace=workspace)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "motion/compositions/release-v1" in (env.message or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_other_role_not_authorized(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_arm(monkeypatch)
|
|
_stub_project(monkeypatch)
|
|
t = _task(draft={"composition_id": "release-v1"})
|
|
actions, _task_svc = _make_actions(task_obj=t, role="documenter", team="ux_ui")
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "not_authorized"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# dev happy path — own working tree
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_happy_path_extracts_frames_and_stamps_marker(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch, slug="demo-project")
|
|
|
|
clone_root = tmp_path / "clone"
|
|
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
|
|
workspace = MagicMock()
|
|
workspace.get_clone_root_path.return_value = clone_root
|
|
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
|
|
|
|
t = _task(draft={"composition_id": "release-v1"})
|
|
actions, task_svc = _make_actions(
|
|
task_obj=t, role="developer", team="ux_ui", workspace=workspace
|
|
)
|
|
expected_frame_count = 2
|
|
expected_duration = 3.2
|
|
default_frame_count = 8
|
|
tar_bytes = _make_frames_tar(["frame-0.png", "frame-1.png"])
|
|
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=expected_duration)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error is None
|
|
assert env.status == t.status
|
|
assert env.task_id == str(t.id)
|
|
assert env.evidence is not None
|
|
frames = env.evidence["frames"]
|
|
assert len(frames) == expected_frame_count
|
|
for p in frames:
|
|
assert Path(p).is_file()
|
|
assert env.evidence["duration_seconds"] == expected_duration
|
|
assert env.evidence["source"] == "workspace"
|
|
assert env.evidence["dirty"] is False
|
|
assert env.evidence["rendered_by"]
|
|
assert "note" in env.evidence
|
|
assert "frames[]" in (env.next or "")
|
|
|
|
payload = markers.get_render_preview(t)
|
|
assert payload is not None
|
|
assert payload["composition_id"] == "release-v1"
|
|
assert payload["orientation"] == "vertical"
|
|
assert payload["frame_count"] == default_frame_count
|
|
assert payload["duration_seconds"] == expected_duration
|
|
assert payload["source"] == "workspace"
|
|
assert payload["dirty"] is False
|
|
assert payload["frames"] == frames
|
|
assert "at" in payload
|
|
task_svc.session.flush.assert_awaited()
|
|
task_svc.heartbeat.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_composition_id_backfills_video_draft(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A dev who never called propose_video must still leave
|
|
video_draft.composition_id stamped — the post-completion render loop
|
|
keys on it and skips silently when absent (proven live)."""
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch, slug="demo-project")
|
|
|
|
clone_root = tmp_path / "clone"
|
|
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
|
|
workspace = MagicMock()
|
|
workspace.get_clone_root_path.return_value = clone_root
|
|
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
|
|
|
|
t = _task(draft=None)
|
|
actions, _ = _make_actions(
|
|
task_obj=t, role="developer", team="ux_ui", workspace=workspace
|
|
)
|
|
_stub_renderer(
|
|
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
|
|
)
|
|
|
|
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v1")
|
|
|
|
assert env.error is None
|
|
draft = markers.get_video_draft(t)
|
|
assert draft is not None
|
|
assert draft["composition_id"] == "release-v1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_video_draft_composition_id_not_overwritten(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch, slug="demo-project")
|
|
|
|
clone_root = tmp_path / "clone"
|
|
for comp in ("release-v1", "release-v2"):
|
|
(clone_root / "motion" / "compositions" / comp).mkdir(parents=True)
|
|
workspace = MagicMock()
|
|
workspace.get_clone_root_path.return_value = clone_root
|
|
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
|
|
|
|
t = _task(draft={"composition_id": "release-v1", "occasion": "r1"})
|
|
actions, _ = _make_actions(
|
|
task_obj=t, role="developer", team="ux_ui", workspace=workspace
|
|
)
|
|
_stub_renderer(
|
|
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
|
|
)
|
|
|
|
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v2")
|
|
|
|
assert env.error is None
|
|
draft = markers.get_video_draft(t)
|
|
assert draft is not None
|
|
assert draft["composition_id"] == "release-v1"
|
|
assert draft["occasion"] == "r1"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# QA happy path — read-only branch export, never a working tree
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_happy_path_uses_branch_export(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch, slug="demo-project")
|
|
|
|
scratch = tmp_path / "scratch"
|
|
(scratch / "motion" / "compositions" / "release-v1").mkdir(parents=True)
|
|
read_clone = tmp_path / "readclone"
|
|
read_clone.mkdir()
|
|
|
|
workspace = MagicMock()
|
|
workspace.export_branch_motion = AsyncMock(return_value=scratch)
|
|
workspace.ensure_read_clone = AsyncMock(return_value=read_clone)
|
|
|
|
t = _task(
|
|
draft={"composition_id": "release-v1"},
|
|
branch_name="feature/ux_ui/ABCD1234",
|
|
)
|
|
actions, task_svc = _make_actions(
|
|
task_obj=t, role="qa", team=None, workspace=workspace
|
|
)
|
|
tar_bytes = _make_frames_tar(["frame-0.png"])
|
|
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=1.0)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error is None
|
|
assert env.evidence is not None
|
|
assert env.evidence["source"] == "branch"
|
|
assert env.evidence["dirty"] is False
|
|
assert len(env.evidence["frames"]) == 1
|
|
workspace.export_branch_motion.assert_awaited_once()
|
|
called_project, called_branch = workspace.export_branch_motion.call_args.args
|
|
assert called_project.slug == "demo-project"
|
|
assert called_branch == "feature/ux_ui/ABCD1234"
|
|
payload = markers.get_render_preview(t)
|
|
assert payload is not None
|
|
assert payload["source"] == "branch"
|
|
task_svc.heartbeat.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_without_branch_name_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
_stub_project(monkeypatch)
|
|
t = _task(draft={"composition_id": "release-v1"}, branch_name=None)
|
|
actions, _task_svc = _make_actions(task_obj=t, role="qa", team=None)
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# renderer failure
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_renderer_error_is_retryable_invalid_state(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_arm(monkeypatch)
|
|
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
|
|
_stub_project(monkeypatch, slug="demo-project")
|
|
|
|
clone_root = tmp_path / "clone"
|
|
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
|
|
workspace = MagicMock()
|
|
workspace.get_clone_root_path.return_value = clone_root
|
|
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
|
|
|
|
t = _task(draft={"composition_id": "release-v1"})
|
|
actions, _task_svc = _make_actions(
|
|
task_obj=t, role="developer", team="ux_ui", workspace=workspace
|
|
)
|
|
_stub_renderer(monkeypatch, error=VideoRendererError("sidecar unreachable"))
|
|
|
|
env = await actions.request_render(agent_id=uuid4())
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "retry" in (env.remediate or "").lower()
|
|
assert markers.get_render_preview(t) is None
|