mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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'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>
|
||||
|
||||
@@ -41,6 +41,7 @@ export { videoApi, videoMediaUrl } from "./video";
|
||||
export type {
|
||||
VideoCut,
|
||||
VideoPost,
|
||||
VideoPipelineItem,
|
||||
VideoPostExecuteResult,
|
||||
VideoPostHistoryEntry,
|
||||
VideoRequestResult,
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user