fix(backlot): surface approval artifacts before gates

This commit is contained in:
calesthio
2026-07-18 01:33:12 -07:00
parent 5072b4647c
commit af87fc1337
5 changed files with 546 additions and 14 deletions

View File

@@ -69,6 +69,10 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
{
"name": s["name"],
"gated": bool(s.get("human_approval_default", False)),
"produces": [
str(name) for name in (s.get("produces") or [])
if isinstance(name, str) and name
],
}
for s in manifest.get("stages", [])
if isinstance(s, dict) and s.get("name")
@@ -83,7 +87,7 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
pass
return {
"pipeline_type": pipeline_type or "unknown",
"stages": [{"name": s, "gated": False} for s in FALLBACK_STAGES],
"stages": [{"name": s, "gated": False, "produces": []} for s in FALLBACK_STAGES],
"known": False,
}
@@ -152,6 +156,7 @@ def _build_stage_rail(
entry: dict[str, Any] = {
"name": name,
"gated": stage_def["gated"],
"produces": list(stage_def.get("produces") or []),
"status": status or "pending",
"timestamp": cp.get("timestamp") if cp else None,
"review": cp.get("review") if cp else None,
@@ -188,6 +193,11 @@ def _build_stage_rail(
entry = {
"name": name,
"gated": False,
"produces": [
str(artifact_name)
for artifact_name in (cp.get("artifacts") or {})
if isinstance(artifact_name, str) and artifact_name
],
"status": cp.get("status") or "unknown",
"timestamp": cp.get("timestamp"),
"review": cp.get("review"),

View File

@@ -496,6 +496,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
/* ---------- layout ---------- */
.board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; }
.board.solo { grid-template-columns: minmax(0, 1fr); }
.main-col { min-width: 0; display: flex; flex-direction: column; gap: 22px; }
.panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; }
@@ -565,12 +566,92 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em;
z-index: 2;
}
.script-approved {
.script-status {
position: absolute; top: 20px; right: 26px;
font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); font-weight: 600; letter-spacing: .14em;
color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px;
padding: 3px 8px; transform: rotate(6deg); opacity: .8;
}
.script-approved { color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px; }
.script-pending { color: #9a5b00; border: 1.5px solid #9a5b00; border-radius: 3px; }
.script-draft { color: #59657a; border: 1.5px solid #59657a; border-radius: 3px; }
/* ---------- approval review surface ---------- */
.approval-review {
width: 100%; max-width: 1000px; margin: 0 auto;
border: 1px solid color-mix(in srgb, var(--amber) 52%, var(--border-soft));
border-radius: 14px;
background:
radial-gradient(circle at 100% 0, color-mix(in srgb, var(--amber) 13%, transparent), transparent 42%),
var(--surface);
box-shadow: 0 28px 76px -54px rgba(0,0,0,.9);
overflow: hidden;
animation: rise .55s cubic-bezier(.2,.7,.3,1) .18s backwards;
}
.approval-review-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
padding: 24px 26px 20px; border-bottom: 1px solid var(--border-soft);
}
.approval-review-head h2 {
margin: 4px 0 6px; color: var(--text); font-size: calc(21px * var(--fs-scale));
line-height: 1.2; text-transform: capitalize;
}
.approval-review-head p { max-width: 650px; color: var(--text-2); font-size: calc(12.5px * var(--fs-scale)); line-height: 1.5; }
.approval-eyebrow, .approval-status, .approval-artifact-kicker,
.approval-selected, .approval-rationale b, .approval-review-note b,
.approval-review-foot, .approval-fact span {
font-family: var(--mono); letter-spacing: .11em; text-transform: uppercase;
}
.approval-eyebrow { color: var(--amber); font-size: calc(9.5px * var(--fs-scale)); }
.approval-status {
flex: none; color: var(--amber); border: 1px solid var(--amber); border-radius: 99px;
padding: 5px 9px; font-size: calc(9px * var(--fs-scale)); white-space: nowrap;
}
.approval-review-note {
margin: 18px 26px 0; padding: 10px 12px; border-left: 2px solid var(--amber);
background: color-mix(in srgb, var(--amber) 7%, var(--surface-2));
color: var(--text-2); font-size: calc(11.5px * var(--fs-scale)); line-height: 1.5;
}
.approval-review-note b { color: var(--amber); font-size: calc(9px * var(--fs-scale)); }
.approval-artifacts { display: grid; gap: 14px; padding: 20px 26px 22px; }
.approval-artifact {
padding: 20px; border: 1px solid var(--border-soft); border-radius: 10px;
background: color-mix(in srgb, var(--surface-2) 72%, transparent);
}
.approval-artifact-kicker { color: var(--amber); font-size: calc(9px * var(--fs-scale)); }
.approval-artifact > h2 { margin: 5px 0 14px; font-size: calc(18px * var(--fs-scale)); line-height: 1.25; }
.approval-lead { color: var(--text-2); font-size: calc(13px * var(--fs-scale)); line-height: 1.55; margin: 0 0 14px; }
.approval-facts { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.approval-fact {
display: grid; gap: 2px; min-width: 112px; padding: 8px 10px;
border: 1px solid var(--border-soft); border-radius: 7px; background: var(--surface);
}
.approval-fact span { color: var(--text-3); font-size: calc(8px * var(--fs-scale)); }
.approval-fact b { color: var(--text-2); font-size: calc(11px * var(--fs-scale)); font-weight: 600; overflow-wrap: anywhere; }
.approval-items { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 8px; }
.approval-items li { padding: 10px 12px; border-left: 2px solid var(--border); background: var(--surface); }
.approval-items li.selected { border-left-color: var(--amber); }
.approval-item-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--text); font-size: calc(12px * var(--fs-scale)); font-weight: 600; }
.approval-items p { color: var(--text-3); font-size: calc(11px * var(--fs-scale)); line-height: 1.45; margin-top: 3px; }
.approval-selected { color: var(--amber); font-size: calc(8px * var(--fs-scale)); font-weight: 500; }
.approval-rationale, .approval-guidance { color: var(--text-3); font-size: calc(10.5px * var(--fs-scale)); line-height: 1.5; margin-top: 12px; }
.approval-rationale b { color: var(--text-2); font-size: calc(8.5px * var(--fs-scale)); }
.approval-guidance { color: var(--amber); }
.approval-missing {
padding: 16px; border: 1px solid color-mix(in srgb, var(--red) 45%, var(--border));
border-radius: 9px; background: var(--red-dim); color: var(--text-2); font-size: calc(12px * var(--fs-scale));
}
.approval-missing b { color: var(--red); }
.approval-review-foot {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 14px 26px; border-top: 1px solid var(--border-soft);
color: var(--text-3); font-size: calc(9px * var(--fs-scale));
}
.approval-review-foot button {
border: 1px solid var(--border); border-radius: 99px; padding: 6px 11px;
background: var(--surface-2); color: var(--text-2); font: inherit; letter-spacing: inherit;
cursor: pointer;
}
.approval-review-foot button:hover, .approval-review-foot button:focus-visible { color: var(--amber); border-color: var(--amber); outline: none; }
/* ---------- right rail: decisions & activity ---------- */
.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); }
@@ -866,7 +947,8 @@ a { color: inherit; }
body:not(.first) .slate, body:not(.first) .rail .stage,
body:not(.first) .script-card, body:not(.first) .notice,
body:not(.first) aside .panel, body:not(.first) .scene-card,
body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
body:not(.first) .lib-card, body:not(.first) .drawer,
body:not(.first) .approval-review { animation: none; }
/* stages that ran but aren't declared by the pipeline manifest */
.stage.undeclared .node { border-style: dashed; opacity: .85; }
@@ -919,6 +1001,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
}
.section-title { flex-wrap: wrap; }
.section-title .meta { margin-left: 0; }
.approval-review-head { flex-wrap: wrap; gap: 12px; }
}
@media (max-width: 520px) {
@@ -940,7 +1023,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
padding: 24px 20px 28px;
border-radius: 5px;
}
.script-approved { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; }
.script-status { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; }
.script-card .sp-title { font-size: calc(14px * var(--fs-scale)); padding-right: 58px; }
.script-card .sp-meta { margin-bottom: 18px; }
.script-card .sp-slug .tc { float: none; display: block; margin-top: 2px; }
@@ -951,4 +1034,9 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
.drawer .drawer-head { flex-wrap: wrap; }
.drawer pre { font-size: calc(10.5px * var(--fs-scale)); }
.scene-card { max-width: calc(100vw - 42px); }
.approval-review-head, .approval-artifacts, .approval-review-foot { padding-left: 16px; padding-right: 16px; }
.approval-review-note { margin-left: 16px; margin-right: 16px; }
.approval-review-foot { align-items: flex-start; flex-direction: column; }
.approval-review-foot button { width: 100%; }
.approval-status { white-space: normal; }
}

