Files
roboco/tests/e2e_smoke/test_video_pipeline.py
T
e9d0e0bd48 feat(video): 0.19.0 video engine (Remotion) + preview auth + render persistence (#307)
* feat(video): Phase A — VideoEngine origination spine + held-source gates

New default-off engine skeleton: opens a UX/UI authoring task (source=video, assigned to a ux-dev, LOW complexity to clear the dev-needs-subtasks guard) and materializes a held CEO-approval draft (source=video_post). Excludes video_post from all three held-source skip sites; adds the video_draft marker, six config flags, and the feature-flag entries. Origination + gate behavior unit-tested.

* refactor(orchestrator): fold _dispatch_dev_work skip chain into a helper

The per-source if/continue chain grew past xenon's --max-absolute B when the video_post held source joined it. Extract _is_non_dev_dispatch_source (every held-CEO source plus the two Board exploration sources) so the dev loop's skip is one flat call. Behavior-identical.

* feat(video): Phase B — propose_video do-tool (metadata-only, team-gated)

UX/UI dev records a video's composition ref + per-platform captions onto the authoring task's video_draft marker. Team-gated at runtime via _caller_team (Role.DEVELOPER can't tell a ux-dev from a be-dev). Resolves the caller's ACTIVE task via get_active_task_for_agent, not an oldest-first scan that would clobber a second open video task. Metadata only, no render. Wired through do_server + route + schema; added to _DEV_DO.

* feat(video): Phase D — render loop + RemotionRenderer client

Orchestrator-async _video_render_loop renders a completed authoring task's merged composition to MP4 (vertical + square) via the remotion-renderer sidecar and materializes the held video_post draft. RemotionRenderer tars the read-clone's motion/ source, POSTs it, and saves the returned MP4 bytes to a TASK-scoped local path (no shared volume; a composition is reused across videos so a composition-scoped path would clobber an earlier draft). Render failures bounded-retry (read-clone catch-up window, transient sidecar) up to a cap, then terminal-fail. Client tested vs a mock transport; loop vs a mock renderer + real DB.

* feat(video): Phase C — release / spotlight / on-demand video triggers

Three entry points open a UX/UI video-authoring task via VideoEngine.open_video_task: (1) a published release drafts a companion video — best-effort in ReleaseProposalService.approve, never fails the publish; script from the CHANGELOG via the local model with a template fallback. (2) propose_feature_spotlight gains optional wants_video/video_script — best-effort, gated on video_on_spotlight, default-off leaves the spotlight flow byte-for-byte unchanged. (3) POST /video/request (CEO-only) for an on-demand brief, with clean disabled/not_opened responses. All gated on video_engine_enabled.

* fix(video): savepoint-isolate video-task inserts (F042 poisoned session)

The best-effort try/except around open_video_task (release-publish + spotlight hooks) swallowed the Python exception, but a DBAPI error at the insert flush left the shared session must-rollback — so the caller's next commit (release finalize / request boundary) threw PendingRollbackError: the release stuck 'pending' after actually publishing, or the spotlight draft + HTTP response were lost. Wrap both inserts (open_video_task, _originate_video_post) in a begin_nested savepoint (the repo's established F042 pattern) so a DB error rolls back only the insert. open_video_task returns None (every caller already handles it); _originate_video_post propagates to the render loop's handler. Regression test: an insert FK error returns None with the session left usable. Dormant while the flags were off; armed on the NAS.

* feat(video): Phase G — motion/ package + remotion-renderer sidecar + compose

In-repo Remotion v4 motion/ package (ReleaseAnnouncement composition; calculateMetadata returns 1080x1920 vertical / 1080x1080 square from inputProps.orientation) + a credential-free remotion-renderer sidecar: untar the POSTed motion/ source, bundle (LRU-cached per source sha), selectComposition + renderMedia h264, stream the MP4 bytes back — matching the RemotionRenderer client contract. docker/remotion.Dockerfile on Debian (Chrome apt deps, build-time Chrome pre-warm, ffmpeg bundled in @remotion/renderer). Wired into both compose files (roboco_default only, shm_size 1gb, /health check) + the release publish matrix. Verified via a real local render of both cuts; the Debian docker build is the CEO's to run.

* chore(video): D-hardening — video_post source_task_id + render-loop docstring

Add a source_task_id back-reference to the video_post held-draft marker (traceability from a draft to its authoring task; also makes the render loop's two-key idempotency check wireable later). Fix the render-loop test's stale docstring ('never retried' -> bounded-retry). Both from the Phase D critic's non-blocking follow-ups.

* feat(video): Phase E1 — VideoPostService + heartbeat mutex (approve->post)

CEO-approve->post service: heartbeat-renewed Redis mutex (fail-closed, grace=ttl-2*heartbeat), re-read-in-lock double-post guard, per-platform durable commits (asyncio.shield-ed, settle-before-rollback on lock-loss), all writes inside the lock (captions validated pre-lock, applied in-lock — no stale whole-column clobber), idempotent, per-platform retry-skip. Poster interfaces (X/TikTok, mocked here). Reject + list-held-drafts. Survived 3 adversarial rounds; residual = a crash in the poster->commit window (CEO-gated low-freq, documented).

* fix(video): G-hardening — renderer leaks + Share Tech Mono brand font

Sidecar: give bundle() an explicit outDir tracked + deleted on LRU eviction (was leaking ~19MB remotion-webpack-bundle-* per source); res.on('close') cleanup so an aborted/retried download no longer leaks its remotion-out-* MP4 dir. Fonts: vendor Share Tech Mono (roboco-website brand font) as the display face (self-hosted woff2, 400-weight, headline fontWeight 700->400 to avoid faux-bold) + self-hosted Inter body — no gstatic fetch at render time (lsof-verified). Extras: composition_id whitelist (400) + Multer error middleware (400/413).

* feat(video): Phase E2 — X v2 + TikTok posters, tiktok_credentials, routes

LiveXVideoPoster (X v2 chunked media upload: init/append/finalize/STATUS-poll -> tweet w/ media_ids, OAuth1 signer reused). LiveTikTokPoster (OAuth2 inbox: init -> chunked PUT with asymmetric final chunk -> status-fetch; 401 -> refresh_token grant, rotated token persisted). tiktok_credentials Fernet singleton + migration 062 (single head). Routes: CEO approve/reject + list held drafts + write-only tiktok creds, wiring real posters into VideoPostService. Residual: a lock-loss right after a token-refresh flush can discard the rotated token (same rare CEO-gated class as the documented post->commit window).

* feat(video): Phase F — panel video-post queue + TikTok creds card + flags

video-post-queue.tsx: <video> MP4 preview with 9:16/1:1 cut switch, per-platform editable captions (280/2200 counters, over-limit disables approve), approve/reject, Request-a-video dialog. tiktok-credentials-card.tsx (4 write-only OAuth2 fields). feature-flags-card inlines TikTokCredentialsForm under video_engine_enabled. Mounted in command-center. tsc/eslint clean, 273 panel tests green. NOTE: needs the GET /video/posts/{id}/media route + mp4_paths on VideoPostResponse (folded into H) for the preview source.

* feat(video): Phase H — media route + e2e smoke + NAS arming + docs

GET /video/posts/{id}/media?cut= (CEO-gated FileResponse of the rendered MP4; closes the panel preview gap) + mp4_paths on VideoPostResponse. e2e smoke tests/e2e_smoke/test_video_pipeline.py (full flow, sidecar+X/TikTok mocked; asserts dispatcher skips, render-loop materialize, propose_video team-gate, approve idempotency). NAS arming: docker-compose.yml/.yaml ROBOCO_VIDEO_ENGINE_ENABLED/ON_RELEASE/ON_SPOTLIGHT default-on (.yaml resynced to .yml); registry stays off. CLAUDE.md video-engine section + CHANGELOG. Fixed 2 pre-existing route-test pollution leaks. Full suite 11763 passed.

* fix(video): auth-carrying preview, media route confinement, VideoPost type drift

Three fixes along the video preview path:

1. panel video preview auth: the <video> element was pointed straight at
   GET /video/posts/{id}/media, but a native <video src> GET carries none
   of axios's X-Agent-ID/X-Agent-Role headers — so in the default
   header-trust deployment the request 401s. Fetch the cut via
   videoApi.getMediaBlob (axios, responseType: blob) and drive <video>
   off a URL.createObjectURL result instead. The object URL is revoked
   on cut-change (the previous cut's URL) and on unmount, so neither
   cut switches nor row teardown leak blob URLs.

2. backend media route confinement: GET /video/posts/{id}/media now
   resolves mp4_path and refuses it with 404 when it falls outside
   settings.video_output_dir. Defense-in-depth against any future
   writer of mp4_paths serving files from arbitrary disk locations.

3. panel VideoPost type/comment drift: added mp4_paths to the
   VideoPost interface (the committed VideoPostResponse already
   carries it), and corrected the stale comment on videoMediaUrl
   that claimed no route served the rendered bytes — the route has
   existed since the media endpoint landed; the comment now describes
   why getMediaBlob exists instead of a direct <video src>.

* Persist rendered videos to data in physical storage.

* ++

* docs(video): 0.18.0 CHANGELOG entry + RAG + map reference for video engine

- Move the video engine bullet from [Unreleased] into [0.18.0] and note
  the ROBOCO_VIDEO_OUTPUT_DIR bind-mount persistence.
- Add docs/rag/architecture/video-engine.md (mirrors x-engine.md shape:
  enable/disable, three triggers, render loop + sidecar, CEO gate, media
  route confinement, credentials).
- Reference the video render loop in docs/map/orchestrator.md's engine list.

* chore(video): re-bump to 0.19.0 + sync registry compose defaults

Version was wrongly bumped to 0.18.0; 0.18.0 is an already-released
section. Restore its 2026-07-04 date and move the video-engine CHANGELOG
bullet into a new [0.19.0] - 2026-07-05 section above it. Bump
pyproject.toml, roboco/__init__.py, roboco/config.py (app_version),
panel/package.json, and the motion/README inputProps example to 0.19.0.

docker-compose.registry.yml: add ROBOCO_VIDEO_ENGINE_ENABLED /
_VIDEO_ON_RELEASE / _VIDEO_ON_SPOTLIGHT defaulted false (NAS arms them
true), and comment out the video-renders bind mount with a short note
so the public registry image ships video off by default. Structural
sync with docker-compose.yml maintained.

* fix(video): rate-limit /render + reflow motion/README

CodeQL flagged js/missing-rate-limiting on the renderer /render route.
The sidecar is container-network-only with one trusted caller (the
orchestrator, which renders cuts serially), so this limiter is a
retry-storm ceiling (30/min, well above legit render rate), not the
primary control. Also reflows motion/README.md hard-wrapped prose that
failed the markdown quality gate.

* fix(build): finish pnpm 11 migration + regen verb tables

The panel Docker image build failed on `pnpm install --frozen-lockfile`:
node:22-alpine's corepack resolved to its bundled pnpm 11, but
panel/package.json pinned packageManager to pnpm@10.25.0, and pnpm 11
refuses to run against that pin. The Dockerfiles were already written for
pnpm 11 (comments, CI=true, strictDepBuilds); the package.json pin was the
stale outlier. Finish the migration instead of working around it:

- panel/package.json: packageManager pnpm@10.25.0 -> pnpm@11.10.0; drop the
  `pnpm` field (pnpm 11 ignores it — build approval lives in
  panel/pnpm-workspace.yaml's allowBuilds). Lockfile unchanged (pnpm 11
  accepts it as-is); frozen-lockfile verified.
- remotion-renderer/package.json: pin packageManager pnpm@11.10.0 for
  determinism (was relying on corepack's implicit default); engines.node
  >=22.13 (pnpm 11 requirement).
- docker/panel.Dockerfile + docker/remotion.Dockerfile: `corepack prepare
  pnpm@11.10.0 --activate` so the build uses the pinned version explicitly
  instead of trusting corepack's bundled default (which a future
  node:22-alpine could change).
- .github/workflows/panel-ci.yml: Node 20 -> 22 (pnpm 11 requires
  Node >=22.13; Node 20 fails the engines check).

Also regenerate agents/prompts/_generated/{developer,head_marketing,verbs}.md
— the video engine added propose_video and extended propose_feature_spotlight
(wants_video, video_script) but the verb tables weren't refreshed, failing
the foundation-check quality gate.

* chore(build): approve esbuild build script in remotion pnpm-workspace.yaml

pnpm 11 generated this file with a placeholder ('set this to true or false')
during install; resolve it to true so local dev of the renderer doesn't
re-prompt. esbuild's postinstall only verifies the prebuilt platform binary
(@esbuild/<platform> is installed as an optional dep), so approving it is
safe and silences the ERR_PNPM_IGNORED_BUILDS warning.

* fix(build): copy pnpm-workspace.yaml into panel + remotion images

pnpm 11 hard-errors with [ERR_PNPM_IGNORED_BUILDS] (exit 1) when a
dependency ships a postinstall script that isn't approved in
allowBuilds. Both Dockerfiles copied only package.json + pnpm-lock.yaml,
so the build-approval map in pnpm-workspace.yaml never made it into the
image — the remotion image build died on esbuild@0.28.1's postinstall.

Copy pnpm-workspace.yaml alongside the manifests in both images. In
panel, this also drops the --config.strictDepBuilds=false workaround:
with sharp and unrs-resolver now approved, their postinstalls run and
install the platform-specific binaries (previously skipped, leaving
sharp without its @img/sharp-* binary at runtime).

Verified locally: remotion + panel `pnpm install --frozen-lockfile`
exit 0 with the workspace file present; both exit 1 without it.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-05 13:37:17 +02:00

360 lines
14 KiB
Python

"""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
render -> materialize chain (only the remotion-renderer sidecar client + the
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:
"""Stands in for the remotion-renderer sidecar: returns a deterministic
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(
"roboco.services.remotion_client.get_remotion_renderer",
_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