Feature/video artifact verification (#537)

* 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>
This commit is contained in:
Renzo F
2026-07-16 19:49:26 +02:00
committed by GitHub
co-authored by Renn F
parent 797847e379
commit aa15dc40cc
37 changed files with 2146 additions and 54 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -30,6 +30,7 @@
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
| `propose_video` | `propose_video(composition_id: str, x_caption: str, tiktok_caption: str, platforms: list[str], input_props: str | Any | None = None)` |
| `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` |
| `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` |
+1
View File
@@ -25,6 +25,7 @@
| `evidence` | `evidence(task_id: UUID)` |
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
| `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` |
| `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` |
+2
View File
@@ -39,6 +39,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
| `propose_video` | `propose_video(composition_id: str, x_caption: str, tiktok_caption: str, platforms: list[str], input_props: str | Any | None = None)` |
| `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` |
| `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` |
@@ -69,6 +70,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `evidence` | `evidence(task_id: UUID)` |
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
| `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` |
| `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` |
+4 -2
View File
@@ -15,17 +15,19 @@ The RoboCo video engine: a default-off subsystem that authors bespoke short mark
| `roboco/runtime/heartbeat_mutex.py` | `HeartbeatMutex` — Redis mutex with heartbeat-renewed TTL, shared with `ReleaseProposalService`'s release-execute lock shape; backs `VideoPostService.approve`'s long video-upload critical section. | — |
| `roboco/mcp/do_server.py` `propose_video` | Do-tool the UX/UI dev calls exactly once per authoring task to stamp the `video_draft` marker (composition id + per-platform captions + input props); metadata-only, does not render. | — |
| `roboco/services/gateway/content_actions.py` `propose_video` | Server-side action: team-gated (`_caller_team` rejects be-dev/fe-dev), resolves the caller's open video task, `markers.set_video_draft` with the metadata. | — |
| `roboco/services/gateway/content_actions.py` `request_render` | Do-verb (developer/QA): renders the caller's ACTUAL composition to keyframe PNGs via the sidecar's frames mode and stamps the `render_preview` marker — dev renders their own tree (worktree-aware, `head_sha`/`dirty` stamped), QA a read-only branch export (`WorkspaceService.export_branch_motion`). Frames land at the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`. | — |
| `roboco/foundation/policy/tracing.py` `RENDER_VERIFIED` | `i_am_done` requirement on `source=video` tasks: no stamped `render_preview` → tracing gap naming `render_preview` (hint: call `request_render`, Read every frame). Mirrored in the possibilities-matrix fast path. | — |
| `alembic/versions/062_tiktok_credentials.py` | Migration 062 — the `tiktok_credentials` singleton row (Fernet-encrypted OAuth2 secrets, all-or-nothing set/clear, mirroring the git-token / `x_credentials` pattern). | 44 |
| `video-renderer/` | The sidecar: `server.js` (HTTP+tarball boundary), `render.js` (`@hyperframes/producer` `createRenderJob` + `executeRenderJob`, system `ffmpeg`, headless Chromium). Credential-free and git-free — reads only what's POSTed. | — |
| `docker/video-renderer.Dockerfile` | Sidecar image (`roboco-video-renderer`): Node + Chromium + system `ffmpeg`; installs `@hyperframes/producer`. No RoboCo source, no creds. | — |
## Data Flow
DETECT → AUTHOR: a release publish (`ROBOCO_VIDEO_ON_RELEASE`), a CEO-approved feature-spotlight draft that requests one (`ROBOCO_VIDEO_ON_SPOTLIGHT`), or a CEO on-demand `POST /api/video/request` calls `VideoEngine.open_video_task`, which creates a normal ASSIGNED UX/UI authoring task (`source=video`, `confirmed_by_human=True`, balanced across the two ux-devs) — NOT held, NOT in any dispatcher's skip bucket. The assigned dev authors `motion/compositions/<id>/{vertical,square}.html` (HyperFrames render params on `<html>`), calls the `propose_video` do-tool exactly once (server-side `content_actions.propose_video` is team-gated and stamps `video_draft`), then `commit` + `open_pr` through the normal PR-review gate. The authoring task rides the standard QA/doc/review lifecycle to `completed`.
DETECT → AUTHOR: a release publish (`ROBOCO_VIDEO_ON_RELEASE`), a CEO-approved feature-spotlight draft that requests one (`ROBOCO_VIDEO_ON_SPOTLIGHT`), or a CEO on-demand `POST /api/video/request` calls `VideoEngine.open_video_task`, which creates a normal ASSIGNED UX/UI authoring task (`source=video`, `confirmed_by_human=True`, balanced across the two ux-devs) — NOT held, NOT in any dispatcher's skip bucket. The assigned dev authors `motion/compositions/<id>/{vertical,square}.html` (HyperFrames render params on `<html>`), calls the `propose_video` do-tool exactly once (server-side `content_actions.propose_video` is team-gated and stamps `video_draft`), then verifies the ARTIFACT: `request_render` renders the dev's actual working tree to keyframe PNGs the dev must Read (every scene fully visible and legible — the gate that catches an authored duration shorter than its scene list), iterating fix → re-render until the frames prove the brief; `i_am_done` refuses without the stamped `render_preview` marker (`Requirement.RENDER_VERIFIED`). Then `commit` + `open_pr` through the normal PR-review gate. The authoring task rides the standard QA/doc/review lifecycle to `completed`, with QA's `claim_review` evidence carrying a `video_context` block (the dev's preview + an instruction to `request_render` the branch state fresh).
RENDER: once the authoring task is `completed`, the orchestrator's `_video_render_loop` (bounded retry, `_MAX_VIDEO_RENDER_ATTEMPTS`) resolves the project's read-clone at the merged HEAD, tars the `motion/` dir, and POSTs it to the credential-free `video-renderer` sidecar (`ROBOCO_VIDEO_RENDERER_BASE_URL`). The sidecar untars, runs `@hyperframes/producer`'s `createRenderJob` + `executeRenderJob` per orientation (headless Chrome + system `ffmpeg`, `ROBOCO_VIDEO_RENDER_TIMEOUT_SECONDS` per render), and streams both 9:16 and 1:1 MP4s back. `VideoRenderer` saves them to `ROBOCO_VIDEO_OUTPUT_DIR` (`_save` also PUTs each to MinIO when `minio_endpoint` is set, non-fatal). On success `VideoEngine._originate_video_post` materializes a held `video_post` draft (`source=video_post`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) carrying `mp4_paths` (`{vertical, square}` absolute paths) + the per-platform captions.
CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET /api/video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player (CEO-gated, falls back to `FileResponse` on `S3Error`/unconfigured MinIO). The CEO edits captions and approves/rejects in the panel's `video-post-queue.tsx`. `POST /api/video/posts/{id}/approve` is the ONLY caller of `XVideoPoster` / `TikTokPoster`: it acquires `HeartbeatMutex`, re-reads the committed task state inside the lock, commits `COMPLETED` before releasing (so a concurrent approve can't double-post), commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster). `POST /api/video/posts/{id}/reject` cancels the draft with a reason.
CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET /api/video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player (CEO-gated, falls back to `FileResponse` on `S3Error`/unconfigured MinIO). The CEO edits captions and approves/rejects in the panel's `video-post-queue.tsx`. `POST /api/video/posts/{id}/approve` is the ONLY caller of `XVideoPoster` / `TikTokPoster`: it acquires `HeartbeatMutex`, re-reads the committed task state inside the lock, commits `COMPLETED` before releasing (so a concurrent approve can't double-post), commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster). `POST /api/video/posts/{id}/reject` cancels the draft with a reason — and, for a non-empty reason, `VideoEngine.reauthor_from_rejection` opens a fresh authoring task (same occasion, brief = the CEO's verbatim feedback + revise-in-place pointer at the existing composition) so the rejection feedback re-enters the delivery flow instead of dying on the cancelled draft; best-effort, never fails the reject.
## Config Flags
+5 -1
View File
@@ -24,6 +24,10 @@ Even when enabled, distribution requires an explicit per-clip CEO approval — a
All three open a normal, **assigned** UX/UI authoring task (balanced across the two ux-devs) rather than a held draft — the dev builds a HyperFrames HTML composition under `motion/compositions/<id>/` (one `<id>/vertical.html` + `<id>/square.html` carrying the HyperFrames render params on `<html>`) and proposes its composition id + per-platform captions via the team-gated `propose_video` do-tool, then ships it through the standard commit/PR/QA/doc/review lifecycle.
## Artifact verification (request_render)
Authoring is gated on the RENDERED artifact, not just its source: the `request_render` do-tool (developer/QA) renders the caller's actual composition through the sidecar and extracts evenly spaced keyframe PNGs to a container-shared `.previews/` path, returning their absolute paths in the envelope's `evidence.frames`. The agent must Read every frame and verify each scene/feature from the brief appears fully and legibly — a 14-second cut that only ever shows its first scene is exactly what this catches. A developer renders their own working tree (worktree-aware, `head_sha`/`dirty` provenance stamped); QA renders a read-only `git archive` export of the assembled branch — never a working tree. A successful render stamps the task's `render_preview` marker, and `i_am_done` on a video-authoring task refuses without it (`Requirement.RENDER_VERIFIED`, mirrored in the possibilities-matrix fast path), so no video task can complete on a source-only self-review. QA's `claim_review` evidence carries a `video_context` block (composition id + the dev's stamped preview + an instruction to re-render the branch state) so the reviewer checks output, not source.
## Render loop and the sidecar
Once the authoring task completes, an orchestrator render loop (`_video_render_loop`, interval `ROBOCO_VIDEO_RENDER_INTERVAL_SECONDS`) tars the merged `motion/` source and POSTs it to the `video-renderer` sidecar (`ROBOCO_VIDEO_RENDERER_BASE_URL`). The sidecar is credential-free and git-free — it reads only what it's POSTed, bundles the composition, renders both the 9:16 and 1:1 MP4 cuts (`ROBOCO_VIDEO_RENDER_TIMEOUT_SECONDS` per render), and streams the bytes back. The orchestrator writes the MP4s to `ROBOCO_VIDEO_OUTPUT_DIR` (bind-mounted in all three compose files so renders survive container recreation) — the sidecar never writes to disk directly. The sidecar renders via `@hyperframes/producer` — agent-authored HTML rendered in headless Chrome plus system `ffmpeg` for the seek-driven, frame-deterministic MP4 cut. HyperFrames was chosen for its Apache-2.0 license (no React/TSX toolchain lock-in for the authoring agents), agent-authored HTML as the composition substrate, and seek-driven deterministic renders that match the post-pipeline shape without a node-bundled renderer.
@@ -36,7 +40,7 @@ The render pass materializes a held `video_post` draft — mirroring the X-post/
|----------|--------|
| `GET /api/video/posts` | List every held video draft awaiting decision. |
| `POST /api/video/posts/{task_id}/approve` | Post the rendered clip to X (native video, v2 media upload) and/or TikTok (inbox upload). Idempotent — approving an already-posted draft is a no-op. |
| `POST /api/video/posts/{task_id}/reject` | Cancel the draft with a reason. Terminal. |
| `POST /api/video/posts/{task_id}/reject` | Cancel the draft with a reason — and route the feedback back into the flow: a non-empty reason opens a fresh authoring task (same occasion, brief = the CEO's verbatim feedback + a revise-in-place pointer at the existing composition), so a rejection is a rework loop, not a dead end. |
| `POST /api/video/request` | Open an on-demand authoring task (CEO-only). |
Approval runs under a Redis heartbeat-renewed lock so a double-click can't double-post; the task is marked `COMPLETED` under the same lock before it releases.
+4
View File
@@ -166,3 +166,7 @@ Your plan's steps are the progress checklist; the percentage is derived from com
## Sandbox DB/Redis/Mongo (Developer + QA)
`request_sandbox(services=None, extensions=None)` — a **content tool** on `roboco-do`, not a flow verb — provisions a throwaway sandbox Postgres/Redis/Mongo on demand, for a project that opted in (`projects.sandbox_services`). Only `developer` and `qa` carry it. Omit `services` for the project's whole opted-in set; requesting one outside it is rejected naming the allowed set. `extensions` is an optional per-service map of extensions/modules to activate (e.g. `{"postgres": ["vector"]}`), unioned with the project's standing `sandbox_extensions` set and bounded by a fixed allowlist (pg: vector/postgis/pg_trgm/citext/uuid-ossp; redis: search/json/bloom — no `plpython3u`); an unallowed feature or a feature for a non-opted service is rejected naming the allowed set. Creds come back in the envelope's `evidence`, one entry per service, including ready-to-`export` `ROBOCO_TEST_*` values for gate tooling and an `available_extensions` list of what was activated. Calling it again is a cheap no-op (same creds) as long as the requested features are a subset of the cached set. See `docs/rag/architecture/sandbox-db.md`.
## Video render preview (Developer + QA, video-authoring tasks)
`request_render(composition_id=None, orientation="vertical", frame_count=8, input_props=None)` — a **content tool** on `roboco-do` — renders your ACTUAL HyperFrames composition through the video-renderer sidecar and returns evenly spaced keyframe PNGs. Only valid on a `source=video` authoring task. Omit `composition_id` to use the one you already proposed via `propose_video`. The envelope's `evidence.frames` lists absolute paths (readable from your container): **Read every frame** and verify each scene/feature from the brief appears fully and legibly — the composition source looking right is NOT evidence the rendered clip is right. A developer renders their own working tree; QA renders a read-only export of the assembled branch. A successful render stamps the task's `render_preview` marker — `i_am_done` on a video task refuses without it. If a scene is missing, clipped, or rushed, fix the composition and call it again. See `docs/rag/architecture/video-engine.md`.
+18
View File
@@ -32,6 +32,7 @@ from roboco.api.schemas.v1.do import (
PRUpdateRequest,
ReadMessagesRequest,
RejectPlaybookRequest,
RequestRenderRequest,
RequestSandboxRequest,
)
from roboco.security import (
@@ -274,6 +275,23 @@ async def do_request_sandbox(
return envelope_to_response(env, request)
@router.post("/request_render")
async def do_request_render(
request: Request,
body: RequestRenderRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.request_render(
agent_id=x_agent_id,
composition_id=body.composition_id,
orientation=body.orientation,
frame_count=body.frame_count,
input_props=body.input_props,
)
return envelope_to_response(env, request)
# ---------------------------------------------------------------------------
# Wave 1 — pre-gateway parity
# ---------------------------------------------------------------------------
+11
View File
@@ -183,6 +183,17 @@ class RequestSandboxRequest(BaseModel):
extensions: dict[str, list[str]] | None = None
class RequestRenderRequest(BaseModel):
"""Render a video composition to preview frames for artifact
verification. Omitted `composition_id` falls back to the task's
`video_draft` marker."""
composition_id: str | None = None
orientation: str = "vertical"
frame_count: int = 8
input_props: dict[str, Any] | None = None
class ProgressRequest(BaseModel):
"""Progress update; % is DERIVED from the plan checklist.
@@ -48,6 +48,7 @@ X_SPOTLIGHT_SKIP_REASON = "x_spotlight_skip_reason"
ROADMAP_CYCLE = "roadmap_cycle"
VIDEO_DRAFT = "video_draft"
VIDEO_REJECT_REASON = "video_reject_reason"
RENDER_PREVIEW = "render_preview"
VAULT_CURATION_DISPATCHED = "vault_curation_dispatched"
VAULT_NOTE_REF = "vault_note_ref"
DOCS_SYNC_RELEASE_VERSION = "docs_sync_release_version"
@@ -302,6 +303,29 @@ def set_video_reject_reason(task: HasMarkers, reason: str) -> None:
set_marker(task, VIDEO_REJECT_REASON, reason)
# Task-source tag for a video-authoring task. Canonical here so foundation-
# layer gates can key on it without importing the services layer;
# services/task.py's VIDEO_SOURCE aliases this.
VIDEO_TASK_SOURCE = "video"
def get_render_preview(task: HasMarkers) -> dict[str, Any] | None:
"""The last ``request_render`` preview stamped on a video-authoring task.
Payload: {at, composition_id, orientation, frame_count, duration_seconds,
frames (absolute container paths, readable from every agent container),
head_sha, dirty, rendered_by, source ("workspace"|"branch")}. Its
presence is what the i_am_done RENDER_VERIFIED gate checks proof the
author looked at the actual rendered artifact, not just the source.
"""
val = get_marker(task, RENDER_PREVIEW)
return val if isinstance(val, dict) else None
def set_render_preview(task: HasMarkers, payload: dict[str, Any]) -> None:
set_marker(task, RENDER_PREVIEW, payload)
# --- vault curation ---------------------------------------------------------
# One-shot guard for the root-completion Auditor spawn (orchestrator): set the
# moment the spawn fires so a restart can't re-spawn a root another process
+17
View File
@@ -19,6 +19,8 @@ from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from roboco.foundation.policy.content import markers
class Requirement(StrEnum):
PLAN = "plan"
@@ -55,6 +57,10 @@ class Requirement(StrEnum):
# before the developer can resubmit. Trivially satisfied by an empty
# ledger (no findings ever filed).
FINDINGS_ADDRESSED = "findings_addressed"
# A video-authoring task must carry a request_render preview before
# i_am_done — proof the author looked at the rendered artifact, not just
# the source. No-op for a non-video task.
RENDER_VERIFIED = "render_verified"
@dataclass(frozen=True)
@@ -218,6 +224,13 @@ def _check_findings_addressed(_task: Any, ctx: GateContext) -> list[str]:
return [f"finding:{fid}" for fid in ctx.open_finding_ids]
def _check_render_verified(task: Any, _ctx: GateContext) -> list[str]:
"""The gate that makes a video dev look at the rendered artifact."""
if getattr(task, "source", None) != markers.VIDEO_TASK_SOURCE:
return []
return [] if markers.get_render_preview(task) else ["render_preview"]
def _check_subtasks_terminal(task: Any, _ctx: GateContext) -> list[str]:
"""Caller passes a task whose `_subtasks_all_terminal` boolean is set
by the choreographer based on a DB query. Validator just reads it."""
@@ -250,6 +263,7 @@ _CHECKERS: dict[Requirement, Checker] = {
Requirement.PR_REVIEWER_NOTES_MIN_CHARS: _check_pr_reviewer_notes_min_chars,
Requirement.QUICK_CONTEXT_MIN_CHARS: _check_quick_context_min_chars,
Requirement.FINDINGS_ADDRESSED: _check_findings_addressed,
Requirement.RENDER_VERIFIED: _check_render_verified,
}
@@ -314,6 +328,9 @@ VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = {
# `resolved_findings`) before resubmitting. Trivially satisfied
# when the ledger has no rows for this task.
Requirement.FINDINGS_ADDRESSED,
# A video-authoring task must have a request_render preview on
# file. No-op for every other task source.
Requirement.RENDER_VERIFIED,
}
),
# QA pass/fail.
+47
View File
@@ -50,6 +50,10 @@ _COMMIT_TIMEOUT = 190
# 1035s — images are pre-pulled at startup in practice, so cold pulls here
# are the exception, but the timeout must cover the worst case anyway.
_SANDBOX_TIMEOUT = 1080
# request_render runs an inline sidecar render call; the sidecar's own render
# ceiling is video_render_timeout_seconds (default 600s) plus its request
# timeout for uploading the tarball — 660s gives that headroom.
_RENDER_TIMEOUT = 660
# Tight timeout for SDK loopback — local sidecar; gateway path must not stall.
_SDK_TIMEOUT = 2.0
# FastAPI's default missing-route status. Every /api/v1/do/* route returns
@@ -658,6 +662,10 @@ def propose_video(
composition in motion/compositions/<id>/. Then commit + open_pr to send
it through the normal PR-review gate.
After proposing, call request_render and verify the frames it returns
before i_am_done the composition's SOURCE looking right is not the
same as the RENDERED artifact looking right.
Args:
composition_id: The HyperFrames composition id (the directory name
under motion/compositions/, e.g. 'release-announcement').
@@ -754,6 +762,44 @@ def request_sandbox(
)
def request_render(
composition_id: str | None = None,
orientation: str = "vertical",
frame_count: int = 8,
input_props: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Render your video composition to a strip of preview frames — call
this after building or altering the HyperFrames composition, on a
video-authoring task, to verify the RENDERED artifact rather than just
trusting the source looks right.
Omit ``composition_id`` to use the one already proposed via
propose_video. Returns ``evidence.frames`` absolute paths to the
rendered frame images. Read EVERY frame with your file tools before
calling i_am_done; if a scene is missing, clipped, or wrong, fix the
composition and call this again. A developer renders from their own
working tree; QA renders from a read-only export of the assembled
branch either way this never mutates your working tree.
Args:
composition_id: The composition id under motion/compositions/. Omit
to use the task's already-proposed composition_id.
orientation: 'vertical' or 'square'.
frame_count: How many evenly-spaced preview frames to extract (1-32).
input_props: Optional props passed into the composition at render time.
"""
return _post(
"/api/v1/do/request_render",
{
"composition_id": composition_id,
"orientation": orientation,
"frame_count": frame_count,
"input_props": input_props,
},
timeout=_RENDER_TIMEOUT,
)
def draft_playbook(
title: str,
problem: str,
@@ -962,6 +1008,7 @@ _TOOLS: dict[str, Any] = {
"notify": notify,
"evidence": evidence,
"request_sandbox": request_sandbox,
"request_render": request_render,
"progress": progress,
"notify_list": notify_list,
"notify_get": notify_get,
+27 -1
View File
@@ -77,6 +77,7 @@ from roboco.services.task import (
ROADMAP_SOURCE,
SELF_HEAL_SOURCE,
VIDEO_HELD_SOURCES,
VIDEO_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
X_SOURCES,
)
@@ -14268,6 +14269,28 @@ If the fast path refuses (a gate it checks is not actually met), the
state, f'Call evidence(task_id="{task_id}") to check status.'
)
def _video_prompt_block(self) -> str:
"""Video-authoring instructions appended for a ``source=VIDEO_SOURCE``
task: build + propose the composition, then render it and eyeball
every keyframe a clean source review is not evidence the RENDERED
clip is right; the frames are."""
return """
## VIDEO TASK — verify the RENDERED clip, not just the source
1. Build/extend the composition under motion/compositions/<id>/ per the brief.
2. propose_video(composition_id="<id>", x_caption="...", tiktok_caption="...",
platforms=[...], input_props={<real facts from the brief>}) once.
3. request_render() renders your actual composition and
returns keyframe PNG paths Read every one. Confirm each scene/feature
named in the brief appears fully and legibly, and that the authored
data-duration actually covers all scenes (nothing cut off or rushed).
4. A frame doesn't prove it: fix the composition, then request_render()
again. Repeat until every frame checks out.
i_am_done() refuses without a stamped render preview a clean self-review of
the source is not enough; the rendered frames are the evidence.
"""
async def _build_dev_prompt(self, task: dict[str, Any]) -> str:
"""Build state-aware initial prompt for a developer."""
task_id = task.get("id", "unknown")
@@ -14298,6 +14321,9 @@ If the fast path refuses (a gate it checks is not actually met), the
instructions = self._get_workflow_instructions(
workflow_state, task_id, open_findings_block
)
video_block = (
self._video_prompt_block() if task.get("source") == VIDEO_SOURCE else ""
)
# The task spec travels with the prompt so the dev starts with the
# actual ask (file:line targets, constraints, the intake's rationale)
@@ -14320,7 +14346,7 @@ and constraints come from the intake analysis and PM decomposition. Re-articulat
only the HOW (the solution); the WHAT is already decided upstream.
{instructions}
{video_block}
Start by calling evidence(task_id="{task_id}") for full details, acceptance
criteria, and the upstream parent/ancestor context (the original intake analysis).
+11 -1
View File
@@ -56,6 +56,7 @@ from roboco.services.gateway.remediation import (
hint_for_missing_qa_notes,
hint_for_missing_reflect,
hint_for_open_findings,
hint_for_render_preview,
hint_for_short_dev_notes,
hint_for_short_doc_notes,
hint_for_short_pr_reviewer_notes,
@@ -2856,12 +2857,20 @@ class Choreographer:
async def _fast_path_rejection(self, ctx: _IAmDoneContext) -> Any:
"""The fast path's ordered rejection cascade; None means proceed.
Order matters: notes first (cheap, no writes), then finding
Order matters: notes first (cheap, no writes), then the video
render-preview check (cheap, no writes a video task cannot fast-
path around looking at the rendered artifact), then finding
resolution + guards, then the open-findings re-check, then the
quality verdict with its toolchain backstop.
"""
if soup := self._soup_reason(ctx.notes, "notes", 4):
return soup
if getattr(
ctx.task, "source", None
) == markers.VIDEO_TASK_SOURCE and not markers.get_render_preview(ctx.task):
return await self._build_tracing_gap(
ctx.agent_id, ctx.task_id, ["render_preview"], task=ctx.task
)
await self._apply_resolved_findings(ctx)
guards = (
lambda: self._check_submit_qa_field_gates(
@@ -3477,6 +3486,7 @@ class Choreographer:
"submit_verification automatically; if you see this gap, "
"retry i_am_done after the previous call returned."
),
"render_preview": hint_for_render_preview(),
}
return simple_hints.get(missing_key)
@@ -202,6 +202,28 @@ class QAMixin(_Base):
return [{"could_not_run": True, "reason": reason}]
return list(result.get("findings", []))
@staticmethod
def _qa_video_context(t: Any) -> dict[str, Any] | None:
"""QA-facing artifact context for a video-authoring task.
None for every non-video task. Carries the composition id (from the
``video_draft`` marker) + the latest ``request_render`` preview so QA
is pointed at the rendered artifact instead of the source alone.
"""
if getattr(t, "source", None) != markers.VIDEO_TASK_SOURCE:
return None
draft = markers.get_video_draft(t) or {}
return {
"composition_id": draft.get("composition_id"),
"render_preview": markers.get_render_preview(t),
"note": (
"This task ships a rendered video. Call request_render to "
"render the PR branch state, then Read every returned frame "
"image — verify each acceptance criterion's scene appears "
"fully and legibly. Do not pass on source reading alone."
),
}
async def _build_qa_claim_evidence(
self, qa_agent_id: UUID, t: Any, task_id: UUID
) -> Any:
@@ -264,6 +286,7 @@ class QAMixin(_Base):
prior_findings=prior_findings,
parent_context=parent_context,
collision_context=collision_context,
video_context=self._qa_video_context(t),
)
async def _verify_qa_owner(
+466
View File
@@ -11,9 +11,15 @@ Pure orchestration; no DB writes outside what the underlying services do.
from __future__ import annotations
import asyncio
import contextlib
import io
import re
import shutil
import tarfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
import structlog
@@ -32,6 +38,7 @@ from roboco.services.gateway.evidence_builder import build_evidence_for_task
from roboco.services.x_client import MAX_TWEET_CHARS
if TYPE_CHECKING:
from collections.abc import Callable
from uuid import UUID
from roboco.foundation.identity import Team
@@ -306,6 +313,19 @@ class ContentActionsDeps:
orchestrator: Any = None
@dataclass(frozen=True)
class _RenderSource:
"""A request_render source: a directory containing ``motion/``, plus its
provenance. ``cleanup`` (set only for a QA branch-export scratch dir) is
always called by request_render's ``finally``, dev or QA alike."""
root: Path
head_sha: str | None
dirty: bool
kind: str # "workspace" (dev's own tree) | "branch" (QA's read-only export)
cleanup: Callable[[], None] | None = None
_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority)
# The Board roles that may author a pitch (a product proposal for CEO approval).
_PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"})
@@ -2195,6 +2215,452 @@ class ContentActions:
context_briefing={},
)
async def _render_active_video_task(
self, agent_id: UUID
) -> tuple[Any, Envelope | None]:
"""request_render's task guard: an active, project-bound video-
authoring task, or a clean invalid_state rejection."""
from roboco.services.task import VIDEO_SOURCE
t = await self.task.get_active_task_for_agent(agent_id)
if t is None or t.project_id is None or t.source != VIDEO_SOURCE:
return None, Envelope.invalid_state(
message="no active video-authoring task assigned to you",
remediate=(
"request_render is only available on a video-authoring "
"task — claim your assigned authoring task first"
),
context_briefing={},
)
return t, None
@staticmethod
def _render_resolve_composition_id(
task: Any, composition_id: str | None
) -> tuple[str, Envelope | None]:
"""Explicit ``composition_id``, else the task's ``video_draft``
marker's, validated with the SAME charset regex ``propose_video``
enforces so an unrenderable id is refused here, not deep inside the
sidecar call."""
resolved = composition_id or (markers.get_video_draft(task) or {}).get(
"composition_id"
)
if not resolved or not str(resolved).strip():
return "", Envelope.incomplete_input(
missing=["composition_id"],
field_hints={"composition_id": "the HyperFrames composition id"},
remediate=(
"pass composition_id explicitly, or call propose_video "
"first so it's on the task's video_draft marker"
),
context_briefing={},
)
resolved = str(resolved).strip()
if not _COMPOSITION_ID_RE.fullmatch(resolved):
return "", Envelope.invalid_state(
message=f"composition_id {resolved!r} is not renderable",
remediate=(
"letters, digits, '_' or '-' with optional interior dots — "
"match the directory name under motion/compositions/"
),
context_briefing={},
)
return resolved, None
_RENDER_ORIENTATIONS: ClassVar[frozenset[str]] = frozenset({"vertical", "square"})
_RENDER_MAX_FRAMES: ClassVar[int] = 32
@classmethod
def _render_validate_params(
cls, orientation: str, frame_count: int
) -> Envelope | None:
"""``orientation``/``frame_count`` bounds guard, folded into one
rejection point so ``request_render`` keeps one return per guard."""
if orientation not in cls._RENDER_ORIENTATIONS:
return Envelope.invalid_state(
message=f"orientation {orientation!r} must be 'vertical' or 'square'",
remediate="pass orientation='vertical' or orientation='square'",
context_briefing={},
)
if not isinstance(frame_count, int) or not (
1 <= frame_count <= cls._RENDER_MAX_FRAMES
):
bound = cls._RENDER_MAX_FRAMES
return Envelope.invalid_state(
message=f"frame_count {frame_count!r} must be an integer 1-{bound}",
remediate=f"pass frame_count between 1 and {bound}",
context_briefing={},
)
return None
@staticmethod
def _render_resolve_input_props(
task: Any, input_props: dict[str, Any] | None
) -> dict[str, Any]:
if input_props is not None:
return input_props
draft = markers.get_video_draft(task) or {}
return draft.get("input_props") or draft.get("suggested_input_props") or {}
@staticmethod
async def _render_git_rev(workspace: Path, ref: str) -> str | None:
"""Best-effort ``git rev-parse <ref>`` in ``workspace``; ``None`` on
any failure (missing ref, missing dir, no git binary). The render
preview's provenance stamp is best-effort — a git hiccup must never
block the render response itself."""
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
str(workspace),
"rev-parse",
ref,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
except OSError:
return None
if proc.returncode != 0:
return None
sha = out.decode().strip()
return sha or None
@classmethod
async def _render_git_head_and_dirty(
cls, workspace: Path
) -> tuple[str | None, bool]:
"""Best-effort ``(HEAD sha, has-uncommitted-changes)`` for a dev's own
working tree. ``(None, False)`` on any failure."""
head = await cls._render_git_rev(workspace, "HEAD")
if head is None:
return None, False
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
str(workspace),
"status",
"--porcelain",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
except OSError:
return head, False
dirty = bool(out.decode().strip()) if proc.returncode == 0 else False
return head, dirty
async def _render_dev_source(
self, agent_id: UUID, agent_slug: str, task: Any, project: Any
) -> tuple[Any, Envelope | None]:
"""A developer's own working tree — the per-task worktree when one
exists on disk, else the clone root (F123)."""
from roboco.services.workspace import WorkspaceError
agent = await self.task.agent_for(agent_id)
if agent is None or not agent.team:
return None, Envelope.invalid_state(
message="your team could not be resolved",
remediate="ensure your agent record has a team, then retry",
context_briefing={},
)
try:
clone_root = self.workspace.get_clone_root_path(
project.slug, agent.team, agent_slug
)
worktree = self.workspace.get_worktree_path(
project.slug, agent.team, agent_slug, task.id.hex[:8]
)
except WorkspaceError as exc:
return None, Envelope.invalid_state(
message=f"could not resolve your workspace path: {exc}",
remediate="retry request_render; escalate to your PM if it persists",
context_briefing={},
)
root = worktree if worktree.exists() else clone_root
head_sha, dirty = await self._render_git_head_and_dirty(root)
return (
_RenderSource(root=root, head_sha=head_sha, dirty=dirty, kind="workspace"),
None,
)
async def _render_qa_source(
self, task: Any, project: Any
) -> tuple[Any, Envelope | None]:
"""QA never renders from a working tree: a read-only export of the
assembled branch's ``motion/`` subtree via
``WorkspaceService.export_branch_motion``."""
from roboco.services.workspace import WorkspaceError
branch = getattr(task, "branch_name", None)
if not branch:
return None, Envelope.invalid_state(
message="task has no recorded branch to export for a QA render",
remediate=(
"the assembled PR's branch must exist before requesting a render"
),
context_briefing={},
)
try:
scratch = await self.workspace.export_branch_motion(project, branch)
except WorkspaceError as exc:
return None, Envelope.invalid_state(
message=f"could not export branch {branch!r} for render: {exc}",
remediate=(
"ensure the branch is pushed to origin, then retry request_render"
),
context_briefing={},
)
read_clone = await self.workspace.ensure_read_clone(project.slug)
head_sha = await self._render_git_rev(
read_clone, f"refs/remotes/origin/{branch}"
)
def _cleanup() -> None:
shutil.rmtree(scratch, ignore_errors=True)
return (
_RenderSource(
root=scratch,
head_sha=head_sha,
dirty=False,
kind="branch",
cleanup=_cleanup,
),
None,
)
async def _render_resolve_source(
self, agent_id: UUID, agent_slug: str, task: Any, project: Any
) -> tuple[Any, Envelope | None]:
"""Dispatch the render SOURCE by the caller's role: developer → their
own tree; QA a read-only branch export; anyone else refused."""
role = await self._caller_role(agent_id)
if role == "developer":
return await self._render_dev_source(agent_id, agent_slug, task, project)
if role == "qa":
return await self._render_qa_source(task, project)
return None, Envelope.not_authorized(
message=f"role {role!r} may not request a render",
remediate="request_render is developer/QA only",
context_briefing={},
)
async def _render_resolve_project_and_source(
self, agent_id: UUID, agent_slug: str, task: Any
) -> tuple[Any, Envelope | None]:
"""Resolve the task's project, then the caller's render source —
bundled into one combined-rejection helper so ``request_render``'s
own return count stays under the xenon/PLR0911 budget. Success value
is a ``(project, source)`` pair."""
from roboco.services.project import get_project_service
project = await get_project_service(self.task.session).get(task.project_id)
if project is None:
return None, Envelope.invalid_state(
message="task's project could not be resolved",
remediate="retry shortly; the project record may be mid-update",
context_briefing={},
)
source, rejection = await self._render_resolve_source(
agent_id, agent_slug, task, project
)
if rejection is not None:
return None, rejection
return (project, source), None
def _render_extract_frames(
self, project_slug: str, task_id: Any, orientation: str, frames_tar_gz: bytes
) -> list[str]:
"""Extract the sidecar's frames tar.gz to the container-shared
preview dir, wiping any stale render first; returns sorted absolute
frame paths. Every agent container mounts the same /data/workspaces
volume, so this path is identical inside every container regardless
of who rendered that's what lets a dev render and a QA (or PM)
read the same frames from their own container."""
out_dir = (
Path(settings.workspaces_root)
/ project_slug
/ ".previews"
/ task_id.hex[:8]
/ orientation
)
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(frames_tar_gz)) as tar:
tar.extractall(out_dir, filter="data")
return sorted(str(p) for p in out_dir.rglob("*") if p.is_file())
async def _render_execute(
self,
*,
task: Any,
project: Any,
agent_slug: str,
resolved_id: str,
resolved_props: dict[str, Any],
orientation: str,
frame_count: int,
source: Any,
) -> Envelope:
"""The render call + frame extraction + marker stamp, once every
guard in ``request_render`` has passed. Always cleans up a QA
scratch-dir source (dev sources have no cleanup callback)."""
try:
comp_dir = source.root / "motion" / "compositions" / resolved_id
if not comp_dir.is_dir():
expected = f"motion/compositions/{resolved_id}/"
return Envelope.invalid_state(
message=f"no composition found at {expected}",
remediate=(
f"build the composition under {expected} first "
"(propose_video / commit it), then retry request_render"
),
context_briefing={},
)
from roboco.services.video_renderer_client import (
VideoRendererError,
get_video_renderer,
)
try:
frames_tar_gz, duration = await get_video_renderer().render_frames(
str(source.root / "motion"),
composition_id=resolved_id,
input_props=resolved_props,
orientation=orientation,
frame_count=frame_count,
)
except VideoRendererError as exc:
return Envelope.invalid_state(
message=f"render failed: {exc}",
remediate=(
"retry request_render shortly; escalate to your PM if "
"it keeps failing"
),
context_briefing={},
)
frames = self._render_extract_frames(
project.slug, task.id, orientation, frames_tar_gz
)
payload = {
"at": datetime.now(UTC).isoformat(),
"composition_id": resolved_id,
"orientation": orientation,
"frame_count": frame_count,
"duration_seconds": duration,
"frames": frames,
"head_sha": source.head_sha,
"dirty": source.dirty,
"rendered_by": agent_slug,
"source": source.kind,
}
markers.set_render_preview(task, payload)
# The post-completion render loop keys on video_draft.composition_id
# — a dev who only ever passed composition_id explicitly (never
# propose_video) must still leave it stamped, or the loop skips the
# completed task silently (proven live on task 1dae04a7).
draft = markers.get_video_draft(task) or {}
if not draft.get("composition_id"):
markers.set_video_draft(task, {**draft, "composition_id": resolved_id})
await self.task.session.flush()
await self._touch_heartbeat(task.id)
return Envelope.ok(
status=str(task.status),
task_id=str(task.id),
next=(
"Read every frames[] path with your file tools; if any "
"scene is missing or clipped, fix the composition and "
"call request_render again."
),
evidence={
**payload,
"note": (
"Read each frame image and verify every scene/feature "
"from the brief appears fully and legibly before "
"i_am_done."
),
},
context_briefing={},
)
finally:
if source.cleanup is not None:
source.cleanup()
async def request_render(
self,
*,
agent_id: UUID,
composition_id: str | None = None,
orientation: str = "vertical",
frame_count: int = 8,
input_props: dict[str, Any] | None = None,
) -> Envelope:
"""Render a video composition to a strip of preview frames the
caller reads with file tools verifying the RENDERED artifact, not
just the HyperFrames source, which can look plausible and still
render wrong (missing scene, clipped layout, wrong text).
Guards, in order: video engine flag off; caller has no active,
project-bound video-authoring task (`_render_active_video_task`);
the renderer sidecar unconfigured (`video_renderer_base_url`);
`composition_id` unresolvable or failing `propose_video`'s own
charset regex, or `orientation`/`frame_count` out of range; the
caller's role-based SOURCE (`_render_resolve_source` — a developer's
own tree, or QA's read-only branch export, never a QA working tree);
the resolved source missing `motion/compositions/<id>/`; the sidecar
call itself (`VideoRendererError` -> a retryable rejection). On
success, extracts the returned frames to a container-shared
`.previews/<task>/<orientation>/` path and stamps `render_preview`
the marker `i_am_done`'s RENDER_VERIFIED gate checks.
"""
if not settings.video_engine_enabled:
return Envelope.invalid_state(
message="the video engine is disabled",
remediate="ROBOCO_VIDEO_ENGINE_ENABLED is off — nothing to render",
context_briefing={},
)
t, rejection = await self._render_active_video_task(agent_id)
if rejection is not None:
return rejection
renderer_rej = (
None
if settings.video_renderer_base_url.strip()
else Envelope.invalid_state(
message="the video-renderer sidecar is not configured",
remediate="ask the CEO to set ROBOCO_VIDEO_RENDERER_BASE_URL",
context_briefing={},
)
)
resolved_id, rej_id = self._render_resolve_composition_id(t, composition_id)
rej_params = self._render_validate_params(orientation, frame_count)
rejection = renderer_rej or rej_id or rej_params
if rejection is not None:
return rejection
resolved_props = self._render_resolve_input_props(t, input_props)
from roboco.agents_config import _resolve_to_slug
agent_slug = _resolve_to_slug(str(agent_id))
resolved, rejection = await self._render_resolve_project_and_source(
agent_id, agent_slug, t
)
if rejection is not None:
return rejection
project, source = resolved
return await self._render_execute(
task=t,
project=project,
agent_slug=agent_slug,
resolved_id=resolved_id,
resolved_props=resolved_props,
orientation=orientation,
frame_count=frame_count,
source=source,
)
# =========================================================================
# Wave 1 — pre-gateway parity restoration
# =========================================================================
+10 -1
View File
@@ -26,6 +26,7 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
"parent_context",
"description",
"collision_context",
"video_context",
)
@@ -64,6 +65,10 @@ class EvidencePayload:
# declared-vs-actual drift. Empty for a root or a task with no colliding
# siblings; the block is omitted when empty (zero token cost).
collision_context: list[dict[str, Any]] = field(default_factory=list)
# QA-facing artifact context for a video-authoring task (composition_id +
# the latest request_render preview + a verification instruction). None
# for every non-video task; omitted from the dict when empty.
video_context: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
data = asdict(self)
@@ -167,6 +172,7 @@ def build_evidence_for_task(
prior_findings: list[Any] | None = None,
parent_context: list[dict[str, Any]] | None = None,
collision_context: list[dict[str, Any]] | None = None,
video_context: dict[str, Any] | None = None,
) -> EvidencePayload:
"""Compose an EvidencePayload from a Task model + supplemental data.
@@ -177,7 +183,9 @@ def build_evidence_for_task(
reads the intake's original analysis verbatim. ``collision_context`` is
the prebuilt collision-map block (the caller fetches siblings + actual
files and runs the pure ``build_collision_context``); passed through
verbatim so this module stays DB-free.
verbatim so this module stays DB-free. ``video_context`` is the
prebuilt video-artifact block (caller-assembled from the task's
``video_draft`` marker + render preview); passed through verbatim.
"""
return EvidencePayload(
pr_number=task.pr_number,
@@ -194,6 +202,7 @@ def build_evidence_for_task(
revision_findings=render_findings(revision_findings),
prior_findings=render_findings(prior_findings),
collision_context=list(collision_context or []),
video_context=video_context,
)
+8
View File
@@ -101,6 +101,14 @@ def hint_for_short_pr_reviewer_notes(*, min_chars: int) -> str:
)
def hint_for_render_preview() -> str:
return (
"call request_render() to render this task's PR branch, Read every "
"returned frame image, and verify each scene/feature from the brief "
"appears fully and legibly — then retry i_am_done"
)
def hint_for_short_quick_context(*, min_chars: int, task_id: str) -> str:
return (
f"your quick_context section is empty or under {min_chars} chars. Before "
+7
View File
@@ -59,6 +59,10 @@ _DEV_DO = (
# On-demand sandbox DB/Redis/Mongo — carried unconditionally (declarative
# manifest), gated for real by request_sandbox's project opt-in check.
"request_sandbox",
# Render the video composition to preview frames (dev's own working
# tree); manifest carries it unconditionally, gated for real by
# request_render's active-video-task + flag checks.
"request_render",
*_NOTIFY_RECEIVER,
)
@@ -69,6 +73,9 @@ _QA_DO = (
"evidence",
"draft_playbook",
"request_sandbox",
# QA's render source is a read-only branch export, never a working tree
# (see request_render/_render_qa_source); gated the same way as above.
"request_render",
*_NOTIFY_RECEIVER,
)
+4 -2
View File
@@ -642,8 +642,10 @@ X_SOURCES = (X_POST_SOURCE, X_REPLY_SOURCE, X_FEATURE_SOURCE)
# Source tag for a video-authoring task: the VideoEngine assigns this to a
# UX/UI dev to build a HyperFrames composition. Unlike X_SOURCES above it IS
# dispatched — a normal, pre-assigned delivery task like any other cell code
# task — so it stays out of every held-source skip bucket.
VIDEO_SOURCE = "video"
# task — so it stays out of every held-source skip bucket. Canonical string
# lives in foundation (markers.VIDEO_TASK_SOURCE) so the tracing gate can
# key on it without importing this layer.
VIDEO_SOURCE = markers.VIDEO_TASK_SOURCE
# Source tag for a held video-post draft: mp4s + captions ready for CEO
# approval. Like release_manager/X_SOURCES this is NEVER dispatched — held
+95 -1
View File
@@ -49,6 +49,8 @@ _AUTHORING_ACCEPTANCE_CRITERIA = [
"Captions within platform limits",
"Composition follows motion/README.md's design bar and uses the "
"panel-demo kit register where the occasion shows the product",
"request_render preview frames verified — every scene in the brief "
"appears fully and legibly in the rendered cut",
]
_POST_ACCEPTANCE_CRITERIA = ["CEO approves or rejects the draft"]
@@ -64,7 +66,8 @@ _MOTION_DESIGN_POINTER = (
"Before authoring: read motion/README.md's design bar and motion/kit/"
"README.md. Build in the panel-demo register on motion/kit/ — extend "
"compositions/panel-demo/ rather than starting from scratch or shipping "
"a text card."
"a text card. Before submitting: call request_render and read every "
"returned frame to verify the RENDERED cut, not just the source."
)
@@ -143,6 +146,26 @@ def _release_video_brief(version: str, changelog: str, highlights: list[str]) ->
return "\n\n".join(parts)
def _reauthor_brief(reason: str, draft: dict[str, Any]) -> str:
"""The revision brief for a CEO-rejected cut: the verbatim rejection
feedback, a revise-in-place pointer at the existing composition (when
known), then the original brief/script for context."""
parts = [
"REVISION of a CEO-rejected cut. CEO rejection feedback "
f"(address every point): {reason}"
]
composition_id = draft.get("composition_id")
if composition_id:
parts.append(
f"Revise the EXISTING composition motion/compositions/{composition_id}/ "
"in place — do not start a new composition."
)
original = draft.get("brief") or draft.get("script") or ""
if original:
parts.append(original)
return "\n\n".join(parts)
class VideoEngine(BaseService):
"""Open video-authoring tasks (event hooks + on-demand), both gated."""
@@ -448,6 +471,77 @@ class VideoEngine(BaseService):
)
return task
# ---- reject -> re-author (CEO feedback loop) ---------------------------
async def _resolve_reauthor_project(
self, post_task: TaskTable, draft: dict[str, Any]
) -> UUID | None:
"""The project to re-author against: the rejected post's own
``project_id`` (the normal case), else its source authoring task's
(via the draft's ``source_task_id``) — a defensive fallback for a
draft that somehow landed without one."""
if post_task.project_id is not None:
return cast("UUID", post_task.project_id)
source_task_id = draft.get("source_task_id")
if not source_task_id:
return None
source_task = await get_task_service(self.session).get(
cast("UUID", source_task_id)
)
return cast("UUID", source_task.project_id) if source_task else None
async def reauthor_from_rejection(
self, post_task: TaskTable, reason: str
) -> TaskTable | None:
"""Route a CEO's rejection reason into a fresh authoring task that
revises the SAME composition in place, instead of the feedback going
nowhere.
Reads the rejected ``video_post`` draft's carried-forward
``video_draft`` marker (occasion, brief/script, composition_id,
platforms, input_props, source_task_id) and re-opens via
``open_video_task`` under the SAME occasion that call's own dedup
only scans OPEN drafts, so the just-cancelled post never blocks it (a
second reject while a revision is already open correctly dedups
against it instead of stacking a third).
Best-effort: never raises. A missing draft marker, an unresolvable
project, or any other failure just logs a warning and returns None
the caller's reject must succeed regardless of this seam.
"""
try:
draft = markers.get_video_draft(post_task)
if draft is None:
self.log.warning(
"video-engine: reauthor skipped, no video_draft marker",
task_id=str(post_task.id),
)
return None
project_id = await self._resolve_reauthor_project(post_task, draft)
if project_id is None:
self.log.warning(
"video-engine: reauthor skipped, no project resolvable",
task_id=str(post_task.id),
)
return None
return await self.open_video_task(
occasion=str(draft.get("occasion") or post_task.title),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
brief=_reauthor_brief(reason, draft),
suggested_input_props=(
draft.get("input_props") or draft.get("suggested_input_props")
),
project_id=project_id,
)
except Exception as exc:
self.log.warning(
"video-engine: reauthor from rejection failed",
task_id=str(post_task.id),
error=str(exc),
)
return None
# ---- re-render (CEO-triggered retry) -----------------------------------
async def rerender(self, task_id: UUID) -> TaskTable | None:
+26 -2
View File
@@ -488,7 +488,9 @@ class VideoPostService(BaseService):
)
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
"""Record the CEO's reason and cancel the draft (never posted).
"""Record the CEO's reason, cancel the draft (never posted), and
route the feedback into a fresh authoring task that revises the same
composition.
Acquires the same post-mutex ``approve()`` holds (same key, same
non-blocking acquire style) so a reject can't interleave with a
@@ -534,9 +536,31 @@ class VideoPostService(BaseService):
markers.set_video_reject_reason(locked, reason)
locked.status = TaskStatus.CANCELLED
await self.session.flush()
return locked
cancelled = locked
finally:
await mutex.release(token)
# Outside the lock/try-finally, after the cancel is committed/flushed:
# a reauthor failure must never fail or roll back the reject above.
if reason.strip():
await self._reauthor_after_reject(cancelled, reason)
return cancelled
async def _reauthor_after_reject(self, task: TaskTable, reason: str) -> None:
"""Best-effort: hand the CEO's reject reason to VideoEngine so it
opens a revision authoring task. Never raises."""
# Local import: no cycle (video_engine doesn't import this module),
# but mirrors the lazy get_video_engine import every other caller
# (release_proposal.py, x_post_service.py) uses.
from roboco.services.video_engine import get_video_engine
try:
await get_video_engine(self.session).reauthor_from_rejection(task, reason)
except Exception as exc:
logger.warning(
"video-post reauthor-from-rejection failed for task %s: %s",
task.id,
exc,
)
def get_video_post_service(
+50 -11
View File
@@ -76,7 +76,7 @@ class VideoRenderer:
"video-renderer sidecar not configured (video_renderer_base_url unset)"
)
tar_bytes = await asyncio.to_thread(self._tar_source, source_dir)
mp4_bytes = await self._post(
response = await self._post(
tar_bytes,
composition_id=composition_id,
input_props=input_props,
@@ -84,11 +84,45 @@ class VideoRenderer:
)
return await asyncio.to_thread(
self._save,
mp4_bytes,
response.content,
render_key=render_key,
orientation=orientation,
)
async def render_frames(
self,
source_dir: str,
*,
composition_id: str,
input_props: dict[str, Any],
orientation: str,
frame_count: int,
) -> tuple[bytes, float]:
"""Render one cut and extract ``frame_count`` preview keyframes.
Returns the frames tar.gz bytes plus the sidecar-probed real render
duration (parsed from the ``X-Video-Duration`` response header, or
``0.0`` when absent/unparsable never a save, never MinIO: this is
the artifact-verification surface, not the publish path).
"""
if not self._base_url:
raise VideoRendererError(
"video-renderer sidecar not configured (video_renderer_base_url unset)"
)
tar_bytes = await asyncio.to_thread(self._tar_source, source_dir)
response = await self._post(
tar_bytes,
composition_id=composition_id,
input_props=input_props,
orientation=orientation,
frames=frame_count,
)
try:
duration = float(response.headers.get("X-Video-Duration", ""))
except ValueError:
duration = 0.0
return response.content, duration
@staticmethod
def _tar_source(source_dir: str) -> bytes:
"""Tar ``source_dir`` (the motion/ package) into an in-memory gzip archive."""
@@ -109,26 +143,31 @@ class VideoRenderer:
composition_id: str,
input_props: dict[str, Any],
orientation: str,
) -> bytes:
"""POST the tarball + render params; return the MP4 response body.
frames: int | None = None,
) -> httpx.Response:
"""POST the tarball + render params; return the raw response.
Timeout is split: connect/write/pool use the short request timeout
(sending the tar), while `read` gets the long render timeout (the
sidecar renders before it writes the response body).
sidecar renders before it writes the response body). ``frames`` set
switches the sidecar to the frames-tar branch instead of the MP4.
"""
timeout = httpx.Timeout(
settings.video_request_timeout_seconds,
read=settings.video_render_timeout_seconds,
)
data = {
"composition_id": composition_id,
"orientation": orientation,
"input_props": json.dumps(input_props),
}
if frames is not None:
data["frames"] = str(frames)
client = await self._http(timeout)
try:
response = await client.post(
f"{self._base_url}/render",
data={
"composition_id": composition_id,
"orientation": orientation,
"input_props": json.dumps(input_props),
},
data=data,
files={"source": ("motion.tar.gz", tar_bytes, "application/gzip")},
timeout=timeout,
)
@@ -138,7 +177,7 @@ class VideoRenderer:
raise VideoRendererError(
f"render failed: HTTP {response.status_code}: {response.text[:200]}"
)
return response.content
return response
@staticmethod
def _save(mp4_bytes: bytes, *, render_key: str, orientation: str) -> str:
+74
View File
@@ -20,6 +20,7 @@ Example:
import asyncio
import contextlib
import io
import json
import math
import os
@@ -27,6 +28,7 @@ import re
import shlex
import shutil
import subprocess
import tarfile
import tempfile
import time
from collections.abc import Iterator
@@ -1966,6 +1968,78 @@ class WorkspaceService:
await asyncio.to_thread(_ensure_agent_owned, workspace)
return workspace
async def export_branch_motion(self, project: Any, branch: str) -> Path:
"""Export ``branch``'s ``motion/`` subtree into a fresh scratch dir —
request_render's QA source. Read-only by construction: fetches
``origin/<branch>`` into the project's shared READ CLONE (never a
per-agent working tree) and ``git archive``s the commit directly
no checkout, so the read clone's own pinned HEAD is undisturbed for
every other conventions-standard reader of that same clone. Caller
owns cleanup of the returned scratch dir.
Raises WorkspaceError only when the branch itself can't be fetched
(bad ref, auth failure, network). A branch that fetches fine but has
no ``motion/`` at that commit yields an EMPTY scratch dir the
caller's ``motion/compositions/<id>/`` existence check handles that
uniformly alongside the ordinary "missing composition" case, rather
than a second distinct error path here.
"""
from roboco.services.project import get_project_service
project_service = get_project_service(self.session)
workspace = await self.ensure_read_clone(project.slug)
git_token = await self._read_clone_token(project_service, project.slug)
prefix: list[str] = []
if git_token:
import base64
basic = base64.b64encode(f"x-access-token:{git_token}".encode()).decode()
prefix = ["-c", f"http.extraheader=Authorization: Basic {basic}"]
def _git(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *prefix, *args],
cwd=str(workspace),
capture_output=True,
text=True,
check=False,
)
def _export() -> Path:
fetched = _git("fetch", "--no-tags", "origin", branch)
if fetched.returncode != 0:
raise WorkspaceError(
f"export_branch_motion: fetch of {branch!r} failed: "
f"{fetched.stderr.strip()}"
)
sha = _git("rev-parse", "FETCH_HEAD").stdout.strip()
scratch = Path(tempfile.mkdtemp(prefix="render-src-"))
if not sha:
return scratch
archive = subprocess.run(
["git", "archive", sha, "--", "motion"],
cwd=str(workspace),
capture_output=True,
check=False,
)
if archive.returncode != 0:
# Most commonly: no motion/ tree at this commit. Leave the
# scratch dir empty so the caller's composition-path check
# reports the clean "missing composition" rejection instead
# of a second distinct error here.
logger.warning(
"export_branch_motion: git archive found no motion/ tree",
branch=branch,
stderr=archive.stderr.decode(errors="replace").strip()[:200],
)
return scratch
with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar:
tar.extractall(scratch, filter="data")
return scratch
return await asyncio.to_thread(_export)
async def delete_workspace(
self,
project_slug: str,
+1
View File
@@ -35,6 +35,7 @@ def test_requirement_enum_has_canonical_values() -> None:
"pr_reviewer_notes>=min",
"quick_context>=min",
"findings_addressed",
"render_verified",
}
actual = {r.value for r in tracing.Requirement}
assert actual == expected, f"Requirement drift: {actual ^ expected}"
@@ -0,0 +1,58 @@
"""The RENDER_VERIFIED tracing requirement — i_am_done's video-artifact gate.
Pure unit tests against ``foundation.policy.tracing`` (no DB, no
choreographer): the checker itself, its registration in
``VERB_REQUIREMENTS["i_am_done"]``, and the "non-video task is untouched"
contract.
"""
from __future__ import annotations
from types import SimpleNamespace
from roboco.foundation.policy import tracing as tr
from roboco.foundation.policy.content import markers
def test_render_verified_is_required_by_i_am_done() -> None:
assert tr.Requirement.RENDER_VERIFIED in tr.VERB_REQUIREMENTS["i_am_done"]
def test_render_verified_not_required_by_submit_up_or_submit_root() -> None:
assert tr.Requirement.RENDER_VERIFIED not in tr.VERB_REQUIREMENTS["submit_up"]
assert tr.Requirement.RENDER_VERIFIED not in tr.VERB_REQUIREMENTS["submit_root"]
def test_non_video_task_passes_regardless_of_marker() -> None:
task = SimpleNamespace(source="code", orchestration_markers=None)
assert tr._check_render_verified(task, tr.GateContext()) == []
def test_video_task_without_preview_fails() -> None:
task = SimpleNamespace(source=markers.VIDEO_TASK_SOURCE, orchestration_markers=None)
assert tr._check_render_verified(task, tr.GateContext()) == ["render_preview"]
def test_video_task_with_preview_passes() -> None:
task = SimpleNamespace(
source=markers.VIDEO_TASK_SOURCE,
orchestration_markers={markers.RENDER_PREVIEW: {"frames": ["a.png"]}},
)
assert tr._check_render_verified(task, tr.GateContext()) == []
def test_i_am_done_requirements_include_the_pre_existing_set_too() -> None:
"""Adding RENDER_VERIFIED must not have dropped any prior requirement."""
required = tr.VERB_REQUIREMENTS["i_am_done"]
for expected in (
tr.Requirement.COMMITS_AT_LEAST_ONE,
tr.Requirement.PR_OPEN,
tr.Requirement.PROGRESS_AT_LEAST_ONE,
tr.Requirement.SELF_VERIFIED,
tr.Requirement.JOURNAL_REFLECT,
tr.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE,
tr.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED,
tr.Requirement.DEV_NOTES_MIN_CHARS,
tr.Requirement.FINDINGS_ADDRESSED,
):
assert expected in required
@@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.envelope import Envelope
@@ -61,6 +62,8 @@ def _t(
t.pr_number = 12345 if pr_created else None
t.acceptance_criteria = list(criteria)
t.acceptance_criteria_status = ac_status
t.source = "code"
t.orchestration_markers = None
return t
@@ -334,6 +337,44 @@ async def test_fast_path_open_findings_blocks(monkeypatch: pytest.MonkeyPatch) -
c.task.submit_qa.assert_not_awaited()
@pytest.mark.asyncio
async def test_fast_path_video_task_without_render_preview_rejects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A video-authoring task with no request_render preview must not be
able to fast-path around looking at the rendered artifact."""
c = Choreographer(_deps())
stubs = _stub_fast_path(c, monkeypatch)
tracing_gap = AsyncMock(
return_value=Envelope.tracing_gap(missing=["render_preview"], remediate="x")
)
monkeypatch.setattr(c, "_build_tracing_gap", tracing_gap)
ctx = _ctx()
ctx.task = _t()
ctx.task.source = markers.VIDEO_TASK_SOURCE
await c._i_am_done_fast_path(ctx)
stubs.reject.assert_awaited_once()
c.task.submit_qa.assert_not_awaited()
tracing_gap.assert_awaited_once_with(
ctx.agent_id, ctx.task_id, ["render_preview"], task=ctx.task
)
@pytest.mark.asyncio
async def test_fast_path_video_task_with_render_preview_passes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
c = Choreographer(_deps())
stubs = _stub_fast_path(c, monkeypatch)
ctx = _ctx()
ctx.task = _t()
ctx.task.source = markers.VIDEO_TASK_SOURCE
ctx.task.orchestration_markers = {markers.RENDER_PREVIEW: {"frames": ["a.png"]}}
await c._i_am_done_fast_path(ctx)
stubs.ok.assert_awaited_once()
c.task.submit_qa.assert_awaited_once()
@pytest.mark.asyncio
async def test_fast_path_conventions_block_rejects(
monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,485 @@
"""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
@@ -64,6 +64,7 @@ def _build_choreographer() -> Choreographer:
"commits>=1",
"pr_open",
"self_verified",
"render_preview",
],
)
def test_hint_registered_for_previously_unhinted_token(token: str) -> None:
@@ -93,6 +94,13 @@ def test_during_work_hint_warns_reflect_doesnt_count() -> None:
), f"during_work hint must warn that scope='reflect' doesn't satisfy this: {hint!r}"
def test_render_preview_hint_mentions_request_render() -> None:
"""The render_preview hint must name the exact next call (request_render)."""
hint = Choreographer._hint_for_missing_key("render_preview", uuid4())
assert hint is not None
assert "request_render" in hint
# ---------------------------------------------------------------------------
# Multi-hint remediate — numbered list, not semicolon-joined
# ---------------------------------------------------------------------------
@@ -0,0 +1,67 @@
"""QA claim_review evidence carries a video-artifact context for
video-source tasks pointing QA at the rendered artifact, not just source."""
from __future__ import annotations
from unittest.mock import MagicMock
from roboco.foundation.policy.content import markers
from roboco.services.gateway.choreographer import Choreographer
from roboco.services.gateway.evidence_builder import build_evidence_for_task
def _stub_task() -> MagicMock:
task = MagicMock()
task.pr_number = None
task.pr_url = None
task.commits = []
task.dev_notes = None
task.acceptance_criteria_status = []
task.source = "code"
task.orchestration_markers = None
return task
def test_video_context_none_for_non_video_task() -> None:
assert Choreographer._qa_video_context(_stub_task()) is None
def test_video_context_present_for_video_task_with_render_preview() -> None:
task = _stub_task()
task.source = markers.VIDEO_TASK_SOURCE
task.orchestration_markers = {
markers.VIDEO_DRAFT: {"composition_id": "intro-v1"},
markers.RENDER_PREVIEW: {"frames": ["a.png", "b.png"]},
}
ctx = Choreographer._qa_video_context(task)
assert ctx is not None
assert ctx["composition_id"] == "intro-v1"
assert ctx["render_preview"] == {"frames": ["a.png", "b.png"]}
assert "request_render" in ctx["note"]
def test_video_context_render_preview_none_without_marker() -> None:
task = _stub_task()
task.source = markers.VIDEO_TASK_SOURCE
task.orchestration_markers = {markers.VIDEO_DRAFT: {"composition_id": "intro-v1"}}
ctx = Choreographer._qa_video_context(task)
assert ctx is not None
assert ctx["composition_id"] == "intro-v1"
assert ctx["render_preview"] is None
def test_evidence_payload_includes_video_context() -> None:
video_context = {"composition_id": "x", "render_preview": None, "note": "n"}
ev = build_evidence_for_task(
_stub_task(),
journal_highlights=[],
files_changed=[],
video_context=video_context,
)
assert ev.as_dict()["video_context"] == video_context
def test_evidence_payload_video_context_default_absent() -> None:
ev = build_evidence_for_task(_stub_task(), journal_highlights=[], files_changed=[])
assert ev.video_context is None
assert "video_context" not in ev.as_dict()
@@ -0,0 +1,55 @@
"""Video-authoring dev prompt block: the request_render/propose_video
verification instructions appear only for ``source=VIDEO_SOURCE`` tasks,
never for anything else mirrors test_possibilities_matrix_prompt.py's
bare-instance ``_build_dev_prompt`` idiom.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import VIDEO_SOURCE
def _orch() -> AgentOrchestrator:
orch = object.__new__(AgentOrchestrator)
orch._instances = {}
return orch
def _task(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": str(uuid4()),
"title": "Video: release v1.0.0",
"status": "claimed",
"plan": None,
}
base.update(over)
return base
@pytest.mark.asyncio
async def test_video_source_prompt_includes_request_render_instruction() -> None:
prompt = await _orch()._build_dev_prompt(_task(source=VIDEO_SOURCE))
assert "request_render" in prompt
assert "propose_video" in prompt
assert "render preview" in prompt
@pytest.mark.asyncio
async def test_non_video_source_prompt_omits_request_render_instruction() -> None:
prompt = await _orch()._build_dev_prompt(_task(source="code"))
assert "request_render" not in prompt
@pytest.mark.asyncio
async def test_missing_source_prompt_omits_request_render_instruction() -> None:
prompt = await _orch()._build_dev_prompt(_task())
assert "request_render" not in prompt
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+80 -2
View File
@@ -35,6 +35,7 @@ SLUG = "roboco"
ONE = 1
TWO = 2
THREE = 3
FOUR = 4
async def _seed(session: AsyncSession) -> None:
@@ -138,9 +139,11 @@ async def test_open_video_task_creates_assigned_authoring_task(
assert task.estimated_complexity == Complexity.LOW
assert task.acceptance_criteria # non-empty
# Third AC line: composition follows the design bar / demo-kit register.
assert len(task.acceptance_criteria) == THREE
assert len(task.acceptance_criteria) == FOUR
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
# Fourth AC line: request_render preview frames must be verified.
assert "request_render" in task.acceptance_criteria[3]
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
assert project.slug == SLUG
@@ -154,6 +157,7 @@ async def test_open_video_task_creates_assigned_authoring_task(
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "request_render" in draft["brief"] # verify-the-render pointer
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert draft["suggested_input_props"] == {} # none supplied
assert task.description == draft["brief"]
@@ -715,9 +719,10 @@ async def test_open_video_task_acceptance_criteria_has_design_bar_line(
occasion="release v9.9.9", script="s", platforms=["x"], brief="b"
)
assert task is not None
assert len(task.acceptance_criteria) == THREE
assert len(task.acceptance_criteria) == FOUR
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
assert "request_render" in task.acceptance_criteria[3]
@pytest.mark.asyncio
@@ -818,3 +823,76 @@ async def test_rerender_none_for_missing_task(db_session: AsyncSession) -> None:
assert result is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert open_tasks == []
# --------------------------------------------------------------------------- #
# reauthor_from_rejection — CEO reject feedback loop
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_reauthor_from_rejection_opens_revision_with_reason_and_pointer(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
source_task = await engine.open_video_task(
occasion="release v1.0.0",
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert source_task is not None
draft = markers.get_video_draft(source_task) or {}
markers.set_video_draft(source_task, {**draft, "composition_id": "ReleaseIntro"})
# Mirrors production: the render loop only fires once the authoring task
# has gone through the full delivery lifecycle to COMPLETED — an open
# source task with the same occasion would otherwise dedupe the revision
# reauthor is about to open right back against itself.
source_task.status = TS.COMPLETED
await db_session.flush()
post_task = await engine._originate_video_post(
source_task=source_task,
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
captions={"x": "cap", "tiktok": "cap2"},
platforms=["x", "tiktok"],
)
# Mirrors VideoPostService.reject: the draft is already CANCELLED by the
# time reauthor_from_rejection runs, so open_video_task's own-occasion
# dedup (which only scans OPEN drafts) doesn't block against it.
post_task.status = TS.CANCELLED
await db_session.flush()
revision = await engine.reauthor_from_rejection(
post_task, "Logo is cut off in the second scene"
)
assert revision is not None
assert revision.id != post_task.id
assert revision.source == VIDEO_SOURCE
revision_draft = markers.get_video_draft(revision)
assert revision_draft is not None
assert revision_draft["occasion"] == "release v1.0.0" # SAME occasion
assert revision_draft["platforms"] == ["x", "tiktok"]
assert "Logo is cut off in the second scene" in revision_draft["brief"]
assert "motion/compositions/ReleaseIntro/" in revision_draft["brief"]
assert "do not start a new composition" in revision_draft["brief"]
@pytest.mark.asyncio
async def test_reauthor_from_rejection_missing_draft_returns_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="no-draft", script="s", platforms=["x"], brief="b"
)
assert task is not None
task.orchestration_markers = {} # strip the video_draft marker
await db_session.flush()
result = await engine.reauthor_from_rejection(task, "some reason")
assert result is None
@@ -509,6 +509,80 @@ async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> No
assert markers.get_video_reject_reason(updated) == "Doesn't match the release"
@pytest.mark.asyncio
async def test_reject_with_reason_calls_reauthor_with_cancelled_task(
db_session: AsyncSession,
) -> None:
"""A non-blank reject reason routes into VideoEngine.reauthor_from_rejection,
called with the just-cancelled task and the verbatim reason."""
task = await _seed_video_post(db_session)
fake_engine = MagicMock()
fake_engine.reauthor_from_rejection = AsyncMock(return_value=None)
with (
_LOCKED[0],
_LOCKED[1],
patch(
"roboco.services.video_engine.get_video_engine",
return_value=fake_engine,
),
):
updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert updated is not None
assert updated.status == TS.CANCELLED
fake_engine.reauthor_from_rejection.assert_awaited_once()
called_task, called_reason = fake_engine.reauthor_from_rejection.await_args.args
assert called_task.id == task.id
assert called_reason == "Doesn't match the release"
@pytest.mark.asyncio
async def test_reject_succeeds_even_when_reauthor_raises(
db_session: AsyncSession,
) -> None:
"""A reauthor failure must never fail or roll back the reject — the
cancel already committed before this best-effort seam runs."""
task = await _seed_video_post(db_session)
fake_engine = MagicMock()
fake_engine.reauthor_from_rejection = AsyncMock(side_effect=RuntimeError("boom"))
with (
_LOCKED[0],
_LOCKED[1],
patch(
"roboco.services.video_engine.get_video_engine",
return_value=fake_engine,
),
):
updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert updated is not None
assert updated.status == TS.CANCELLED
assert markers.get_video_reject_reason(updated) == "Doesn't match the release"
@pytest.mark.asyncio
async def test_reject_blank_reason_skips_reauthor(db_session: AsyncSession) -> None:
task = await _seed_video_post(db_session)
fake_engine = MagicMock()
fake_engine.reauthor_from_rejection = AsyncMock()
with (
_LOCKED[0],
_LOCKED[1],
patch(
"roboco.services.video_engine.get_video_engine",
return_value=fake_engine,
),
):
updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), " ")
assert updated is not None
assert updated.status == TS.CANCELLED
fake_engine.reauthor_from_rejection.assert_not_awaited()
@pytest.mark.asyncio
async def test_reject_takes_the_same_lock_approve_holds(
db_session: AsyncSession,
@@ -152,6 +152,130 @@ async def test_null_renderer_raises_without_network_call(tmp_path: Path) -> None
)
@pytest.mark.asyncio
async def test_render_frames_posts_frames_field_and_parses_duration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_source(tmp_path)
monkeypatch.setattr(cfg, "video_request_timeout_seconds", 5.0)
monkeypatch.setattr(cfg, "video_render_timeout_seconds", 30.0)
captured: dict[str, bytes] = {}
expected_duration = 12.5
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = request.content
return httpx.Response(
200,
content=b"fake-frames-tar-gz",
headers={"X-Video-Duration": str(expected_duration)},
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = VideoRenderer(base_url="http://fake-video-renderer", client=http_client)
tar_bytes, duration = await renderer.render_frames(
str(source),
composition_id="Intro",
input_props={"title": "hello"},
orientation="vertical",
frame_count=8,
)
await http_client.aclose()
assert tar_bytes == b"fake-frames-tar-gz"
assert duration == expected_duration
body = captured["body"]
assert isinstance(body, bytes)
assert b'name="frames"' in body
assert b"8" in body
@pytest.mark.asyncio
async def test_render_frames_missing_duration_header_returns_zero(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_source(tmp_path)
monkeypatch.setattr(cfg, "video_request_timeout_seconds", 5.0)
monkeypatch.setattr(cfg, "video_render_timeout_seconds", 30.0)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"fake-frames-tar-gz")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = VideoRenderer(base_url="http://fake-video-renderer", client=http_client)
tar_bytes, duration = await renderer.render_frames(
str(source),
composition_id="Intro",
input_props={},
orientation="square",
frame_count=4,
)
await http_client.aclose()
assert tar_bytes == b"fake-frames-tar-gz"
assert duration == 0.0
@pytest.mark.asyncio
async def test_render_frames_non_success_response_raises_clear_error(
tmp_path: Path,
) -> None:
source = _make_source(tmp_path)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(400, text="frames out of bounds")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = VideoRenderer(base_url="http://fake-video-renderer", client=http_client)
with pytest.raises(VideoRendererError, match="400"):
await renderer.render_frames(
str(source),
composition_id="Intro",
input_props={},
orientation="square",
frame_count=4,
)
await http_client.aclose()
@pytest.mark.asyncio
async def test_render_frames_unconfigured_renderer_raises_without_network_call(
tmp_path: Path,
) -> None:
source = _make_source(tmp_path)
renderer = VideoRenderer(base_url="")
with pytest.raises(VideoRendererError, match="not configured"):
await renderer.render_frames(
str(source),
composition_id="Intro",
input_props={},
orientation="vertical",
frame_count=4,
)
@pytest.mark.asyncio
async def test_render_frames_null_renderer_raises_without_network_call(
tmp_path: Path,
) -> None:
source = _make_source(tmp_path)
renderer = NullVideoRenderer()
with pytest.raises(VideoRendererError, match="not configured"):
await renderer.render_frames(
str(source),
composition_id="Intro",
input_props={},
orientation="vertical",
frame_count=4,
)
def test_get_video_renderer_returns_null_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+85
View File
@@ -8,13 +8,17 @@
// render call produces its own temp file the caller cleans up once it has
// streamed the response.
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { promisify } from "node:util";
import * as tar from "tar";
const execFileP = promisify(execFile);
// @hyperframes/producer reads each cut's dimensions from the composition HTML
// itself (data-width/data-height on the stage), so the sidecar no longer
// passes width/height — it only picks the quality tier.
@@ -247,4 +251,85 @@ export async function renderComposition({
}
throw err;
}
}
export const MAX_PREVIEW_FRAMES = 32;
// Downscaled so agents reading the frames as images get a small file that
// still keeps on-screen copy legible (720 wide ≈ half of a 1080 cut).
const PREVIEW_FRAME_WIDTH = 720;
/**
* Render one cut and extract `frameCount` evenly spaced keyframes from it
* the preview surface behind the fleet's `request_render` verb, so an agent
* can verify the actual artifact instead of the composition source. Samples
* scene MIDPOINTS (duration * (i + 0.5) / N), never t=0 or t=duration, so a
* fade-in first frame or an EOF seek can't produce a blank/missing frame.
* Returns the frames tarball path, the probed real duration, and a cleanup
* callback the caller MUST invoke after streaming.
*/
export async function renderFrames({
tarBuffer,
compositionId,
inputProps,
orientation,
frameCount,
}) {
const { outputLocation, cleanup: cleanupRender } = await renderComposition({
tarBuffer,
compositionId,
inputProps,
orientation,
});
let framesDir;
try {
// ffprobe the RENDERED file rather than trusting the composition's
// data-duration attribute — the whole point is ground truth.
const { stdout } = await execFileP("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
outputLocation,
]);
const duration = Number(stdout.trim());
if (!Number.isFinite(duration) || duration <= 0) {
throw new Error("could not probe rendered video duration");
}
framesDir = await mkdtemp(path.join(tmpdir(), "hyperframes-frames-"));
const files = [];
for (let i = 0; i < frameCount; i++) {
const t = (duration * (i + 0.5)) / frameCount;
// Timestamp in the filename — self-describing, no manifest needed.
const name = `frame-${String(i + 1).padStart(2, "0")}-of-${frameCount}-at-${t.toFixed(1)}s.png`;
await execFileP("ffmpeg", [
"-v", "error",
"-ss", t.toFixed(3),
"-i", outputLocation,
"-frames:v", "1",
"-vf", `scale=${PREVIEW_FRAME_WIDTH}:-2`,
"-y",
path.join(framesDir, name),
]);
files.push(name);
}
const tarPath = path.join(framesDir, "frames.tar.gz");
await tar.create({ gzip: true, cwd: framesDir, file: tarPath }, files);
return {
tarPath,
duration,
cleanup: () =>
Promise.all([
cleanupRender(),
rm(framesDir, { recursive: true, force: true }).catch(() => {}),
]),
};
} catch (err) {
await cleanupRender();
if (framesDir) {
await rm(framesDir, { recursive: true, force: true }).catch(() => {});
}
throw err;
}
}
+90 -29
View File
@@ -10,9 +10,12 @@ import express from "express";
import multer from "multer";
import rateLimit from "express-rate-limit";
import { createReadStream } from "node:fs";
import { fileURLToPath } from "node:url";
import {
renderComposition,
renderFrames,
ExtractedSizeExceededError,
MAX_PREVIEW_FRAMES,
RenderTimeoutError,
UnknownCompositionError,
} from "./render.js";
@@ -58,6 +61,61 @@ app.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});
/**
* Validate the optional 'frames' form field. Absent/empty keeps the
* existing MP4 behavior (`count: null`); present must be an integer in
* 1..MAX_PREVIEW_FRAMES or `error` names the bound for the 400 response.
* Pure + exported so the branch is unit-testable without a live server.
*/
export function parseFramesField(raw) {
if (raw === undefined || raw === null || raw === "") return { count: null };
const count = Number(raw);
if (!Number.isInteger(count) || count < 1 || count > MAX_PREVIEW_FRAMES) {
return {
error: `'frames' must be an integer between 1 and ${MAX_PREVIEW_FRAMES}`,
};
}
return { count };
}
/**
* Stream `filePath` as the response body and run `cleanup` once the
* response is done shared by both the MP4 and the frames-tar branches of
* /render. See the inline comment below on why cleanup fires from both the
* source stream's own "close" and the response's "close" (client abort).
*/
function streamFileWithCleanup(res, filePath, { contentType, headers, cleanup }) {
res.status(200);
res.setHeader("Content-Type", contentType);
for (const [name, value] of Object.entries(headers ?? {})) {
res.setHeader(name, value);
}
const stream = createReadStream(filePath);
stream.on("error", (err) => {
console.error("video-renderer: stream error", err);
if (!res.headersSent) {
res.status(500);
}
res.end();
cleanup();
});
stream.on("close", () => {
cleanup();
});
// stream.pipe() never propagates a DESTINATION close back to the
// source: if the client aborts, or the orchestrator's retry-on-timeout
// hangs up mid-download, `res` closes but the source stream's own
// "close" above never fires — leaking this request's render-output
// temp dir on every such disconnect. Destroying the still-open source
// releases its fd immediately; cleanup() is idempotent (rm force:true)
// so also landing here on a normal end-of-stream close is harmless.
res.on("close", () => {
stream.destroy();
cleanup();
});
stream.pipe(res);
}
app.post("/render", renderLimiter, upload.single("source"), async (req, res) => {
const body = req.body ?? {};
const compositionId = body.composition_id;
@@ -96,40 +154,39 @@ app.post("/render", renderLimiter, upload.single("source"), async (req, res) =>
return;
}
const framesField = parseFramesField(body.frames);
if (framesField.error) {
res.status(400).json({ error: framesField.error });
return;
}
try {
if (framesField.count !== null) {
const { tarPath, duration, cleanup } = await renderFrames({
tarBuffer: req.file.buffer,
compositionId,
inputProps,
orientation,
frameCount: framesField.count,
});
streamFileWithCleanup(res, tarPath, {
contentType: "application/gzip",
headers: { "X-Video-Duration": String(duration) },
cleanup,
});
return;
}
const { outputLocation, cleanup } = await renderComposition({
tarBuffer: req.file.buffer,
compositionId,
inputProps,
orientation,
});
res.status(200);
res.setHeader("Content-Type", "video/mp4");
const stream = createReadStream(outputLocation);
stream.on("error", (err) => {
console.error("video-renderer: stream error", err);
if (!res.headersSent) {
res.status(500);
}
res.end();
cleanup();
streamFileWithCleanup(res, outputLocation, {
contentType: "video/mp4",
cleanup,
});
stream.on("close", () => {
cleanup();
});
// stream.pipe() never propagates a DESTINATION close back to the
// source: if the client aborts, or the orchestrator's retry-on-timeout
// hangs up mid-download, `res` closes but the source stream's own
// "close" above never fires — leaking this request's render-output
// temp dir on every such disconnect. Destroying the still-open source
// releases its fd immediately; cleanup() is idempotent (rm force:true)
// so also landing here on a normal end-of-stream close is harmless.
res.on("close", () => {
stream.destroy();
cleanup();
});
stream.pipe(res);
} catch (err) {
if (
err instanceof UnknownCompositionError ||
@@ -171,6 +228,10 @@ app.use((err, req, res, _next) => {
res.status(500).json({ error: "internal error" });
});
app.listen(PORT, () => {
console.log(`video-renderer listening on :${PORT}`);
});
// Guarded so a test can `import` this module (for parseFramesField) without
// also binding the real port — only `node server.js` triggers the listen.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
app.listen(PORT, () => {
console.log(`video-renderer listening on :${PORT}`);
});
}
+42
View File
@@ -0,0 +1,42 @@
// node --test: exercises the pure 'frames' form-field validation only —
// server.js's app.listen is guarded (see server.js) so importing it here
// never binds the real port.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseFramesField } from "./server.js";
import { MAX_PREVIEW_FRAMES } from "./render.js";
test("parseFramesField treats an absent field as the existing MP4 path", () => {
assert.deepEqual(parseFramesField(undefined), { count: null });
});
test("parseFramesField treats an empty string as the existing MP4 path", () => {
assert.deepEqual(parseFramesField(""), { count: null });
});
test("parseFramesField accepts an in-bounds integer string", () => {
assert.deepEqual(parseFramesField("8"), { count: 8 });
});
test("parseFramesField accepts the boundary values 1 and MAX_PREVIEW_FRAMES", () => {
assert.deepEqual(parseFramesField("1"), { count: 1 });
assert.deepEqual(parseFramesField(String(MAX_PREVIEW_FRAMES)), {
count: MAX_PREVIEW_FRAMES,
});
});
test("parseFramesField rejects zero", () => {
assert.ok(parseFramesField("0").error);
});
test("parseFramesField rejects above MAX_PREVIEW_FRAMES", () => {
assert.ok(parseFramesField(String(MAX_PREVIEW_FRAMES + 1)).error);
});
test("parseFramesField rejects a non-integer value", () => {
assert.ok(parseFramesField("4.5").error);
});
test("parseFramesField rejects a non-numeric value", () => {
assert.ok(parseFramesField("abc").error);
});