View File

@@ -169,6 +169,36 @@ const STAGE_ARTIFACTS = {
publish: ["publish_log"],
};
function artifactNamesForStage(st) {
const declared = Array.isArray(st.produces) ? st.produces : [];
const fallback = STAGE_ARTIFACTS[st.name] || [];
return [...new Set([...declared, ...fallback].filter(Boolean))];
}
function reviewMetrics(review) {
const nested = review && review.summary && typeof review.summary === "object"
? review.summary : {};
return {
critical: Number((review && review.critical) ?? nested.critical ?? 0),
suggestions: Number((review && review.suggestions) ?? nested.suggestions ?? 0),
nitpicks: Number((review && review.nitpicks) ?? nested.nitpicks ?? 0),
};
}
function reviewSummaryText(review) {
if (!review) return "";
if (typeof review.summary === "string") return review.summary;
const nested = review.summary && typeof review.summary === "object" ? review.summary : {};
const counts = reviewMetrics(review);
return [
review.decision,
`${counts.critical} critical`,
`${counts.suggestions} suggestion${counts.suggestions === 1 ? "" : "s"}`,
nested.review_focus_met ? `review focus ${nested.review_focus_met}` : null,
nested.schema_validation,
].filter(Boolean).join(" · ");
}
function renderDrawer(s) {
if (!selectedStage) return null;
const st = s.stages.find((x) => x.name === selectedStage);
@@ -177,15 +207,17 @@ function renderDrawer(s) {
const body = el("div", { class: "drawer-body" });
if (st.review) {
const metrics = reviewMetrics(st.review);
const summary = reviewSummaryText(st.review);
body.append(el("div", { class: "findings", style: "margin-bottom:12px" },
el("span", { class: `f ${st.review.critical ? "crit" : ""}` }, `${st.review.critical ?? 0} critical`),
el("span", { class: `f ${st.review.suggestions ? "sugg" : ""}` }, `${st.review.suggestions ?? 0} suggestions`),
el("span", { class: "f" }, `${st.review.nitpicks ?? 0} nitpicks`),
typeof st.review.summary === "string" ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, st.review.summary) : null,
el("span", { class: `f ${metrics.critical ? "crit" : ""}` }, `${metrics.critical} critical`),
el("span", { class: `f ${metrics.suggestions ? "sugg" : ""}` }, `${metrics.suggestions} suggestions`),
el("span", { class: "f" }, `${metrics.nitpicks} nitpicks`),
summary ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, summary) : null,
));
}
const names = STAGE_ARTIFACTS[st.name] || [];
const names = artifactNamesForStage(st);
let shown = false;
for (const name of names) {
const artifact = s.artifacts[name];
@@ -243,10 +275,17 @@ function renderScriptCard(s) {
const script = s.artifacts.script;
if (!script) return null;
const scriptStage = s.stages.find((x) => x.name === "script");
const approved = scriptStage && scriptStage.status === "completed";
const status = scriptStage ? scriptStage.status : "unknown";
const stamp = status === "completed"
? el("span", { class: "script-status script-approved" }, "APPROVED")
: status === "awaiting_human"
? el("span", { class: "script-status script-pending" }, "PENDING APPROVAL")
: status === "in_progress"
? el("span", { class: "script-status script-draft" }, "DRAFTING")
: null;
const card = el("div", { class: "script-card script-preview", title: "Click to expand full script", onclick: openScriptModal },
approved ? el("span", { class: "script-approved" }, "APPROVED") : null,
stamp,
el("div", { class: "sp-title" }, script.title || s.title),
el("div", { class: "sp-meta" },
`script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`),
@@ -256,6 +295,238 @@ function renderScriptCard(s) {
return card;
}
function humanize(value) {
return String(value || "artifact").replaceAll("_", " ");
}
function shortText(value, limit = 180) {
const text = String(value || "").trim();
return text.length > limit ? `${text.slice(0, limit - 1)}` : text;
}
function reviewFact(label, value) {
if (value == null || value === "") return null;
return el("div", { class: "approval-fact" },
el("span", {}, label),
el("b", {}, value),
);
}
function reviewFacts(items) {
const facts = items.filter(Boolean);
return facts.length ? el("div", { class: "approval-facts" }, facts) : null;
}
function titledItems(items, selectedId = null) {
const rows = (items || []).slice(0, 4).map((item, index) => {
if (item == null) return null;
if (typeof item !== "object") {
return el("li", {}, shortText(item));
}
const id = item.id || item.concept_id || item.option_id;
const title = item.title || item.name || item.display_name || item.label || id || item.path || item.platform || item.description || `Item ${index + 1}`;
const detail = item.hook || item.why_this_works || item.summary || item.description || item.silhouette_notes;
return el("li", { class: id && id === selectedId ? "selected" : "" },
el("div", { class: "approval-item-title" }, shortText(title, 100),
id && id === selectedId ? el("span", { class: "approval-selected" }, "SELECTED") : null),
detail && detail !== title ? el("p", {}, shortText(detail)) : null,
);
}).filter(Boolean);
return rows.length ? el("ul", { class: "approval-items" }, rows) : null;
}
function genericArtifactSummary(artifact) {
const facts = [];
const items = [];
for (const [key, value] of Object.entries(artifact || {})) {
if (["version", "decision_log_ref"].includes(key)) continue;
if (["string", "number", "boolean"].includes(typeof value)) {
facts.push(reviewFact(humanize(key), shortText(value, 90)));
} else if (Array.isArray(value)) {
facts.push(reviewFact(humanize(key), `${value.length} item${value.length === 1 ? "" : "s"}`));
if (!items.length && value.length) items.push(titledItems(value));
}
if (facts.length >= 6) break;
}
return [reviewFacts(facts), ...items].filter(Boolean);
}
function artifactReviewContent(name, artifact) {
if (name === "brief") {
return [
artifact.hook ? el("p", { class: "approval-lead" }, artifact.hook) : null,
reviewFacts([
reviewFact("platform", artifact.target_platform),
reviewFact("duration", artifact.target_duration_seconds != null ? fmtDuration(artifact.target_duration_seconds) : null),
reviewFact("tone", artifact.tone),
reviewFact("style", artifact.style),
]),
titledItems(artifact.key_points),
].filter(Boolean);
}
if (name === "proposal_packet") {
const selected = (artifact.selected_concept || {}).concept_id;
const plan = artifact.production_plan || {};
const cost = artifact.cost_estimate || {};
return [
reviewFacts([
reviewFact("runtime", plan.render_runtime),
reviewFact("pipeline", plan.pipeline),
reviewFact("estimated cost", cost.total_estimated_usd != null ? fmtMoney(cost.total_estimated_usd) : null),
reviewFact("concepts", Array.isArray(artifact.concept_options) ? artifact.concept_options.length : null),
]),
titledItems(artifact.concept_options, selected),
(artifact.selected_concept || {}).rationale
? el("p", { class: "approval-rationale" },
el("b", {}, "WHY THIS CONCEPT "), shortText(artifact.selected_concept.rationale))
: null,
].filter(Boolean);
}
if (name === "research_brief") {
return [
artifact.topic ? el("p", { class: "approval-lead" }, artifact.topic) : null,
reviewFacts([
reviewFact("sources", Array.isArray(artifact.sources) ? artifact.sources.length : null),
reviewFact("data points", Array.isArray(artifact.data_points) ? artifact.data_points.length : null),
reviewFact("angles", Array.isArray(artifact.angles_discovered) ? artifact.angles_discovered.length : null),
]),
titledItems(artifact.angles_discovered),
].filter(Boolean);
}
if (name === "script") {
const first = (artifact.sections || [])[0];
return [
reviewFacts([
reviewFact("duration", fmtDuration(artifact.total_duration_seconds)),
reviewFact("sections", (artifact.sections || []).length),
]),
first && first.text ? el("p", { class: "approval-lead" }, shortText(first.text, 220)) : null,
el("p", { class: "approval-guidance" }, "The complete script preview is shown directly below."),
].filter(Boolean);
}
if (name === "scene_plan") {
const scenes = artifact.scenes || [];
const end = scenes.reduce((max, scene) => Math.max(max, Number(scene.end_seconds) || 0), 0);
return [
reviewFacts([
reviewFact("scenes", scenes.length),
reviewFact("duration", end ? fmtDuration(end) : null),
]),
titledItems(scenes),
el("p", { class: "approval-guidance" }, "Review timing and shot coverage in the storyboard below."),
].filter(Boolean);
}
if (name === "asset_manifest") {
const assets = artifact.assets || [];
const types = [...new Set(assets.map((asset) => asset.type).filter(Boolean))];
return [
reviewFacts([
reviewFact("assets", assets.length),
reviewFact("types", types.join(", ")),
reviewFact("generation cost", artifact.total_cost_usd != null ? fmtMoney(artifact.total_cost_usd) : null),
]),
titledItems(assets),
el("p", { class: "approval-guidance" }, "Inspect every generated take in the filmstrip below before approving compose."),
].filter(Boolean);
}
if (name === "edit_decisions") {
return [
reviewFacts([
reviewFact("cuts", Array.isArray(artifact.cuts) ? artifact.cuts.length : null),
reviewFact("runtime", artifact.render_runtime || (artifact.metadata || {}).render_runtime),
]),
titledItems(artifact.cuts),
].filter(Boolean);
}
if (name === "render_report") {
return [
reviewFacts([
reviewFact("outputs", Array.isArray(artifact.outputs) ? artifact.outputs.length : null),
reviewFact("duration", artifact.duration_seconds != null ? fmtDuration(artifact.duration_seconds) : null),
]),
titledItems(artifact.outputs),
].filter(Boolean);
}
if (name === "publish_log") {
return [
reviewFacts([reviewFact("destinations", Array.isArray(artifact.entries) ? artifact.entries.length : null)]),
titledItems((artifact.entries || []).map((entry) => ({
title: entry.platform || entry.destination || "Publish destination",
description: [entry.status, entry.url].filter(Boolean).join(" · "),
}))),
].filter(Boolean);
}
return genericArtifactSummary(artifact);
}
function artifactReviewTitle(name, artifact, s) {
if (name === "proposal_packet") {
const selected = (artifact.selected_concept || {}).concept_id;
const concept = (artifact.concept_options || []).find((item) => item.id === selected);
return (concept && concept.title) || "Production proposal";
}
if (name === "research_brief") return artifact.topic || "Research brief";
if (name === "scene_plan") return "Scene plan";
if (name === "asset_manifest") return "Generated assets";
if (name === "edit_decisions") return "Edit decisions";
if (name === "render_report") return "Render report";
if (name === "publish_log") return "Publish plan";
return artifact.title || artifact.name || s.title;
}
function renderApprovalReview(s) {
const awaiting = s.stages.find((item) => item.status === "awaiting_human");
if (!awaiting) return null;
const names = artifactNamesForStage(awaiting);
const entries = names
.filter((name) => name !== "decision_log")
.map((name) => [name, s.artifacts[name]])
.filter(([, artifact]) => artifact && typeof artifact === "object");
const stageIndex = s.stages.findIndex((item) => item.name === awaiting.name);
const nextStage = stageIndex >= 0 ? s.stages[stageIndex + 1] : null;
const review = awaiting.review || {};
const reviewSummary = reviewSummaryText(review);
const artifacts = entries.map(([name, artifact]) => el("article", {
class: "approval-artifact",
"data-artifact": name,
},
el("div", { class: "approval-artifact-kicker" }, humanize(name)),
el("h2", {}, artifactReviewTitle(name, artifact, s)),
...artifactReviewContent(name, artifact),
));
if (!artifacts.length) {
artifacts.push(el("div", { class: "approval-missing", role: "alert" },
el("b", {}, "Nothing reviewable was found. "),
names.length
? `The ${awaiting.name} checkpoint declares ${names.map(humanize).join(", ")}, but Backlot could not load it.`
: `The ${awaiting.name} checkpoint does not declare an artifact.`,
));
}
return el("section", { class: "approval-review", "data-stage": awaiting.name },
el("div", { class: "approval-review-head" },
el("div", {},
el("div", { class: "approval-eyebrow" }, "REVIEW GATE"),
el("h2", {}, `${humanize(awaiting.name)} is ready for your review`),
el("p", {}, "Review the artifact here, then reply in chat to approve it or request changes."),
),
el("span", { class: "approval-status" }, "PENDING APPROVAL"),
),
reviewSummary ? el("div", { class: "approval-review-note" },
el("b", {}, "SELF-REVIEW "), shortText(reviewSummary, 260)) : null,
el("div", { class: "approval-artifacts" }, artifacts),
el("div", { class: "approval-review-foot" },
el("span", {}, nextStage
? `Approval unlocks ${humanize(nextStage.name)}.`
: "This is the final approval gate."),
el("button", { type: "button", onclick: () => toggleDrawer(awaiting.name) }, "OPEN FULL ARTIFACT"),
),
);
}
function openScriptModal() {
const script = state && state.artifacts.script;
if (!script) return;
@@ -799,6 +1070,8 @@ function render() {
if (noState) app.append(noState);
const main = el("div", { class: "main-col" });
const approvalReview = renderApprovalReview(s);
if (approvalReview) main.append(approvalReview);
const script = renderScriptCard(s);
if (script) main.append(script);
const aside = el("aside", {});
@@ -813,11 +1086,12 @@ function render() {
const found = renderFoundMedia(s);
const renders = renderRenders(s);
if (script || decisions || activity) {
if (approvalReview || script || decisions || activity) {
for (const section of [storyboard, found, renders]) {
if (section) main.append(section);
}
app.append(el("div", { class: "board" }, main, aside));
const hasAside = Boolean(decisions || activity);
app.append(el("div", { class: `board${hasAside ? "" : " solo"}` }, main, hasAside ? aside : null));
} else {
for (const section of [storyboard, found, renders]) {
if (section) app.append(section);
@@ -830,6 +1104,9 @@ function render() {
function normalize(s) {
s.pipeline = s.pipeline || { pipeline_type: "unknown", stages: [], known: false };
s.stages = Array.isArray(s.stages) ? s.stages : [];
for (const stage of s.stages) {
stage.produces = Array.isArray(stage.produces) ? stage.produces : [];
}
s.artifacts = s.artifacts || {};
s.media = s.media || {};
s.media.renders = Array.isArray(s.media.renders) ? s.media.renders : [];

View File

@@ -91,6 +91,9 @@ class TestBoardState:
assert sc2["visual"]["exists"] is False # missing file flagged
script_stage = next(x for x in s["stages"] if x["name"] == "script")
assert script_stage["status"] == "completed"
assert script_stage["produces"] == ["script"]
proposal_stage = next(x for x in s["stages"] if x["name"] == "proposal")
assert proposal_stage["produces"] == ["proposal_packet", "decision_log"]
def test_gate_skip_detection(self, projects_root):
p = _make_project(projects_root, "sneaky")

View File

@@ -10,16 +10,102 @@ import urllib.request
import pytest
from lib.checkpoint import init_project, write_checkpoint
from scripts import backlot_screenshot_stage
from tests.contracts.test_phase0_contracts import sample_artifact
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import sync_playwright # noqa: E402
APPROVAL_CASES = [
("gate-research", "framework-smoke", "research", "research_brief", "Test Topic"),
("gate-idea", "hybrid", "idea", "brief", "Did you know?"),
("gate-proposal", "cinematic", "proposal", "proposal_packet", "The Surprising Truth About X"),
("gate-script", "cinematic", "script", "script", "Hello world"),
("gate-scene-plan", "cinematic", "scene_plan", "scene_plan", "Host on camera"),
("gate-assets", "cinematic", "assets", "asset_manifest", "asset-1"),
("gate-edit", "documentary-montage", "edit", "edit_decisions", "cut-1"),
("gate-compose", "cinematic", "compose", "render_report", "renders/output.mp4"),
("gate-publish", "cinematic", "publish", "publish_log", "youtube"),
]
def _build_approval_projects() -> None:
root = backlot_screenshot_stage.STAGE_DIR
for project_id, pipeline_type, stage, artifact_name, _visible_text in APPROVAL_CASES:
artifact = sample_artifact(artifact_name)
if artifact_name == "edit_decisions":
artifact["render_runtime"] = "ffmpeg"
review_summary = (
{
"critical": 0,
"suggestions": 1,
"nitpicks": 0,
"review_focus_met": "9/9",
"schema_validation": "proposal_packet PASS",
}
if stage == "proposal"
else "Artifact is ready for human review."
)
init_project(
project_id,
title=f"Approval fixture: {stage}",
pipeline_type=pipeline_type,
pipeline_dir=root,
)
write_checkpoint(
root,
project_id,
stage,
"awaiting_human",
{artifact_name: artifact},
pipeline_type=pipeline_type,
review={
"round": 1,
"decision": "pass",
"critical": 0,
"suggestions": 1,
"nitpicks": 0,
"summary": review_summary,
},
)
# A manifest-declared custom stage/artifact proves the fallback is driven
# by the stage contract rather than a hardcoded canonical-stage list.
init_project(
"gate-character-design",
title="Approval fixture: character design",
pipeline_type="character-animation",
pipeline_dir=root,
)
write_checkpoint(
root,
"gate-character-design",
"character_design",
"awaiting_human",
{"character_design": {
"version": "1.0",
"characters": [{
"id": "ada",
"display_name": "Ada",
"role": "explorer",
"body_type": "round",
"style": "flat graphic",
"silhouette_notes": "Round explorer with a bright orange field jacket",
"required_emotions": ["curious"],
"required_actions": ["wave"],
}],
}},
pipeline_type="character-animation",
)
@pytest.fixture(scope="module")
def staged_backlot_server():
backlot_screenshot_stage.build_stage()
_build_approval_projects()
port = 4897
env = dict(os.environ)
env["OPENMONTAGE_PROJECTS_DIR"] = str(backlot_screenshot_stage.STAGE_DIR)
@@ -57,6 +143,8 @@ def test_project_pages_fit_mobile_and_tablet_widths(staged_backlot_server):
"/p/the-slow-orchard?static=1",
"/p/the-last-lighthouse?static=1",
"/p/paper-boats?static=1",
"/p/gate-proposal?static=1",
"/p/gate-character-design?static=1",
]
viewports = [
{"width": 390, "height": 844},
@@ -108,3 +196,69 @@ def test_static_navigation_invalid_route_and_active_takes(staged_backlot_server)
assert page.locator(".takes .tk.active").count() >= 1
finally:
browser.close()
@pytest.mark.parametrize(
("project_id", "_pipeline_type", "stage", "artifact_name", "visible_text"),
APPROVAL_CASES,
)
def test_every_canonical_gate_promotes_its_artifact_before_approval(
staged_backlot_server,
project_id,
_pipeline_type,
stage,
artifact_name,
visible_text,
):
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 900})
try:
page.goto(
staged_backlot_server + f"/p/{project_id}?static=1",
wait_until="networkidle",
)
review = page.locator(f'.approval-review[data-stage="{stage}"]')
assert review.is_visible()
assert review.get_by_text("PENDING APPROVAL", exact=True).is_visible()
assert "[object Object]" not in review.inner_text()
artifact = review.locator(f'[data-artifact="{artifact_name}"]')
assert artifact.is_visible()
assert visible_text in artifact.inner_text()
review.get_by_role("button", name="OPEN FULL ARTIFACT").click()
assert page.locator(".drawer").is_visible()
assert visible_text in page.locator(".drawer").inner_text()
finally:
browser.close()
def test_script_gate_keeps_script_visible_and_marks_pending_approval(staged_backlot_server):
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 900})
try:
page.goto(staged_backlot_server + "/p/gate-script?static=1", wait_until="networkidle")
assert page.locator(".script-card").is_visible()
assert page.locator(".script-pending").inner_text() == "PENDING APPROVAL"
finally:
browser.close()
def test_manifest_declared_custom_gate_uses_generic_review_fallback(staged_backlot_server):
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 900})
try:
page.goto(
staged_backlot_server + "/p/gate-character-design?static=1",
wait_until="networkidle",
)
review = page.locator('.approval-review[data-stage="character_design"]')
assert review.is_visible()
artifact = review.locator('[data-artifact="character_design"]')
assert artifact.is_visible()
assert "Ada" in artifact.inner_text()
assert "Round explorer" in artifact.inner_text()
finally:
browser.close()