mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-17 22:08:48 +08:00
backlot: fix all five dogfood findings (F-01..F-05)
- F-01: cost bar crit (red) state past 90% of budget - F-02: normalize() hardens fetched board state against sparse payloads - F-03: /thumb 404s for videos with no extractable poster frame instead of serving raw video bytes - F-04: checkpoint artifact path refs only resolve inside the project dir - F-05 (board half): stall detection — in_progress stage with no disk activity >10min renders red 'stalled?' + header badge flips to STALLED?; verified against the real wedged why-cities-glow project - eval harness from dogfood session committed (visual regression + interaction smoke, capture watcher, server/gate test suites) + regression tests for each finding; 46 backlot tests green, visual eval green (restage-before-capture note logged)
This commit is contained in:
@@ -239,7 +239,11 @@ def create_app() -> FastAPI:
|
||||
width = min(THUMB_WIDTHS, key=lambda x: abs(x - w))
|
||||
cached = await asyncio.to_thread(_thumbnail_for, target, width)
|
||||
if cached is None:
|
||||
return FileResponse(target) # not an image we can thumb — serve as-is
|
||||
# Never fall back to raw video bytes for an <img> consumer (F-03);
|
||||
# non-thumbable images are safe to serve as-is.
|
||||
if target.suffix.lower() in {".mp4", ".webm", ".mov"}:
|
||||
raise HTTPException(status_code=404, detail="no poster frame available")
|
||||
return FileResponse(target)
|
||||
return FileResponse(cached, media_type="image/jpeg")
|
||||
|
||||
# ---- Media (range requests handled by FileResponse) ---------------
|
||||
|
||||
@@ -31,6 +31,11 @@ FALLBACK_STAGES = [
|
||||
# How long (seconds) without filesystem activity before a board reads "idle".
|
||||
LIVE_WINDOW_SECONDS = 5 * 60
|
||||
|
||||
# An in_progress stage with no filesystem activity for this long is flagged
|
||||
# as possibly stalled (F-05: a wedged agent must be visible, not silent —
|
||||
# heartbeat checkpoints and tool events both reset the clock).
|
||||
STALL_WINDOW_SECONDS = 10 * 60
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Optional[dict]:
|
||||
"""Read a JSON file, returning None on any failure."""
|
||||
@@ -84,13 +89,22 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _resolve_artifact(project_dir: Path, value: Any) -> Optional[dict]:
|
||||
"""Checkpoint artifacts may be inline dicts or path strings — resolve both."""
|
||||
"""Checkpoint artifacts may be inline dicts or path strings — resolve both.
|
||||
|
||||
Path references are only followed INSIDE the project directory: a
|
||||
checkpoint must not be able to pull arbitrary JSON from elsewhere on
|
||||
disk onto the board (F-04).
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
p = Path(value)
|
||||
if not p.is_absolute():
|
||||
p = project_dir / value
|
||||
try:
|
||||
p.resolve().relative_to(Path(project_dir).resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return _read_json(p)
|
||||
return None
|
||||
|
||||
@@ -553,6 +567,16 @@ def load_board_state(project_dir: Path) -> dict[str, Any]:
|
||||
last_activity = _last_activity(project_dir)
|
||||
now = time.time()
|
||||
|
||||
# Stall detection: an in_progress stage that stopped writing anything.
|
||||
for stage_entry in stages:
|
||||
if (
|
||||
stage_entry["status"] == "in_progress"
|
||||
and last_activity
|
||||
and (now - last_activity) > STALL_WINDOW_SECONDS
|
||||
):
|
||||
stage_entry["stalled"] = True
|
||||
stage_entry["stalled_minutes"] = int((now - last_activity) / 60)
|
||||
|
||||
state: dict[str, Any] = {
|
||||
"project_id": project_id,
|
||||
"title": marker.get("title") or meta_json.get("name") or project_id.replace("-", " ").title(),
|
||||
|
||||
@@ -524,3 +524,11 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
/* stages that ran but aren't declared by the pipeline manifest */
|
||||
.stage.undeclared .node { border-style: dashed; opacity: .85; }
|
||||
.stage.undeclared .name { font-style: italic; }
|
||||
|
||||
/* spend past 90% of budget */
|
||||
.cost .bar i.crit { background: var(--red); }
|
||||
|
||||
/* in_progress stage with no filesystem activity for a while (F-05) */
|
||||
.stage.stalled .node { border-color: var(--red); color: var(--red); background: var(--red-dim); animation: none; }
|
||||
.stage.stalled .name { color: var(--red); }
|
||||
.stage.stalled .sub { color: var(--red); }
|
||||
|
||||
@@ -32,9 +32,13 @@ function renderSlate(s) {
|
||||
|
||||
const awaiting = s.stages.find((x) => x.status === "awaiting_human");
|
||||
const inProgress = s.stages.find((x) => x.status === "in_progress");
|
||||
const stalled = s.stages.find((x) => x.stalled);
|
||||
let liveEl;
|
||||
if (awaiting) {
|
||||
liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "◈ AWAITING YOU");
|
||||
} else if (stalled) {
|
||||
liveEl = el("span", { class: "live", style: "color:var(--red)" },
|
||||
el("span", { class: "dot", style: "background:var(--red);animation:none" }), "⚠ STALLED?");
|
||||
} else if (s.live || inProgress) {
|
||||
liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "LIVE");
|
||||
} else {
|
||||
@@ -52,7 +56,7 @@ function renderSlate(s) {
|
||||
hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : ""));
|
||||
if (hasBudget) {
|
||||
cost.append(el("div", { class: "bar" }, el("i", {
|
||||
class: pct > 75 ? "warn" : "", style: `width:${pct}%`,
|
||||
class: pct > 90 ? "crit" : pct > 75 ? "warn" : "", style: `width:${pct}%`,
|
||||
})));
|
||||
}
|
||||
cost.append(el("div", { class: "label" }, "generation spend"));
|
||||
@@ -77,6 +81,9 @@ function renderSlate(s) {
|
||||
|
||||
function stageSub(st) {
|
||||
if (st.status === "awaiting_human") return "awaiting your approval\nreply in chat to continue";
|
||||
if (st.status === "in_progress" && st.stalled) {
|
||||
return `stalled? no activity for ${st.stalled_minutes}m\nask the agent for status`;
|
||||
}
|
||||
if (st.status === "in_progress" && st.partial_progress) {
|
||||
const done = st.partial_progress.completed_scene_ids;
|
||||
if (Array.isArray(done)) return `${done.length} scene${done.length === 1 ? "" : "s"} done`;
|
||||
@@ -96,7 +103,7 @@ function renderRail(s) {
|
||||
let pendingIndex = 1;
|
||||
for (const st of s.stages) {
|
||||
const cls = st.status === "completed" ? "done"
|
||||
: st.status === "in_progress" ? "active"
|
||||
: st.status === "in_progress" ? (st.stalled ? "active stalled" : "active")
|
||||
: st.status === "awaiting_human" ? "await"
|
||||
: st.status === "failed" ? "failed" : "";
|
||||
const icon = STAGE_ICONS[st.status] || String(pendingIndex);
|
||||
@@ -717,8 +724,31 @@ function render() {
|
||||
if (renders) app.append(renders);
|
||||
}
|
||||
|
||||
// Defensive normalization (F-02): the server contract guarantees these
|
||||
// fields, but a sparse/legacy payload must degrade, never crash the board.
|
||||
function normalize(s) {
|
||||
s.pipeline = s.pipeline || { pipeline_type: "unknown", stages: [], known: false };
|
||||
s.stages = Array.isArray(s.stages) ? s.stages : [];
|
||||
s.artifacts = s.artifacts || {};
|
||||
s.media = s.media || {};
|
||||
s.media.renders = Array.isArray(s.media.renders) ? s.media.renders : [];
|
||||
s.media.snapshots = Array.isArray(s.media.snapshots) ? s.media.snapshots : [];
|
||||
s.media.music = Array.isArray(s.media.music) ? s.media.music : [];
|
||||
s.events = Array.isArray(s.events) ? s.events : [];
|
||||
if (s.storyboard && Array.isArray(s.storyboard.scenes)) {
|
||||
for (const c of s.storyboard.scenes) {
|
||||
c.takes = Array.isArray(c.takes) ? c.takes : [];
|
||||
c.audio = Array.isArray(c.audio) ? c.audio : [];
|
||||
c.required_assets = Array.isArray(c.required_assets) ? c.required_assets : [];
|
||||
}
|
||||
} else {
|
||||
s.storyboard = null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
state = await getJSON(`/api/project/${encodeURIComponent(projectId)}/state`);
|
||||
state = normalize(await getJSON(`/api/project/${encodeURIComponent(projectId)}/state`));
|
||||
render();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user