Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)

* feat(video): pipeline visibility — strip, state-aware queue, render-error capture

Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET
/video/pipeline lists every in-flight video item (authoring statuses,
rendering attempt n/max, terminal failures with the error — now stamped
onto the video_draft marker instead of dying as a log line).
source_task_id exposed on both video schemas. Panel: pipeline strip on
the Social page, state-aware queue empty copy, title/script on queue
rows, missing cuts disabled instead of a blank player, notifications
deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the
markers policy layer (single source of truth).

* feat(video): rich authoring briefs — changelog section, brand voice, kit pointer

Task 2 of the 2026-07-09 video-pipeline review. The release brief is
now a structured block (full CHANGELOG section capped at 4000 chars +
highlights) instead of one LLM-compressed sentence; brand_voice and a
motion/kit design-bar pointer are appended centrally in open_video_task
so release, spotlight, and on-demand paths all inherit them.
suggested_input_props seeded on the video_draft marker; third
acceptance criterion pins the design bar; propose_video docstring
points at the kit.

* fix(video): spotlight video drafts on CEO approval, renderer honors data-fps

Task 4 of the 2026-07-09 video-pipeline review. The companion-video
hook moves from propose_feature_spotlight (HoM authoring time) to
XPostService approve's posted-success branch for x_feature drafts,
mirroring the release-publish seam — a rejected spotlight no longer
burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref
marker. Best-effort: a video-engine failure never breaks the post.
render.js reads data-fps from the composition HTML (clamped 24-60,
fallback 30) instead of hardcoding 30; parseFps covered by node --test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 08:31:08 +02:00
committed by GitHub
co-authored by Renn F
parent 12b91d17a2
commit 18998c4a42
28 changed files with 1677 additions and 190 deletions
@@ -1,6 +1,7 @@
"use client";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import {
useNotifications,
@@ -106,6 +107,19 @@ function NotificationCard({
Needs Ack
</Badge>
)}
{notification.related_task_id && (
<Link
href={`/tasks/${notification.related_task_id}`}
prefetch={false}
>
<Badge
variant="outline"
className="text-xs hover:bg-muted cursor-pointer"
>
Task #{notification.related_task_id.slice(0, 8)}
</Badge>
</Link>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
<Markdown>{notification.body}</Markdown>
@@ -10,6 +10,9 @@ import type { VideoPostHistoryEntry } from "@/lib/api/video";
vi.mock("@/components/dashboard/x-post-queue", () => ({
XPostQueue: () => <div>XPostQueueStub</div>,
}));
vi.mock("@/components/dashboard/video-pipeline-strip", () => ({
VideoPipelineStrip: () => <div>VideoPipelineStripStub</div>,
}));
vi.mock("@/components/dashboard/video-post-queue", () => ({
VideoPostQueue: () => <div>VideoPostQueueStub</div>,
}));
@@ -82,6 +85,7 @@ describe("SocialPage", () => {
);
expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument();
expect(screen.getByText("XPostQueueStub")).toBeInTheDocument();
expect(screen.getByText("VideoPipelineStripStub")).toBeInTheDocument();
expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument();
});
@@ -1,6 +1,7 @@
"use client";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPipelineStrip } from "@/components/dashboard/video-pipeline-strip";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { SocialHistorySection } from "@/components/dashboard/social-history-section";
@@ -16,6 +17,7 @@ export default function SocialPage() {
</div>
<XPostQueue />
<VideoPipelineStrip />
<VideoPostQueue />
<SocialHistorySection />
</div>
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { VideoPipelineItem } from "@/lib/api/video";
const { listPipeline } = vi.hoisted(() => ({
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
}));
vi.mock("@/lib/api", () => ({
videoApi: { listPipeline },
}));
import { VideoPipelineStrip } from "../video-pipeline-strip";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
const AUTHORING: VideoPipelineItem = {
task_id: "vp-1",
title: "Video: launch teaser",
occasion: "launch",
status: "in_progress",
pr_number: null,
composition_id: null,
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
};
const AWAITING_APPROVAL: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-2",
status: "awaiting_ceo_approval",
};
const RENDERING: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-3",
status: "completed",
composition_id: "Intro",
render_attempts: 2,
};
const FAILED: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-4",
status: "completed",
composition_id: "Intro",
render_status: "failed",
render_attempts: 5,
render_error: "sidecar timeout",
};
describe("VideoPipelineStrip", () => {
beforeEach(() => {
listPipeline.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders nothing when the pipeline is empty", async () => {
render(withQueryClient(<VideoPipelineStrip />));
await waitFor(() => expect(listPipeline).toHaveBeenCalled());
expect(screen.queryByText("Video Pipeline")).not.toBeInTheDocument();
});
it("shows a stage chip per in-flight item, including the failure reason", async () => {
listPipeline.mockResolvedValueOnce([
AUTHORING,
AWAITING_APPROVAL,
RENDERING,
FAILED,
]);
render(withQueryClient(<VideoPipelineStrip />));
expect(await screen.findByText("Video Pipeline")).toBeInTheDocument();
expect(screen.getByText("Authoring")).toBeInTheDocument();
expect(screen.getByText("Awaiting your approval")).toBeInTheDocument();
expect(screen.getByText("Rendering (attempt 2/5)")).toBeInTheDocument();
expect(
screen.getByText("Render failed: sidecar timeout"),
).toBeInTheDocument();
});
it("deep-links only the awaiting-approval row to the task", async () => {
listPipeline.mockResolvedValueOnce([AUTHORING, AWAITING_APPROVAL]);
render(withQueryClient(<VideoPipelineStrip />));
await screen.findByText("Video Pipeline");
const reviewLinks = screen.getAllByRole("link", { name: "Review" });
expect(reviewLinks).toHaveLength(1);
expect(reviewLinks[0]).toHaveAttribute("href", "/tasks/vp-2");
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import {
derivePipelineStage,
pipelineStageLabel,
pipelineStageColor,
} from "../video-pipeline-utils";
const BASE = {
status: "in_progress",
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
};
describe("derivePipelineStage", () => {
it.each([
"backlog",
"pending",
"claimed",
"in_progress",
"blocked",
"paused",
"verifying",
"needs_revision",
])("maps %s to authoring", (status) => {
expect(derivePipelineStage({ ...BASE, status })).toEqual({
kind: "authoring",
});
});
it.each([
"awaiting_qa",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
])("maps %s to in_review", (status) => {
expect(derivePipelineStage({ ...BASE, status })).toEqual({
kind: "in_review",
});
});
it("maps awaiting_ceo_approval to awaiting_approval", () => {
expect(
derivePipelineStage({ ...BASE, status: "awaiting_ceo_approval" }),
).toEqual({ kind: "awaiting_approval" });
});
it("maps a completed task with no render_status to rendering, carrying attempts", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: null,
render_attempts: 2,
max_attempts: 5,
}),
).toEqual({ kind: "rendering", attempt: 2, maxAttempts: 5 });
});
it("maps a completed task with render_status='failed' to render_failed, carrying the reason", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: "failed",
render_error: "sidecar timeout",
}),
).toEqual({ kind: "render_failed", reason: "sidecar timeout" });
});
it("render_failed carries a null reason when the marker holds none", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: "failed",
render_error: null,
}),
).toEqual({ kind: "render_failed", reason: null });
});
});
describe("pipelineStageLabel", () => {
it("labels every stage kind", () => {
expect(pipelineStageLabel({ kind: "authoring" })).toBe("Authoring");
expect(pipelineStageLabel({ kind: "in_review" })).toBe("In review");
expect(pipelineStageLabel({ kind: "awaiting_approval" })).toBe(
"Awaiting your approval",
);
expect(
pipelineStageLabel({ kind: "rendering", attempt: 2, maxAttempts: 5 }),
).toBe("Rendering (attempt 2/5)");
expect(
pipelineStageLabel({ kind: "render_failed", reason: "boom" }),
).toBe("Render failed: boom");
expect(pipelineStageLabel({ kind: "render_failed", reason: null })).toBe(
"Render failed",
);
});
});
describe("pipelineStageColor", () => {
it("returns a distinct class per stage kind", () => {
const stages: Parameters<typeof pipelineStageColor>[0][] = [
{ kind: "authoring" },
{ kind: "in_review" },
{ kind: "awaiting_approval" },
{ kind: "rendering", attempt: 1, maxAttempts: 5 },
{ kind: "render_failed", reason: null },
];
const colors = stages.map(pipelineStageColor);
expect(new Set(colors).size).toBe(colors.length);
});
});
@@ -2,51 +2,68 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { VideoPost } from "@/lib/api/video";
import type { VideoPipelineItem, VideoPost } from "@/lib/api/video";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
const {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
} = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: {
vertical: "/fake/vertical.mp4",
square: "/fake/square.mp4",
},
] as VideoPost[],
),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}),
);
},
] as VideoPost[],
),
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}));
vi.mock("@/lib/api", () => ({
videoApi: { listPosts, approve, reject, requestVideo, getMediaBlob },
videoApi: {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
},
}));
import { VideoPostQueue } from "../video-post-queue";
@@ -61,6 +78,7 @@ function withQueryClient(ui: ReactNode) {
describe("VideoPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
listPipeline.mockClear();
approve.mockClear();
reject.mockClear();
requestVideo.mockClear();
@@ -87,6 +105,46 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("shows the fetched title and script instead of dropping them", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText("Video: release v0.19.0"),
).toBeInTheDocument();
expect(
screen.getByText("RoboCo v0.19.0 just shipped!"),
).toBeInTheDocument();
});
it("flags a missing cut as disabled instead of silently blanking the player", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: { vertical: "/fake/vertical.mp4" }, // square never rendered
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
const squareButton = screen.getByRole("button", { name: /1:1/ });
expect(squareButton).toHaveTextContent("(missing)");
expect(squareButton).toBeDisabled();
// The present cut is unaffected — no "(missing)" suffix, not disabled.
const verticalButton = screen.getByRole("button", { name: /9:16/ });
expect(verticalButton).not.toHaveTextContent("(missing)");
expect(verticalButton).not.toBeDisabled();
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
);
});
// H15: the 30s refetchInterval produces a new `post` prop, but useState
// initializes once — so a server-side re-draft between the CEO opening the
// card and approving would be silently overwritten by the stale initial
@@ -242,12 +300,36 @@ describe("VideoPostQueue", () => {
);
});
it("shows an empty-state card (with the request action) when there are no drafts", async () => {
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Request a video/ }),
).toBeInTheDocument();
});
it("shows an in-flight count instead of the keys/engine copy when the pipeline is non-empty", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([
{
task_id: "vp-1",
title: "Video: launch teaser",
occasion: "launch",
status: "in_progress",
pr_number: null,
composition_id: null,
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
},
] as VideoPipelineItem[]);
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText(/1 video in flight — nothing rendered yet/),
).toBeInTheDocument();
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import type { VideoPipelineItem } from "@/lib/api/video";
import {
derivePipelineStage,
pipelineStageColor,
pipelineStageLabel,
} from "./video-pipeline-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Film } from "lucide-react";
// One row: title + occasion + a colored stage chip. The stage chip is
// derived (never fetched) from status + render_status/render_attempts —
// see video-pipeline-utils.ts, unit-tested directly there. Only the
// "awaiting your approval" stage deep-links out (the CEO decision the
// pipeline is surfacing); every other stage is just visibility.
function PipelineRow({ item }: { item: VideoPipelineItem }) {
const stage = derivePipelineStage(item);
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm">
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{item.title}</span>
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>}
<Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)}
</Badge>
{stage.kind === "awaiting_approval" && (
<Link
href={`/tasks/${item.task_id}`}
prefetch={false}
className="ml-auto"
>
<Button variant="outline" size="sm">
Review
</Button>
</Link>
)}
</div>
);
}
// Social page visibility strip: every source=video item still moving
// through authoring/review/rendering, above the Video Post Queue. Renders
// nothing (not even an empty-state card) when the pipeline is empty — the
// queue's own state-aware copy already covers "nothing in flight". Polls
// on the same 30s cadence as the queue.
export function VideoPipelineStrip({ className }: { className?: string }) {
const { data: items, isLoading } = useQuery({
queryKey: ["video", "pipeline"],
queryFn: () => videoApi.listPipeline(),
refetchInterval: 30000,
});
if (isLoading || !items || items.length === 0) return null;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Film className="h-5 w-5" />
Video Pipeline
<Badge variant="secondary">{items.length}</Badge>
</CardTitle>
<CardDescription>
Every video in flight authoring, review, or rendering before it
lands in the queue below.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{items.map((item) => (
<PipelineRow key={item.task_id} item={item} />
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,84 @@
/**
* Pure stage derivation for the video pipeline strip (extracted for direct
* unit testing). A VideoPipelineItem's `status` covers the pre-render
* delivery lifecycle; once it reaches COMPLETED, `render_status` /
* `render_attempts` (from the orchestration_markers.video_draft JSON) take
* over to describe the render loop's own retry/failure states.
*/
import type { VideoPipelineItem } from "@/lib/api/video";
export type PipelineStage =
| { kind: "authoring" }
| { kind: "in_review" }
| { kind: "awaiting_approval" }
| { kind: "rendering"; attempt: number; maxAttempts: number }
| { kind: "render_failed"; reason: string | null };
// Statuses between "self-verified" and "PM merges" — the dev's work is done,
// a reviewer/PM is looking at it.
const IN_REVIEW_STATUSES = new Set([
"awaiting_qa",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
]);
/**
* Derives the pipeline strip's stage chip from an item's raw fields. Only
* reads the subset of VideoPipelineItem the derivation needs, so callers
* (and tests) can pass a partial fixture.
*/
export function derivePipelineStage(
item: Pick<
VideoPipelineItem,
"status" | "render_status" | "render_attempts" | "max_attempts" | "render_error"
>,
): PipelineStage {
if (item.status === "completed") {
if (item.render_status === "failed") {
return { kind: "render_failed", reason: item.render_error };
}
// render_status unset (or any non-terminal value) — still retrying.
return {
kind: "rendering",
attempt: item.render_attempts,
maxAttempts: item.max_attempts,
};
}
if (item.status === "awaiting_ceo_approval") return { kind: "awaiting_approval" };
if (IN_REVIEW_STATUSES.has(item.status)) return { kind: "in_review" };
return { kind: "authoring" };
}
/** Human-readable label for a stage — the chip's display text. */
export function pipelineStageLabel(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "Authoring";
case "in_review":
return "In review";
case "awaiting_approval":
return "Awaiting your approval";
case "rendering":
return `Rendering (attempt ${stage.attempt}/${stage.maxAttempts})`;
case "render_failed":
return stage.reason ? `Render failed: ${stage.reason}` : "Render failed";
}
}
/** Tailwind bg class for the stage badge — mirrors task-status-badge.tsx. */
export function pipelineStageColor(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "bg-blue-600";
case "in_review":
return "bg-teal-600";
case "awaiting_approval":
return "bg-amber-600";
case "rendering":
return "bg-purple-500";
case "render_failed":
return "bg-red-600";
}
}
@@ -83,7 +83,11 @@ function VideoPostRow({
onReject: (post: VideoPost) => void;
approving: boolean;
}) {
const [cut, setCut] = useState<VideoCut>("vertical");
// Default to whichever cut actually rendered — a missing vertical cut
// (mp4_paths lacks the key) must not open on a guaranteed-blank player.
const [cut, setCut] = useState<VideoCut>(() =>
post.mp4_paths?.vertical ? "vertical" : "square",
);
const [editX, setEditX] = useState(post.platforms.includes("x"));
const [editTiktok, setEditTiktok] = useState(
post.platforms.includes("tiktok"),
@@ -101,7 +105,13 @@ function VideoPostRow({
// A native <video src> GET doesn't carry axios's auth headers, so fetch
// the cut as a Blob (through axios) and drive <video> off an object URL
// instead. Re-fetches on cut change; always revokes the previous URL.
// Skips the fetch entirely when mp4_paths has no entry for this cut — the
// JSX below never renders <video> in that case (falls back to a missing-
// cut placeholder instead), so a stale videoSrc is simply never read.
useEffect(() => {
if (!post.mp4_paths?.[cut]) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
videoApi
@@ -118,7 +128,7 @@ function VideoPostRow({
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [post.task_id, cut]);
}, [post.task_id, cut, post.mp4_paths]);
const xOverLimit = editX && xCaption.length > MAX_X_CAPTION_CHARS;
const tiktokOverLimit =
@@ -140,33 +150,52 @@ function VideoPostRow({
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
</div>
<p className="mb-1 text-sm font-medium">{post.title}</p>
{post.script && (
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
{post.script}
</p>
)}
<div className="mb-3 space-y-2">
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
title={
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
}
onClick={() => setCut("vertical")}
>
9:16
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
title={post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"}
onClick={() => setCut("square")}
>
1:1
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
</div>
<video
key={`${post.task_id}-${cut}`}
controls
className="mx-auto max-h-96 w-full rounded-md bg-black object-contain"
src={videoSrc ?? undefined}
>
Your browser does not support embedded video.
</video>
{post.mp4_paths?.[cut] ? (
<video
key={`${post.task_id}-${cut}`}
controls
className="mx-auto max-h-96 w-full rounded-md bg-black object-contain"
src={videoSrc ?? undefined}
>
Your browser does not support embedded video.
</video>
) : (
<div className="flex h-48 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
This cut hasn&apos;t rendered yet
</div>
)}
</div>
<div className="space-y-3">
@@ -388,6 +417,14 @@ export function VideoPostQueue({ className }: { className?: string }) {
queryFn: () => videoApi.listPosts(),
refetchInterval: 30000,
});
// Only consulted for the empty-state copy below — tells apart "nothing
// rendered yet, but N videos are moving through the pipeline" from a
// truly idle engine. Same 30s cadence as the queue + the pipeline strip.
const { data: pipeline } = useQuery({
queryKey: ["video", "pipeline"],
queryFn: () => videoApi.listPipeline(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
@@ -476,9 +513,9 @@ export function VideoPostQueue({ className }: { className?: string }) {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No drafts yet. Set your keys in Settings X (Twitter) / TikTok
Credentials and enable the video engine or request one on demand
above.
{pipeline && pipeline.length > 0
? `${pipeline.length} video${pipeline.length === 1 ? "" : "s"} in flight — nothing rendered yet. Check the pipeline above for status.`
: "No drafts yet. Set your keys in Settings → X (Twitter) / TikTok Credentials and enable the video engine — or request one on demand above."}
</p>
</CardContent>
</Card>
+1
View File
@@ -41,6 +41,7 @@ export { videoApi, videoMediaUrl } from "./video";
export type {
VideoCut,
VideoPost,
VideoPipelineItem,
VideoPostExecuteResult,
VideoPostHistoryEntry,
VideoRequestResult,
+24
View File
@@ -22,6 +22,24 @@ export interface VideoPost {
tiktok_caption?: string | null;
reject_reason?: string | null;
mp4_paths?: Record<string, string>;
source_task_id?: string | null; // the authoring task this draft rendered from
}
// One in-flight source=video authoring task — GET /video/pipeline
// (roboco/api/routes/video.py). Spans claim through the render loop's
// retry/failure states; a rendered task drops out (it's visible via
// VideoPost/listPosts instead).
export interface VideoPipelineItem {
task_id: string;
title: string;
occasion: string;
status: string;
pr_number: number | null;
composition_id: string | null;
render_status: string | null; // null (pending/retrying) | "rendered" | "failed"
render_attempts: number;
max_attempts: number;
render_error: string | null;
}
export interface VideoPostExecuteResult {
@@ -44,6 +62,7 @@ export interface VideoPostHistoryEntry {
reject_reason?: string | null;
posted: Record<string, string>; // platform -> posted id
acted_at: string;
source_task_id?: string | null; // the authoring task this draft rendered from
}
export interface VideoRequestResult {
@@ -72,6 +91,11 @@ export const videoApi = {
const { data } = await api.get<VideoPost[]>("/video/posts");
return data;
},
// Every in-flight source=video authoring task — the pipeline strip's basis.
listPipeline: async (): Promise<VideoPipelineItem[]> => {
const { data } = await api.get<VideoPipelineItem[]>("/video/pipeline");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => {
+31
View File
@@ -17,6 +17,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.video import (
TikTokCredentialsSetRequest,
TikTokCredentialsStatus,
VideoPipelineItemResponse,
VideoPostApproveRequest,
VideoPostExecuteResponse,
VideoPostHistoryResponse,
@@ -139,6 +140,7 @@ def _to_response(task: TaskTable) -> VideoPostResponse:
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
mp4_paths=dict(draft.get("mp4_paths") or {}),
source_task_id=draft.get("source_task_id"),
)
@@ -166,6 +168,34 @@ async def list_video_posts(
return [_to_response(t) for t in tasks]
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
draft = markers.get_video_draft(task) or {}
return VideoPipelineItemResponse(
task_id=str(task.id),
title=task.title,
occasion=str(draft.get("occasion") or ""),
status=_status_value(task),
pr_number=task.pr_number,
composition_id=draft.get("composition_id"),
render_status=draft.get("render_status"),
render_attempts=int(draft.get("render_attempts", 0)),
render_error=draft.get("render_error"),
)
@router.get("/pipeline", response_model=list[VideoPipelineItemResponse])
async def list_video_pipeline(
db: DbSession, agent: CurrentAgentContext
) -> list[VideoPipelineItemResponse]:
"""Every in-flight source=video authoring task, from claim through the
render loop's retry/failure states — the Social page's pipeline-
visibility strip. A rendered task has already materialized its
video_post draft (visible instead via ``/posts``) and drops out here."""
_require_ceo(agent)
tasks = await get_task_service(db).list_video_pipeline_tasks()
return [_to_pipeline_item(t) for t in tasks]
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
@@ -190,6 +220,7 @@ def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
reject_reason=markers.get_video_reject_reason(task),
posted=_posted_ids(draft),
acted_at=task.updated_at or task.created_at,
source_task_id=draft.get("source_task_id"),
)
+22
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from pydantic import BaseModel, Field
from roboco.foundation.policy.content.markers import MAX_VIDEO_RENDER_ATTEMPTS
from roboco.services.video_post_service import MAX_TIKTOK_CAPTION_CHARS
from roboco.services.x_client import MAX_TWEET_CHARS
@@ -38,6 +39,7 @@ class VideoPostResponse(BaseModel):
tiktok_caption: str | None = None
reject_reason: str | None = None
mp4_paths: dict[str, str] = Field(default_factory=dict)
source_task_id: str | None = None # the authoring task this draft rendered from
class VideoPostApproveRequest(BaseModel):
@@ -79,6 +81,26 @@ class VideoPostHistoryResponse(BaseModel):
reject_reason: str | None = None
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
acted_at: datetime
source_task_id: str | None = None # the authoring task this draft rendered from
class VideoPipelineItemResponse(BaseModel):
"""One in-flight source=video item — the Social page's pipeline-strip
basis. Spans the authoring task's whole pre-post lifecycle: any
non-terminal delivery status, then the render loop's retry/failure
states. composition_id/render_status/render_attempts/render_error live
in the orchestration_markers.video_draft JSON, not a column."""
task_id: str
title: str
occasion: str
status: str
pr_number: int | None = None
composition_id: str | None = None
render_status: str | None = None
render_attempts: int = 0
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
render_error: str | None = None
class TikTokCredentialsStatus(BaseModel):
+11 -2
View File
@@ -228,8 +228,17 @@ def set_roadmap_cycle(task: HasMarkers, payload: dict[str, Any]) -> None:
# The video engine's working payload, carried on both the UX/UI authoring task
# (source=video) and the held post draft it later produces (source=video_post):
# {occasion, script, composition_id, input_props, mp4_paths, x_caption,
# tiktok_caption, platforms, render_status}. Set once at authoring-open time
# and extended (not replaced) once rendering fills in the mp4/caption fields.
# tiktok_caption, platforms, render_status, render_attempts, render_error,
# source_task_id}. Set once at authoring-open time and extended (not replaced)
# once rendering fills in the mp4/caption fields.
# Bounded retry for the video render loop: a failed render (read-clone not yet
# synced to the just-merged composition, or a transient sidecar blip) retries
# on a later cycle; only after this many attempts is a task marked terminally
# failed, so a genuinely broken composition can't re-render forever. Single
# source of truth for the orchestrator's render loop AND the pipeline-strip
# API — importable from both without the API importing the orchestrator.
MAX_VIDEO_RENDER_ATTEMPTS = 5
def get_video_draft(task: HasMarkers) -> dict[str, Any] | None:
+6
View File
@@ -630,6 +630,12 @@ def propose_video(
"""UX/UI dev: propose your video's composition + captions. Metadata only —
this does NOT render (rendering happens later, off this path).
Before authoring, read motion/README.md for the design bar and
motion/kit/README.md for the panel-demo kit build in the panel-demo
register on motion/kit/ (extend motion/compositions/panel-demo/) rather
than starting from scratch or shipping a text card, unless the occasion
has no product visual to show.
Call this exactly ONCE per authoring task, after building the HyperFrames
composition in motion/compositions/<id>/. Then commit + open_pr to send
it through the normal PR-review gate.
+8 -7
View File
@@ -793,11 +793,10 @@ def _is_non_dev_dispatch_source(task: dict[str, Any]) -> bool:
return task.get("source") in (ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE)
# Bounded retry for the video render loop: a failed render (read-clone not yet
# synced to the just-merged composition, or a transient sidecar blip) retries on
# a later cycle; only after this many attempts is a task marked terminally
# failed, so a genuinely broken composition can't re-render forever.
_MAX_VIDEO_RENDER_ATTEMPTS = 5
# Bounded retry for the video render loop — single source of truth lives in
# the policy/markers layer (importable from API code without importing this
# orchestrator module); mirrored here under the historical name.
_MAX_VIDEO_RENDER_ATTEMPTS = _markers.MAX_VIDEO_RENDER_ATTEMPTS
class AgentOrchestrator:
@@ -8056,7 +8055,7 @@ Start by:
except Exception as exc:
attempts = int(draft.get("render_attempts", 0)) + 1
terminal = attempts >= _MAX_VIDEO_RENDER_ATTEMPTS
payload = {**draft, "render_attempts": attempts}
payload = {**draft, "render_attempts": attempts, "render_error": str(exc)}
if terminal:
payload["render_status"] = "failed"
markers.set_video_draft(task, payload)
@@ -8136,7 +8135,9 @@ Start by:
},
platforms=draft.get("platforms") or [],
)
markers.set_video_draft(task, {**draft, "render_status": "rendered"})
rendered_payload = {**draft, "render_status": "rendered"}
rendered_payload.pop("render_error", None) # clear any prior-attempt error
markers.set_video_draft(task, rendered_payload)
async def _load_dep_update_set(self, db: Any) -> list[Any]:
"""Projects with a ``dep_update_command`` + a git_url, one per
+23 -28
View File
@@ -1313,11 +1313,14 @@ class ContentActions:
feature hasn't already been covered, then materializes the held X-queue
draft and completes the caller's exploration task. One call per cycle.
``wants_video`` optionally requests a companion video (gated on
``video_engine_enabled AND video_on_spotlight``, on top of this
default-False param) a best-effort side effect that never disturbs
the spotlight draft above. Defaults leave the flow byte-for-byte
unchanged.
``wants_video`` optionally requests a companion video. The video
authoring task no longer opens here it opens later, at CEO-approve
time (``XPostService._open_spotlight_video``, gated on
``video_engine_enabled AND video_on_spotlight``), so a ux-dev never
burns a cycle on a spotlight the CEO then rejects. This verb only
records the request (+ optional script) on the draft's
``x_feature_ref`` marker for approve time to read. Defaults leave the
flow byte-for-byte unchanged.
"""
role = await self._caller_role(agent_id)
if role not in _FEATURE_SPOTLIGHT_ROLES:
@@ -1365,11 +1368,22 @@ class ContentActions:
feature_title=feature_title,
body=body,
)
video_armed = settings.video_engine_enabled and settings.video_on_spotlight
if wants_video and video_armed:
await self._open_spotlight_video(
feature_slug, feature_title, body, video_script
if wants_video:
# Gating (video_engine_enabled AND video_on_spotlight) and the
# actual open_video_task call happen later, at CEO-approve time —
# see XPostService._open_spotlight_video. Stash the request here
# so approve time has it; slug/title are already on this ref from
# materialize_feature_spotlight above, so re-set alongside them.
markers.set_x_feature_ref(
new_task,
{
"slug": feature_slug,
"title": feature_title,
"wants_video": True,
"video_script": video_script.strip(),
},
)
await self.task.session.flush()
return Envelope.ok(
status="feature_spotlight_proposed",
task_id=str(new_task.id),
@@ -1380,25 +1394,6 @@ class ContentActions:
},
)
async def _open_spotlight_video(
self, feature_slug: str, feature_title: str, body: str, video_script: str
) -> None:
"""Best-effort: a spotlight video failure must never break the
spotlight draft that already materialized above. HoM decides *what*
(this brief); UX/UI later builds *how* (the composition)."""
try:
from roboco.services.video_engine import get_video_engine
feature_brief = f"{feature_title}: {body}"
await get_video_engine(self.task.session).open_video_task(
occasion=f"spotlight {feature_slug}",
script=video_script.strip() or feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
except Exception as exc:
logger.warning("spotlight video draft failed (best-effort)", error=str(exc))
@classmethod
def _reject_caption(
cls, value: str, *, field: str, max_chars: int
+33
View File
@@ -1482,6 +1482,39 @@ class TaskService(BaseService):
)
return list(result.scalars().all())
async def list_video_pipeline_tasks(self) -> list[TaskTable]:
"""Every source=VIDEO_SOURCE authoring task still visible in the
Social page's pipeline strip: any non-terminal status, plus
COMPLETED tasks the render loop hasn't finished with (render_status
unset pending/retrying or terminally "failed"). A rendered
COMPLETED task already materialized its video_post draft and drops
out of view. composition_id/render_status/render_attempts live in
the JSON marker, not a column, so that half of the filter runs in
Python on this bounded scan (mirrors list_completed_video_tasks).
Newest-first + bounded, so a large old backlog can't crowd a
currently-active item out of the scanned window; the panel can
re-order for display.
"""
from roboco.config import settings
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source == VIDEO_SOURCE,
TaskTable.status != TaskStatus.CANCELLED,
)
.order_by(TaskTable.created_at.desc())
.limit(settings.video_render_scan_limit)
)
tasks = list(result.scalars().all())
return [
t
for t in tasks
if t.status != TaskStatus.COMPLETED
or (markers.get_video_draft(t) or {}).get("render_status")
in (None, "failed")
]
async def list_open_video_post_drafts(self) -> list[TaskTable]:
"""Non-terminal video_post held drafts only (excludes the video
authoring source) the panel-queue basis for VideoPostService,
+95 -10
View File
@@ -19,7 +19,7 @@ while the flag is off.
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
import httpx
@@ -28,6 +28,7 @@ from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.project import get_project_service
from roboco.services.task import (
VIDEO_POST_SOURCE,
@@ -46,11 +47,26 @@ if TYPE_CHECKING:
_AUTHORING_ACCEPTANCE_CRITERIA = [
"Both 9:16 and 1:1 cuts render",
"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",
]
_POST_ACCEPTANCE_CRITERIA = ["CEO approves or rejects the draft"]
_CHAT_TIMEOUT_SECONDS = 60.0
# The whole CHANGELOG section for a release, not one bullet — capped so a
# pathological entry can't blow up the task description.
_CHANGELOG_BRIEF_CHARS = 4000
# Shared by every open_video_task caller (release/spotlight/on-demand) so the
# authoring dev always lands on the demo kit instead of a text card.
_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."
)
async def _chat(prompt: str) -> str | None:
"""One local-LLM chat call (OpenAI-compatible); None on a non-success.
@@ -77,16 +93,24 @@ async def _chat(prompt: str) -> str | None:
return content if isinstance(content, str) else None
def _first_changelog_bullet(changelog: str) -> str:
"""First CHANGELOG bullet's text, or "" if none — the fallback template's
headline when the local model is unavailable."""
def _changelog_highlights(changelog: str) -> list[str]:
"""Every bullet line in a CHANGELOG section, in order — the structured
highlights list a composition's props.js can render directly."""
highlights = []
for line in changelog.splitlines():
stripped = line.strip()
if stripped.startswith(("-", "*")):
text = stripped.lstrip("-*").strip()
if text:
return text
return ""
highlights.append(text)
return highlights
def _first_changelog_bullet(changelog: str) -> str:
"""First CHANGELOG bullet's text, or "" if none — the fallback template's
headline when the local model is unavailable."""
highlights = _changelog_highlights(changelog)
return highlights[0] if highlights else ""
def _fallback_release_script(version: str, changelog: str) -> str:
@@ -106,6 +130,19 @@ def _release_video_prompt(version: str, changelog: str) -> str:
)
def _release_video_brief(version: str, changelog: str, highlights: list[str]) -> str:
"""The structured release brief: the (capped) CHANGELOG section for this
version plus its highlights list replaces the old one-liner-as-
description. The LLM script stays a separate ``script`` prop suggestion,
never the whole brief."""
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
parts = [f"RoboCo v{version} release notes:", section]
if highlights:
bullets = "\n".join(f"- {h}" for h in highlights)
parts.append(f"Highlights:\n{bullets}")
return "\n\n".join(parts)
class VideoEngine(BaseService):
"""Open video-authoring tasks (event hooks + on-demand), both gated."""
@@ -161,8 +198,39 @@ class VideoEngine(BaseService):
# ---- authoring task (event hooks + on-demand) --------------------------
async def _brand_voice_note(self) -> str:
"""CEO-supplied brand-voice sample from the company charter, or ""
when unset. Same ``company_goals.brand_voice`` read x_engine's
``_voice_guide`` uses, duplicated locally per this service's
no-cross-service-internals policy."""
charter = await get_company_goals_service(self.session).get()
return (charter.get("brand_voice") or "").strip()
async def _enrich_brief(self, brief: str) -> str:
"""Append the CEO's brand-voice sample (when set) and the motion
design-bar pointer to any occasion's brief.
The one seam shared by the release, spotlight, and on-demand callers
of ``open_video_task`` each supplies only its own content, and all
three inherit the same enrichment here rather than duplicating it
per caller (on-demand's caller, the ``/video/request`` route, passes
its brief through verbatim, so this is the only place it can land).
"""
parts = [brief]
brand_voice = await self._brand_voice_note()
if brand_voice:
parts.append(f"Brand voice (from the CEO's charter):\n{brand_voice}")
parts.append(_MOTION_DESIGN_POINTER)
return "\n\n".join(parts)
async def open_video_task(
self, *, occasion: str, script: str, platforms: list[str], brief: str
self,
*,
occasion: str,
script: str,
platforms: list[str],
brief: str,
suggested_input_props: dict[str, Any] | None = None,
) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or None.
@@ -173,6 +241,13 @@ class VideoEngine(BaseService):
delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``)
NOT held so it dispatches straight to the assigned ux-dev like any
other pre-assigned code task.
``brief`` is enriched (brand-voice + motion design-bar pointer
appended) before becoming the task description and the marker's
``brief`` field. ``suggested_input_props`` (e.g. ``{"version": ...,
"highlights": [...]}`` from the release caller) is seeded onto the
marker as-is so the dev copies real structured data into
``propose_video``'s ``input_props`` instead of hand-typing facts.
"""
if not settings.video_engine_enabled:
return None
@@ -194,6 +269,7 @@ class VideoEngine(BaseService):
from sqlalchemy.exc import SQLAlchemyError
assignee = self._select_ux_dev(open_tasks)
enriched_brief = await self._enrich_brief(brief)
# Savepoint-isolate the insert: a DBAPI error here (FK, deadlock,
# dropped connection) must roll back ONLY this insert, never poison the
# shared session — whose next commit is the caller's release-publish
@@ -203,7 +279,7 @@ class VideoEngine(BaseService):
task = await task_svc.create(
TaskCreateRequest(
title=f"Video: {occasion}",
description=brief,
description=enriched_brief,
acceptance_criteria=list(_AUTHORING_ACCEPTANCE_CRITERIA),
team=Team.UX_UI,
assigned_to=assignee,
@@ -227,7 +303,8 @@ class VideoEngine(BaseService):
"occasion": occasion,
"script": script,
"platforms": platforms,
"brief": brief,
"brief": enriched_brief,
"suggested_input_props": dict(suggested_input_props or {}),
},
)
await self.session.flush()
@@ -258,15 +335,23 @@ class VideoEngine(BaseService):
Called from ``ReleaseProposalService.approve()``'s publish success
branch, right beside the X-post draft hook never invoked by a loop
itself.
The brief is the structured changelog block (built independent of
the local model, so it stands even when the model is down); the
LLM-drafted (or template-fallback) one-liner is only the ``script``
prop suggestion, never the whole brief.
"""
if not (settings.video_engine_enabled and settings.video_on_release):
return None
script = await self._draft_release_script(version, changelog)
highlights = _changelog_highlights(changelog)
brief = _release_video_brief(version, changelog, highlights)
return await self.open_video_task(
occasion=f"release {version}",
script=script,
platforms=["x", "tiktok"],
brief=script,
brief=brief,
suggested_input_props={"version": version, "highlights": highlights},
)
async def _draft_release_script(self, version: str, changelog: str) -> str:
+45 -1
View File
@@ -9,6 +9,12 @@ second approve is a no-op that returns the already-posted result. Rejecting
records the reason and CANCELS the draft (unlike the release proposal there is
no revision workflow here the CEO edits inline and re-approves, or a fresh
draft is originated on the next cycle/release).
A successfully-posted ``x_feature`` (spotlight) draft additionally fires
``_open_spotlight_video`` the companion-video hook moved here from
authoring time (``propose_feature_spotlight``) so it only fires once the CEO
has actually approved the spotlight, mirroring the
``ReleaseProposalService.approve`` -> ``_draft_video`` seam.
"""
from __future__ import annotations
@@ -24,7 +30,7 @@ from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.services.base import BaseService
from roboco.services.task import X_SOURCES, get_task_service
from roboco.services.task import X_FEATURE_SOURCE, X_SOURCES, get_task_service
from roboco.services.x_client import MAX_TWEET_CHARS, build_x_client
from roboco.services.x_credentials import get_x_credentials_service
@@ -188,10 +194,48 @@ class XPostService(BaseService):
# release — otherwise a racing approve could acquire the lock the
# instant we drop it and double-post before the route-level commit.
await self.session.commit()
if task.source == X_FEATURE_SOURCE:
await self._open_spotlight_video(task, body)
return XPostExecuteResult(
status="posted", tweet_id=result.tweet_id, detail=result.detail
)
async def _open_spotlight_video(self, task: TaskTable, posted_body: str) -> None:
"""Mirrors ``ReleaseProposalService._draft_video``: a best-effort side
effect after the post has already succeeded, never allowed to affect
the result above. Moved here from authoring time
(``propose_feature_spotlight``) so a ux-dev never burns a delivery
cycle on a spotlight video for a draft the CEO ends up rejecting
this only fires once the tweet is actually live.
Fires only when the draft's ``x_feature_ref`` marker carries
``wants_video`` (stamped by ``propose_feature_spotlight``) and both
``video_engine_enabled`` and ``video_on_spotlight`` are on.
``open_video_task``'s own occasion-dedup covers a hypothetical repeat
call; the COMPLETED short-circuit in ``approve()`` already prevents
``_post`` (and so this) from running twice for the same draft.
"""
if not (settings.video_engine_enabled and settings.video_on_spotlight):
return
ref = markers.get_x_feature_ref(task) or {}
if not ref.get("wants_video"):
return
feature_slug = str(ref.get("slug") or "")
feature_title = str(ref.get("title") or "")
video_script = str(ref.get("video_script") or "")
try:
from roboco.services.video_engine import get_video_engine
feature_brief = f"{feature_title}: {posted_body}"
await get_video_engine(self.session).open_video_task(
occasion=f"spotlight {feature_slug}",
script=video_script.strip() or feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
except Exception as exc:
logger.warning("spotlight video draft failed (best-effort): %s", exc)
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
"""Record the CEO's reason and cancel the draft (never posted)."""
task = await get_task_service(self.session).get(task_id)
+183
View File
@@ -45,6 +45,7 @@ SYSTEM_UUID = _foundation.AGENTS["system"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
HISTORY_LIMIT = 2
RETRY_ATTEMPTS = 2
async def _seed(session: AsyncSession) -> None:
@@ -168,6 +169,55 @@ async def _seed_draft(
return task
async def _seed_authoring_task(
session: AsyncSession,
*,
status: TaskStatus = TaskStatus.IN_PROGRESS,
draft_extra: dict[str, object] | None = None,
pr_number: int | None = None,
) -> TaskTable:
"""A ``source=video`` UX/UI authoring task — the pipeline route's basis.
Mirrors ``_seed_draft`` but for the pre-render authoring stage."""
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
ux_dev = await _seed_agent(session, AgentRole.DEVELOPER, "ux-dev")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.UX_UI,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Video: launch teaser",
description="A short teaser for the launch",
acceptance_criteria=["dev builds the composition"],
status=status,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=system.id,
assigned_to=ux_dev.id,
team=Team.UX_UI,
source=VIDEO_SOURCE,
confirmed_by_human=True,
pr_number=pr_number,
)
session.add(task)
await session.flush()
markers.set_video_draft(
task,
{"occasion": "launch teaser", "script": "script", **(draft_extra or {})},
)
await session.flush()
return task
def _build_app(
db_session: AsyncSession | None, role: AgentRole, agent_id: UUID
) -> FastAPI:
@@ -304,6 +354,139 @@ async def test_list_posts_returns_open_draft(
}
@pytest.mark.asyncio
async def test_list_posts_includes_source_task_id(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""source_task_id round-trips from the marker to the response — the
panel's basis for a future draft->authoring-task deep link."""
source_task_id = uuid4()
task = await _seed_draft(db_session)
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
await db_session.flush()
resp = await ceo_client.get("/api/video/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body[0]["source_task_id"] == str(source_task_id)
@pytest.mark.asyncio
async def test_history_includes_source_task_id(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
source_task_id = uuid4()
task = await _seed_draft(db_session)
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
await db_session.flush()
with _LOCKED[0], _LOCKED[1]:
await ceo_client.post(
f"/api/video/posts/{task.id}/reject", json={"reason": "off-brand"}
)
resp = await ceo_client.get("/api/video/posts/history")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["source_task_id"] == str(source_task_id)
# --- pipeline strip (task 1, 2026-07-09) --------------------------------------
@pytest.mark.asyncio
async def test_pipeline_lists_non_terminal_authoring_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get("/api/video/pipeline")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["status"] == "in_progress"
assert row["occasion"] == "launch teaser"
assert row["render_status"] is None
assert row["render_attempts"] == 0
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] is None
@pytest.mark.asyncio
async def test_pipeline_shows_completed_unrendered_with_attempts(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""A COMPLETED authoring task the render loop hasn't finished with
(render_status unset) stays visible with its retry count."""
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={"composition_id": "Intro", "render_attempts": RETRY_ATTEMPTS},
)
resp = await ceo_client.get("/api/video/pipeline")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["render_attempts"] == RETRY_ATTEMPTS
assert row["render_status"] is None
assert row["composition_id"] == "Intro"
@pytest.mark.asyncio
async def test_pipeline_shows_failed_render_with_error(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={
"composition_id": "Intro",
"render_status": "failed",
"render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
"render_error": "sidecar timeout",
},
)
resp = await ceo_client.get("/api/video/pipeline")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["render_status"] == "failed"
assert row["render_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] == "sidecar timeout"
@pytest.mark.asyncio
async def test_pipeline_excludes_rendered_completed_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""A rendered task already materialized its video_post draft — it must
not double-appear in the pipeline strip."""
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={"composition_id": "Intro", "render_status": "rendered"},
)
resp = await ceo_client.get("/api/video/pipeline")
ids = [row["task_id"] for row in resp.json()]
assert str(task.id) not in ids
@pytest.mark.asyncio
async def test_pipeline_excludes_cancelled_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.CANCELLED)
resp = await ceo_client.get("/api/video/pipeline")
ids = [row["task_id"] for row in resp.json()]
assert str(task.id) not in ids
@pytest.mark.asyncio
async def test_pipeline_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/video/pipeline")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_media_returns_the_rendered_cut(
db_session: AsyncSession,
@@ -9,6 +9,7 @@ from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -181,13 +182,20 @@ async def test_propose_feature_spotlight_materializes_new_draft_task(
# --------------------------------------------------------------------------- #
# wants_video companion — additive, default-False, best-effort
# wants_video companion — additive, default-False. Task 4 (2026-07-09 pipeline
# fixes) moved the actual video-authoring open OFF authoring time and onto
# XPostService.approve (see test_x_post_service.py), so this verb only stamps
# the request onto the draft's x_feature_ref marker and never touches the
# video engine itself, regardless of wants_video or the video flags.
# --------------------------------------------------------------------------- #
def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
def _mock_spotlight_materialization(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[Any, Any]:
"""Wire an open exploration + a materializing XEngine, mirroring the happy
path above, so wants_video tests only need to stub the video engine."""
path above, so wants_video tests only need to inspect the returned draft's
marker (or stub the video engine to prove it's never called)."""
agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
@@ -199,57 +207,51 @@ def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
x_engine.is_feature_seen = AsyncMock(return_value=False)
x_engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: x_engine)
return agent_id
return agent_id, materialized
def _mock_video_engine(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
def _actions_with_flushable_session(role: str) -> ContentActions:
"""``_actions`` with ``task.session.flush`` made awaitable — needed once
``wants_video`` triggers the marker-write flush in
``propose_feature_spotlight`` (mirrors the same pattern in
test_content_actions_roadmap.py)."""
actions = _actions(role)
actions.task.session.flush = AsyncMock()
return actions
@pytest.mark.asyncio
async def test_propose_feature_spotlight_never_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Even with both video flags on and wants_video=True, this verb never
opens a video-authoring task that now happens at CEO-approve time."""
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
agent_id, _materialized = _mock_spotlight_materialization(monkeypatch)
video_engine = MagicMock()
video_engine.open_video_task = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine", lambda _s: video_engine
)
return video_engine
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
assert env.error is None
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
expected_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task?"
)
assert kwargs["brief"] == expected_brief
assert kwargs["script"] == expected_brief # falls back — no video_script given
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
async def test_propose_feature_spotlight_wants_video_stamps_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(
agent_id=agent_id,
**_valid_kwargs(),
wants_video=True,
@@ -257,63 +259,44 @@ async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
)
assert env.error is None
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] != "Custom voiceover script" # brief is always title:body
ref = markers.get_x_feature_ref(materialized)
assert ref is not None
assert ref["slug"] == "org-memory"
assert ref["title"] == "Organizational Memory Loop"
assert ref["wants_video"] is True
assert ref["video_script"] == "Custom voiceover script"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_default_wants_video_false_skips_video(
async def test_propose_feature_spotlight_wants_video_without_script_stores_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default False -> byte-for-byte unchanged spotlight behavior: the video
engine is never even looked up."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
"""No explicit script -> stored as "" (the fallback-to-brief logic lives
in XPostService._open_spotlight_video at approve time, not here)."""
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs()
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
assert env.error is None
assert env.status == "feature_spotlight_proposed"
video_engine.open_video_task.assert_not_called()
ref = markers.get_x_feature_ref(materialized)
assert ref is not None
assert ref["video_script"] == ""
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_but_flags_off_skips_video(
async def test_propose_feature_spotlight_default_wants_video_false_leaves_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
"""Default False -> byte-for-byte unchanged: this verb never re-touches
the x_feature_ref marker at all."""
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_video_failure_does_not_break_spotlight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Best-effort: a video-engine blow-up must not surface as an error on the
spotlight verb the spotlight draft already materialized."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine",
MagicMock(side_effect=RuntimeError("video-engine boom")),
)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
assert env.status == "feature_spotlight_proposed"
assert markers.get_x_feature_ref(materialized) is None
+53
View File
@@ -287,6 +287,59 @@ async def test_list_awaiting_main_pm_all_returns_root_tasks() -> None:
assert out == roots
# ---------------------------------------------------------------------------
# list_video_pipeline_tasks — pipeline-strip basis (task 1, 2026-07-09)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_keeps_non_terminal_and_unrendered() -> None:
"""The query itself only excludes CANCELLED at the SQL level; the
COMPLETED-but-rendered drop happens in Python off the marker, so this
exercises that filter directly against a mixed mocked result set."""
in_progress = _build_task(status=TaskStatus.IN_PROGRESS, orchestration_markers=None)
awaiting_ceo = _build_task(
status=TaskStatus.AWAITING_CEO_APPROVAL, orchestration_markers=None
)
rendered = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_status": "rendered"}},
)
pending_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_attempts": 2}},
)
failed_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={
"video_draft": {"render_status": "failed", "render_error": "boom"}
},
)
scalars = MagicMock()
scalars.all.return_value = [
in_progress,
awaiting_ceo,
rendered,
pending_render,
failed_render,
]
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_video_pipeline_tasks()
assert out == [in_progress, awaiting_ceo, pending_render, failed_render]
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_empty_when_nothing_in_flight() -> None:
scalars = MagicMock()
scalars.all.return_value = []
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.list_video_pipeline_tasks() == []
# ---------------------------------------------------------------------------
# all_subtasks_terminal
# ---------------------------------------------------------------------------
+166 -2
View File
@@ -19,6 +19,7 @@ from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskStatus as TS
from roboco.services import video_engine as video_engine_module
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from sqlalchemy import delete, select
@@ -32,6 +33,7 @@ UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
THREE = 3
async def _seed(session: AsyncSession) -> None:
@@ -134,6 +136,10 @@ async def test_open_video_task_creates_assigned_authoring_task(
# would auto-block for subtasks it never owns and deadlock.
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 "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
assert project.slug == SLUG
@@ -142,7 +148,14 @@ async def test_open_video_task_creates_assigned_authoring_task(
assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "Here's what shipped"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["brief"] == "Announce the release"
# brief is enriched: raw content + motion design-bar pointer appended.
assert draft["brief"].startswith("Announce the release")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert draft["suggested_input_props"] == {} # none supplied
assert task.description == draft["brief"]
@pytest.mark.asyncio
@@ -433,7 +446,16 @@ async def test_draft_release_video_opens_authoring_task(
assert draft["occasion"] == "release 1.0.0"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release."
assert draft["brief"] == draft["script"]
# brief is now the structured changelog block, not the LLM one-liner —
# the whole CHANGELOG section (not one bullet) plus a highlights list.
assert draft["brief"] != draft["script"]
assert _CHANGELOG.strip() in draft["brief"]
assert "a huge new release" in draft["brief"]
assert "motion/README.md" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "1.0.0",
"highlights": ["a huge new release"],
}
@pytest.mark.asyncio
@@ -451,6 +473,10 @@ async def test_draft_release_video_falls_back_to_template_on_local_model_failure
assert draft is not None
assert "1.0.0" in draft["script"]
assert "a huge new release" in draft["script"]
# The structured brief is built independent of the local model, so a
# model outage still produces the full changelog-derived brief.
assert _CHANGELOG.strip() in draft["brief"]
assert draft["suggested_input_props"]["highlights"] == ["a huge new release"]
@pytest.mark.asyncio
@@ -466,6 +492,144 @@ async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
assert "- no bullets" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "2.0.0",
"highlights": ["no bullets"],
}
@pytest.mark.asyncio
async def test_draft_release_video_brief_contains_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
_mock_local_model(monkeypatch, "shipped!")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
# --------------------------------------------------------------------------- #
# brief enrichment shared by every occasion — brand voice + motion pointer
# (spec Task 2: "Feed the video brief")
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_open_video_task_brief_carries_motion_pointer_no_brand_voice_by_default(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Simulates the on-demand (`POST /video/request`) caller, which passes
its brief through verbatim enrichment must land centrally here since
that route can't append it itself."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo",
script="script",
platforms=["x"],
brief="CEO's on-demand brief, verbatim.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith("CEO's on-demand brief, verbatim.")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert task.description == draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_brief_includes_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo 2",
script="script",
platforms=["x"],
brief="CEO's on-demand brief.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_does_not_compress_spotlight_brief(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Mirrors content_actions._open_spotlight_video's `f"{title}: {body}"`
shape the spotlight body is already rich; enrichment only appends,
never truncates or rewrites the original content."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
feature_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task? A local model distills one high-signal lesson "
"per task, retrieved back into future briefings."
)
task = await engine.open_video_task(
occasion="spotlight org-memory",
script=feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith(feature_brief) # not truncated/compressed
assert "motion/README.md" in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_no_suggested_input_props_defaults_empty(
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="spotlight x", script="s", platforms=["x"], brief="b"
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["suggested_input_props"] == {}
@pytest.mark.asyncio
async def test_open_video_task_acceptance_criteria_has_design_bar_line(
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="release v9.9.9", script="s", platforms=["x"], brief="b"
)
assert task is not None
assert len(task.acceptance_criteria) == THREE
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
@pytest.mark.asyncio
+256 -2
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
@@ -126,6 +127,40 @@ async def _seed_draft(
return task
_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"
async def _seed_feature_draft(
session: AsyncSession,
*,
wants_video: bool = True,
video_script: str = "",
body: str = "Draft body",
) -> TaskTable:
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
slug/title always, plus wants_video/video_script when a companion video
was requested at authoring time."""
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
markers.set_x_feature_ref(
task,
{
"slug": _FEATURE_SLUG,
"title": _FEATURE_TITLE,
"wants_video": wants_video,
"video_script": video_script,
},
)
await session.flush()
return task
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
def _svc(session: AsyncSession) -> XPostService:
return get_x_post_service(session)
@@ -324,8 +359,9 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession,
) -> None:
"""The feature-spotlight source needs zero service changes: it rides the
same generic approve path as x_post/x_reply."""
"""The feature-spotlight source rides the same generic post path as
x_post/x_reply; it only branches for the best-effort video hook below
(a no-op here since this draft carries no x_feature_ref marker)."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient()
with (
@@ -463,3 +499,221 @@ async def test_approve_does_not_flush_edited_body_before_lock(
assert result.status == "already_posted"
await db_session.refresh(task)
assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit video_script -> script falls back to the brief, mirroring
the fallback the authoring-time hook used to do."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
kwargs = video_engine.open_video_task.call_args.kwargs
expected_brief = "Organizational Memory Loop: Draft body"
assert kwargs["script"] == expected_brief
assert kwargs["brief"] == expected_brief
@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Idempotent re-approve: the second call short-circuits on the already-
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
video_engine.open_video_task.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A plain x_post draft carries no x_feature_ref, so the source check
alone keeps the video hook from ever firing for it."""
_enable_video(monkeypatch)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejecting a spotlight draft never posts, so the video hook (which only
fires from the posted-success branch of _post) never runs either."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
):
updated = await _svc(db_session).reject(_id(task), "not on-brand")
assert updated is not None
assert updated.status == TS.CANCELLED
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags on but the draft's author didn't request a video (wants_video
absent/False on the marker) -> no video task, distinct from the
flags-off case above."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, wants_video=False)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Best-effort: a video-engine blow-up must not affect the already-
succeeded post."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
await db_session.refresh(task)
assert task.status == TS.COMPLETED
+2 -1
View File
@@ -9,7 +9,8 @@
"node": ">=22.13"
},
"scripts": {
"start": "node server.js"
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"@hyperframes/engine": "^0.7.36",
+28 -3
View File
@@ -9,18 +9,37 @@
// streamed the response.
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
import { existsSync } from "node:fs";
import { cp, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
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 * as tar from "tar";
const FPS = 30;
// @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.
const QUALITY = "high";
const DEFAULT_FPS = 30;
const MIN_FPS = 24;
const MAX_FPS = 60;
/**
* Read the composition-declared frame rate off its HTML text (the
* `data-fps` attribute motion/README.md instructs authors to set it
* appears on both `<html>` and `#stage` in every composition seen so far;
* a plain regex over the whole file catches either). Falls back to
* DEFAULT_FPS when absent, unparsable, or outside the sane broadcast bound
* never lets a bad attribute wedge the job at an undefined rate.
*/
export function parseFps(html) {
const match = /data-fps=["'](\d+)["']/.exec(html);
if (!match) return DEFAULT_FPS;
const fps = Number(match[1]);
if (!Number.isFinite(fps) || fps < MIN_FPS || fps > MAX_FPS) return DEFAULT_FPS;
return fps;
}
// Caps the DECOMPRESSED size (a gzip bomb inflates a tiny upload into a huge
// tar stream); MAX_UPLOAD_BYTES in server.js only bounds the compressed
// bytes on the wire.
@@ -142,6 +161,12 @@ export async function renderComposition({
throw new UnknownCompositionError(compositionId, knownIds);
}
const entryHtml = await readFile(
path.join(compositionDir, `${orientation}.html`),
"utf8",
);
const fps = parseFps(entryHtml);
// The HTML files <script src="props.js"></script> reads this — set up by
// Task T2, but the sidecar must write the file per render so the HTML
// picks up per-release content + orientation.
@@ -171,7 +196,7 @@ export async function renderComposition({
// out of the job config), and resolves the cut's HTML from entryFile
// relative to projectDir.
const job = createRenderJob({
fps: FPS,
fps,
quality: QUALITY,
format: "mp4",
entryFile: `${orientation}.html`,
+46
View File
@@ -0,0 +1,46 @@
// node --test: node's built-in runner, no dependency needed. Only parseFps is
// exercised (a pure function) — renderComposition needs a real Chromium
// render and is covered by manual/e2e verification instead.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseFps } from "./render.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const compositionsRoot = path.join(__dirname, "..", "motion", "compositions");
test("parseFps reads data-fps off a real composition file", async () => {
const html = await readFile(
path.join(compositionsRoot, "release-announcement", "vertical.html"),
"utf8",
);
assert.equal(parseFps(html), 30);
});
test("parseFps reads a declared 24fps composition", () => {
const html = `<div id="stage" data-fps="24"></div>`;
assert.equal(parseFps(html), 24);
});
test("parseFps falls back to 30 when the attribute is missing", () => {
assert.equal(parseFps("<html><body>no fps here</body></html>"), 30);
});
test("parseFps clamps below the 24-60 bound to the 30 fallback", () => {
assert.equal(parseFps(`<div data-fps="15"></div>`), 30);
});
test("parseFps clamps above the 24-60 bound to the 30 fallback", () => {
assert.equal(parseFps(`<div data-fps="120"></div>`), 30);
});
test("parseFps falls back to 30 on an unparsable value", () => {
assert.equal(parseFps(`<div data-fps="abc"></div>`), 30);
});
test("parseFps accepts the boundary values 24 and 60", () => {
assert.equal(parseFps(`<div data-fps="24"></div>`), 24);
assert.equal(parseFps(`<div data-fps="60"></div>`), 60);
});