From ab7e5e3af485d80ef60b28dde049e35a9f478076 Mon Sep 17 00:00:00 2001 From: "Tessa (livepeer-tessa)" Date: Wed, 15 Apr 2026 06:16:56 +0000 Subject: [PATCH 1/2] fix: downgrade bundled plugin uninstall from ERROR to WARNING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempting to uninstall a bundled plugin is an expected, handled condition — not a server-side failure. This was generating ERROR-level log noise on every prod fal.ai job that triggered the cleanup path (issue #837). Changes: - Add PluginBundledError(PluginInstallError) subclass in manager.py so callers can distinguish bundled-rejection from true install failures - Raise PluginBundledError instead of PluginInstallError in _uninstall_plugin_sync when a bundled plugin is targeted - In app.py uninstall endpoint, catch PluginBundledError before the generic PluginInstallError handler: log at WARNING, return HTTP 400 (client error) instead of 500 (server error) Fixes: #837 Signed-off-by: Tessa (livepeer-tessa) --- src/scope/core/plugins/__init__.py | 2 ++ src/scope/core/plugins/manager.py | 8 +++++++- src/scope/server/app.py | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/scope/core/plugins/__init__.py b/src/scope/core/plugins/__init__.py index a681707d7..fcf208413 100644 --- a/src/scope/core/plugins/__init__.py +++ b/src/scope/core/plugins/__init__.py @@ -3,6 +3,7 @@ from .hookspecs import hookimpl from .manager import ( FailedPluginInfo, + PluginBundledError, PluginDependencyError, PluginInstallError, PluginInUseError, @@ -32,4 +33,5 @@ "PluginNameCollisionError", "PluginDependencyError", "PluginInstallError", + "PluginBundledError", ] diff --git a/src/scope/core/plugins/manager.py b/src/scope/core/plugins/manager.py index 87ee5a48a..399bea71e 100644 --- a/src/scope/core/plugins/manager.py +++ b/src/scope/core/plugins/manager.py @@ -80,6 +80,12 @@ class PluginInstallError(Exception): pass +class PluginBundledError(PluginInstallError): + """Attempted to uninstall a bundled (built-in) plugin.""" + + pass + + @dataclass(frozen=True) class FailedPluginInfo: """Information about a plugin entry point that failed to load.""" @@ -1360,7 +1366,7 @@ def _uninstall_plugin_sync( # Prevent uninstalling bundled plugins if plugin_info.get("bundled"): - raise PluginInstallError( + raise PluginBundledError( f"Plugin '{name}' is bundled and cannot be uninstalled" ) diff --git a/src/scope/server/app.py b/src/scope/server/app.py index 470771551..564a392a1 100644 --- a/src/scope/server/app.py +++ b/src/scope/server/app.py @@ -2957,6 +2957,7 @@ async def uninstall_plugin( cloud-hosted scope backend. """ from scope.core.plugins import ( + PluginBundledError, PluginInstallError, PluginNotFoundError, get_plugin_manager, @@ -2991,6 +2992,12 @@ async def uninstall_plugin( status_code=404, detail=f"Plugin '{name}' not found", ) from e + except PluginBundledError as e: + logger.warning(f"Plugin uninstall rejected (bundled plugin): {name} - {e}") + raise HTTPException( + status_code=400, + detail=str(e), + ) from e except PluginInstallError as e: logger.error(f"Plugin uninstall failed: {name} - {e}") raise HTTPException( From 76b7cbc89b04d23a47322b3eb259ffcd96e7b403 Mon Sep 17 00:00:00 2001 From: "Tessa (livepeer-tessa)" Date: Wed, 15 Apr 2026 18:19:50 +0000 Subject: [PATCH 2/2] fix: validate graph sink nodes before stream start, improve error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #947 Root cause: when a graph contains only Output nodes (Spout/NDI/Syphon) and all of them have outputSinkEnabled=false, the serialization step in flowToGraphConfig() skips them — leaving the backend graph with no sink nodes, which triggers 'Invalid graph: Graph must have at least one sink node' from frame_processor. Changes: - frontend/graphUtils.ts: add validateGraphForStream() utility that detects missing/disabled sink nodes and returns a human-readable error before the graph is submitted to the backend - GraphEditor.tsx: expose validateForStream() on GraphEditorHandle so StreamPage can call it before starting the stream - StreamPage.tsx: call validateForStream() in handleStartStream() for graph mode; show toast.error with actionable message instead of failing server-side - OutputNode.tsx: show amber warning banner when output node is disabled, prompting user to enable it or add a Sink node - graph_schema.py: improve the server-side error message to include remediation hint ('Add a Preview (Sink) node or enable an Output node') as a last line of defense - tests/test_graph_schema.py: add 10 unit tests for validate_structure() including the user-friendly error message requirement Signed-off-by: Tessa (livepeer-tessa) --- frontend/src/components/graph/GraphEditor.tsx | 6 ++ .../src/components/graph/nodes/OutputNode.tsx | 10 ++ frontend/src/lib/graphUtils.ts | 69 ++++++++++++ frontend/src/pages/StreamPage.tsx | 9 ++ src/scope/server/graph_schema.py | 5 +- tests/test_graph_schema.py | 100 ++++++++++++++++++ 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 tests/test_graph_schema.py diff --git a/frontend/src/components/graph/GraphEditor.tsx b/frontend/src/components/graph/GraphEditor.tsx index 15446cc80..e3d6808a4 100644 --- a/frontend/src/components/graph/GraphEditor.tsx +++ b/frontend/src/components/graph/GraphEditor.tsx @@ -73,6 +73,7 @@ import { createDaydreamImportSession } from "../../lib/daydreamExport"; import { openExternalUrl } from "../../lib/openExternal"; import { buildPaneMenuItems, buildNodeMenuItems } from "./contextMenuItems"; import type { FlowNodeData } from "../../lib/graphUtils"; +import { validateGraphForStream } from "../../lib/graphUtils"; import { AlertDialog, AlertDialogAction, @@ -138,6 +139,8 @@ const edgeTypes = { export interface GraphEditorHandle { refreshGraph: () => void; + /** Validate the current graph before streaming. Returns an error string or null if valid. */ + validateForStream: () => string | null; getCurrentGraphConfig: () => import("../../lib/api").GraphConfig; getGraphNodePrompts: () => Array<{ nodeId: string; text: string }>; getGraphVaceSettings: () => Array<{ @@ -369,6 +372,7 @@ export const GraphEditor = forwardRef( ref, () => ({ refreshGraph, + validateForStream: () => validateGraphForStream(nodes, edges), getCurrentGraphConfig, getGraphNodePrompts, getGraphVaceSettings, @@ -387,6 +391,8 @@ export const GraphEditor = forwardRef( }), [ refreshGraph, + nodes, + edges, getCurrentGraphConfig, getGraphNodePrompts, getGraphVaceSettings, diff --git a/frontend/src/components/graph/nodes/OutputNode.tsx b/frontend/src/components/graph/nodes/OutputNode.tsx index 9cea9f48f..d3b11b871 100644 --- a/frontend/src/components/graph/nodes/OutputNode.tsx +++ b/frontend/src/components/graph/nodes/OutputNode.tsx @@ -73,6 +73,10 @@ export function OutputNode({ id, data, selected }: NodeProps) { const typeLabel = OUTPUT_TYPE_OPTIONS.find(o => o.value === sinkType)?.label ?? sinkType; + // Check if this is the only output node and it's disabled — we can't tell + // from within the node itself, so we just show a warning when disabled. + const showDisabledWarning = !enabled; + return ( ) { /> {!collapsed && (
+ {showDisabledWarning && ( +
+ ⚠ Disabled — stream will not output unless a Preview (Sink) node + is also connected. +
+ )}
[], + edges: Edge[] +): string | null { + const flatNodes = nodes; + + // Check for pipeline nodes + const hasPipeline = flatNodes.some(n => n.data.nodeType === "pipeline"); + if (!hasPipeline) { + return "Your graph has no pipeline nodes. Add at least one pipeline to your graph before starting."; + } + + // Check for sink nodes (type === "sink" in backend, nodeType === "sink" in frontend) + const hasSinkNode = flatNodes.some(n => n.data.nodeType === "sink"); + + // Check for enabled output nodes (these become backend sink nodes) + const hasEnabledOutput = flatNodes.some( + n => + n.data.nodeType === "output" && + ((n.data.outputSinkEnabled as boolean) ?? false) + ); + + // Check if there are any output nodes at all (even disabled) + const hasDisabledOutputOnly = + !hasSinkNode && + !hasEnabledOutput && + flatNodes.some(n => n.data.nodeType === "output"); + + if (hasDisabledOutputOnly) { + return "Your graph has no active output. Enable an Output node (Spout/NDI/Syphon) or add a Preview (Sink) node to see results."; + } + + if (!hasSinkNode && !hasEnabledOutput) { + return "Your graph has no output node. Add a Preview (Sink) node or an Output node to your graph before starting."; + } + + // Check that at least one pipeline feeds into a sink + const pipelineIds = new Set( + flatNodes.filter(n => n.data.nodeType === "pipeline").map(n => n.id) + ); + const sinkIds = new Set([ + ...flatNodes.filter(n => n.data.nodeType === "sink").map(n => n.id), + ...flatNodes + .filter( + n => + n.data.nodeType === "output" && + ((n.data.outputSinkEnabled as boolean) ?? false) + ) + .map(n => n.id), + ]); + + const connectedToSink = edges.some( + e => pipelineIds.has(e.source) && sinkIds.has(e.target) + ); + if (pipelineIds.size > 0 && sinkIds.size > 0 && !connectedToSink) { + return "Your pipeline is not connected to an output node. Connect a pipeline to a Preview or Output node before starting."; + } + + return null; +} + // Default node dimensions for reference export { NODE_WIDTH, NODE_HEIGHT }; diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index c9db65363..09f2cf682 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -75,6 +75,7 @@ import { applyHardwareInputSourceToLinearGraph, linearGraphFromSettings, stripUIFields, + validateGraphForStream, } from "../lib/graphUtils"; import { resolveLoRAPath } from "../lib/workflowSettings"; import { useLoRAsContext } from "../contexts/LoRAsContext"; @@ -2255,6 +2256,14 @@ export function StreamPage() { if (graphMode || nonLinearGraph) { try { + // Validate graph structure before sending to backend + const graphValidationError = + graphEditorRef.current?.validateForStream(); + if (graphValidationError) { + toast.error(graphValidationError); + return false; + } + // Read graph from frontend React state (always up-to-date) const frontendGraph = graphEditorRef.current?.getCurrentGraphConfig(); if (frontendGraph) { diff --git a/src/scope/server/graph_schema.py b/src/scope/server/graph_schema.py index 26f95341f..40b70f4e9 100644 --- a/src/scope/server/graph_schema.py +++ b/src/scope/server/graph_schema.py @@ -143,7 +143,10 @@ def validate_structure(self) -> list[str]: # At least one sink if not self.get_sink_node_ids(): - errors.append("Graph must have at least one sink node") + errors.append( + "Graph must have at least one sink node. " + "Add a Preview (Sink) node to your graph, or enable an Output node (Spout/NDI/Syphon)." + ) # Pipeline nodes must have pipeline_id for node in self.nodes: diff --git a/tests/test_graph_schema.py b/tests/test_graph_schema.py new file mode 100644 index 000000000..5dd3c8b47 --- /dev/null +++ b/tests/test_graph_schema.py @@ -0,0 +1,100 @@ +"""Tests for graph_schema.GraphConfig.validate_structure().""" +import pytest +from scope.server.graph_schema import GraphConfig, GraphNode, GraphEdge + + +def _make_graph(nodes: list[GraphNode], edges: list[GraphEdge] | None = None) -> GraphConfig: + return GraphConfig(nodes=nodes, edges=edges or []) + + +def _pipeline_node(node_id: str = "p1", pipeline_id: str = "pipe-1") -> GraphNode: + return GraphNode(id=node_id, type="pipeline", pipeline_id=pipeline_id) + + +def _sink_node(node_id: str = "sink") -> GraphNode: + return GraphNode(id=node_id, type="sink") + + +def _source_node(node_id: str = "source") -> GraphNode: + return GraphNode(id=node_id, type="source") + + +def _edge(from_node: str, to_node: str) -> GraphEdge: + return GraphEdge(**{"from": from_node, "from_port": "video", "to_node": to_node, "to_port": "video", "kind": "stream"}) + + +class TestValidateStructure: + def test_valid_minimal_graph(self): + graph = _make_graph( + nodes=[_pipeline_node(), _sink_node()], + edges=[_edge("p1", "sink")], + ) + assert graph.validate_structure() == [] + + def test_valid_with_source(self): + graph = _make_graph( + nodes=[_source_node(), _pipeline_node(), _sink_node()], + edges=[_edge("source", "p1"), _edge("p1", "sink")], + ) + assert graph.validate_structure() == [] + + def test_no_sink_node_returns_error(self): + graph = _make_graph(nodes=[_pipeline_node()]) + errors = graph.validate_structure() + assert len(errors) == 1 + assert "sink node" in errors[0] + + def test_no_sink_error_message_is_user_friendly(self): + """Error message should hint at how to fix, not just describe the problem.""" + graph = _make_graph(nodes=[_pipeline_node()]) + errors = graph.validate_structure() + msg = errors[0] + # Should mention the fix (add a Preview or Output node) + assert "Preview" in msg or "Output" in msg or "output" in msg + + def test_duplicate_node_ids(self): + graph = _make_graph( + nodes=[_pipeline_node("p1"), _pipeline_node("p1"), _sink_node()], + ) + errors = graph.validate_structure() + assert any("Duplicate" in e for e in errors) + + def test_pipeline_missing_pipeline_id(self): + graph = _make_graph( + nodes=[GraphNode(id="p1", type="pipeline"), _sink_node()], + ) + errors = graph.validate_structure() + assert any("missing pipeline_id" in e for e in errors) + + def test_edge_references_nonexistent_source(self): + graph = _make_graph( + nodes=[_pipeline_node(), _sink_node()], + edges=[_edge("nonexistent", "sink")], + ) + errors = graph.validate_structure() + assert any("nonexistent" in e for e in errors) + + def test_edge_references_nonexistent_target(self): + graph = _make_graph( + nodes=[_pipeline_node(), _sink_node()], + edges=[_edge("p1", "does-not-exist")], + ) + errors = graph.validate_structure() + assert any("does-not-exist" in e for e in errors) + + def test_sink_with_sink_mode_counts_as_sink(self): + """An output node (Spout/NDI/Syphon) with sink_mode set should count as a valid sink.""" + output_node = GraphNode(id="spout-out", type="sink", sink_mode="spout") + graph = _make_graph( + nodes=[_pipeline_node(), output_node], + edges=[_edge("p1", "spout-out")], + ) + assert graph.validate_structure() == [] + + def test_multiple_errors_returned(self): + """Multiple structural errors should all be reported.""" + graph = _make_graph( + nodes=[GraphNode(id="p1", type="pipeline")], # no pipeline_id, no sink + ) + errors = graph.validate_structure() + assert len(errors) >= 2