Skip to content

Commit 9caab9e

Browse files
dmitratclaude
andcommitted
fix(bridge): validate/preflight carry the render's target scope - v1.0.10
The 1.0.9 group-launch fix was necessary but not sufficient: the launch pipeline is upload -> VALIDATE -> PREFLIGHT -> render submit, and while the render submit is scoped to the selected group/project, validate and preflight went through Scripts.RunAsync - an UNSCOPED all-clients submit. The engine (rightly) rejects that for accounts without the global grant, so a non-admin died at validation with "not authorized to launch on all clients" before the correctly-scoped render was ever reached. Admin accounts sailed through, which is why the bug only shows on the self-serve flow. - BridgeRenderLaunchService: validate + preflight (and the runtime diagnostics probe) submit via SubmitAsync with the same optional group/project the render uses. - Channel: RunRenderValidateBlendScopedAsync / RunRenderPreflightScopedAsync - scope ids travel as strings ("" = none) so ONE method covers group/project/none in the name+count REST binder; unscoped callers keep the legacy methods. - Addon: _run_validate_blend/_run_preflight pass the resolved target from _get_selected_target. Error UX (operator report): the raw HTTP-500 JSON envelope used to land in a screen-wide popup. bridge_client now extracts ErrorMessage from the envelope, and the launch operator reports a one-sentence popup (innermost engine clause, 160-char cap) while the full text stays in the panel's error field with the Copy button. Suites: py 88+127, C# 33+7 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b4d9371 commit 9caab9e

9 files changed

Lines changed: 210 additions & 29 deletions

File tree

OutWit.Render.BlenderAddon/Tests/bridge_operator_policy_tests.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,32 @@ def test_no_targets_at_all_resolves_to_unscoped(self):
503503
self.assertEqual(bridge_operators._get_selected_target(state), ("", ""))
504504

505505

506+
class ShortErrorMessageTests(unittest.TestCase):
507+
"""The popup gets one readable sentence; the full text stays in the panel's Copy field."""
508+
509+
def test_extracts_the_innermost_parenthesised_engine_clause(self):
510+
raw = ("Failed to process request: One or more errors occurred. "
511+
"(Script submission failed: User 'abc' is not authorized to launch on all clients.)")
512+
self.assertEqual(
513+
bridge_operators._short_error_message(Exception(raw)),
514+
"Script submission failed: User 'abc' is not authorized to launch on all clients.")
515+
516+
def test_plain_message_passes_through(self):
517+
self.assertEqual(
518+
bridge_operators._short_error_message(Exception("Scene not saved.")),
519+
"Scene not saved.")
520+
521+
def test_overlong_message_is_truncated_with_ellipsis(self):
522+
message = bridge_operators._short_error_message(Exception("x" * 500))
523+
self.assertLessEqual(len(message), bridge_operators._ERROR_POPUP_LIMIT)
524+
self.assertTrue(message.endswith("…"))
525+
526+
def test_empty_message_falls_back_to_a_generic_line(self):
527+
self.assertEqual(
528+
bridge_operators._short_error_message(Exception("")),
529+
"Render launch failed.")
530+
531+
506532
class BridgeOperatorPolicyTests(unittest.TestCase):
507533
def test_scene_requires_upload_reuploads_after_a_saved_edit(self) -> None:
508534
# Regression: adding a camera (or any edit) + saving must re-upload even when the path and the

OutWit.Render.BlenderAddon/outwit_render_bridge/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
bl_info = {
22
"name": "OmnibusCloud Render Bridge",
33
"author": "OutWit",
4-
"version": (1, 0, 9),
4+
"version": (1, 0, 10),
55
"blender": (4, 0, 0),
66
"location": "View3D > Sidebar > OmnibusCloud",
77
"description": "Thin Blender addon for the local OmnibusCloud render bridge",

OutWit.Render.BlenderAddon/outwit_render_bridge/blender_manifest.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
schema_version = "1.0.0"
22
id = "omnibuscloud_render_bridge"
3-
version = "1.0.9"
3+
version = "1.0.10"
44
name = "OmnibusCloud Render Bridge"
55
tagline = "Thin Blender addon for the local OmnibusCloud render bridge"
66
maintainer = "OutWit"

OutWit.Render.BlenderAddon/outwit_render_bridge/bridge_client.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ class BridgeClientError(Exception):
6363
pass
6464

6565

66+
def _extract_http_error_message(status_code: int, detail: str) -> str:
67+
"""The human message from a bridge HTTP error body.
68+
69+
The bridge answers errors with a JSON envelope ({"Status", "Data", "ErrorMessage",
70+
"ErrorDetails"}); showing the raw body gave the artist a screen-wide JSON dump for what is
71+
one sentence of ErrorMessage. Unparseable bodies keep the old raw form.
72+
"""
73+
try:
74+
envelope = json.loads(detail)
75+
message = str(envelope.get("ErrorMessage") or "").strip()
76+
if message:
77+
return message
78+
except (ValueError, TypeError, AttributeError):
79+
pass
80+
return f"Bridge HTTP error {status_code}: {detail}"
81+
82+
6683
class BridgeClient:
6784
def __init__(self, context_directory: str):
6885
self._context_directory = context_directory
@@ -135,7 +152,23 @@ def _run_upload(self, start_method: str, file_path: str) -> UploadBlendResponse:
135152

136153
raise BridgeClientError(status.error or f"Upload did not complete (status: {status.status or 'unknown'}).")
137154

138-
def run_render_validate_blend(self, scene_blob_id: str, attached_files: list[dict[str, Any]] | None = None) -> RenderValidateBlendResponse:
155+
def run_render_validate_blend(
156+
self,
157+
scene_blob_id: str,
158+
attached_files: list[dict[str, Any]] | None = None,
159+
selected_client_group_id: str = "",
160+
selected_project_id: str = "",
161+
) -> RenderValidateBlendResponse:
162+
"""Validation must carry the SAME scope as the render: an unscoped utility submit is an
163+
all-clients submit, which the engine rejects for non-admin accounts — the launch then dies
164+
here before the correctly-scoped render is ever reached (live-found on the first non-admin
165+
self-serve run). Unscoped stays on the legacy method for older running bridges."""
166+
group_id = (selected_client_group_id or "").strip()
167+
project_id = (selected_project_id or "").strip()
168+
if group_id or project_id:
169+
return self._post(
170+
"RunRenderValidateBlendScopedAsync", RenderValidateBlendResponse.from_json,
171+
scene_blob_id, attached_files or [], group_id, project_id)
139172
return self._post("RunRenderValidateBlendAsync", RenderValidateBlendResponse.from_json, scene_blob_id, attached_files or [])
140173

141174
def run_render_preflight(
@@ -148,19 +181,17 @@ def run_render_preflight(
148181
options: dict[str, Any],
149182
tile_options: dict[str, Any],
150183
video: dict[str, Any],
184+
selected_client_group_id: str = "",
185+
selected_project_id: str = "",
151186
) -> RenderPreflightResponse:
152-
return self._post(
153-
"RunRenderPreflightAsync",
154-
RenderPreflightResponse.from_json,
155-
frame,
156-
start_frame,
157-
end_frame,
158-
tiles_x,
159-
tiles_y,
160-
options,
161-
tile_options,
162-
video,
163-
)
187+
group_id = (selected_client_group_id or "").strip()
188+
project_id = (selected_project_id or "").strip()
189+
payload = [frame, start_frame, end_frame, tiles_x, tiles_y, options, tile_options, video]
190+
if group_id or project_id:
191+
return self._post(
192+
"RunRenderPreflightScopedAsync", RenderPreflightResponse.from_json,
193+
*payload, group_id, project_id)
194+
return self._post("RunRenderPreflightAsync", RenderPreflightResponse.from_json, *payload)
164195

165196
def run_render_still(self, scene_blob_id: str, frame: int, options: dict[str, Any], attached_files: list[dict[str, Any]] | None = None, selected_client_group_id: str = "", selected_project_id: str = "") -> RunRenderResponse:
166197
payload = [scene_blob_id, frame, options, attached_files or []]
@@ -337,7 +368,7 @@ def _send(self, request: urllib.request.Request) -> Any:
337368
body = response.read().decode("utf-8")
338369
except urllib.error.HTTPError as ex:
339370
detail = ex.read().decode("utf-8", errors="replace")
340-
raise BridgeClientError(f"Bridge HTTP error {ex.code}: {detail}") from ex
371+
raise BridgeClientError(_extract_http_error_message(ex.code, detail)) from ex
341372
except urllib.error.URLError as ex:
342373
raise BridgeClientError(f"Bridge request failed: {ex.reason}") from ex
343374

OutWit.Render.BlenderAddon/outwit_render_bridge/bridge_operators.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,24 @@ def _get_selected_target_unified_id(state) -> str:
221221
return ""
222222

223223

224+
# Blender renders operator ERROR reports as a popup sized to the TEXT — a multi-clause server
225+
# message becomes a screen-wide banner. The popup gets one readable sentence; the full text
226+
# stays in state.last_error, which the panel shows with the Copy button.
227+
_ERROR_POPUP_LIMIT = 160
228+
229+
230+
def _short_error_message(error: BaseException) -> str:
231+
message = str(error).strip() or "Render launch failed."
232+
# The engine wraps the human sentence in transport prefixes ("Failed to process request:
233+
# One or more errors occurred. (...)") — surface the innermost parenthesised clause.
234+
match = re.search(r"\(([^()]+)\)\s*$", message)
235+
if match:
236+
message = match.group(1).strip()
237+
if len(message) > _ERROR_POPUP_LIMIT:
238+
message = message[:_ERROR_POPUP_LIMIT - 1].rstrip() + "…"
239+
return message
240+
241+
224242
def _get_runtime_state(context):
225243
return context.window_manager.outwit_bridge_state
226244

@@ -693,10 +711,19 @@ def _merge_unique_summaries(*summaries: str) -> str:
693711

694712

695713
def _run_validate_blend(context):
714+
# Validate carries the SAME target scope as the render: an unscoped utility submit is an
715+
# all-clients submit the engine rejects for non-admin accounts — the whole launch then died
716+
# right here with "not authorized to launch on all clients" (live-found, first non-admin run).
696717
state = _get_runtime_state(context)
697718
context_directory = _get_context_directory(context)
698719
client = BridgeClient(context_directory)
699-
response = client.run_render_validate_blend(_ensure_uploaded_blob_id(state), _get_uploaded_attachment_manifest(state))
720+
group_id, project_id = _get_selected_target(state)
721+
response = client.run_render_validate_blend(
722+
_ensure_uploaded_blob_id(state),
723+
_get_uploaded_attachment_manifest(state),
724+
group_id,
725+
project_id,
726+
)
700727
_apply_validate_response(state, response)
701728
return response
702729

@@ -705,6 +732,7 @@ def _run_preflight(context):
705732
state = _get_runtime_state(context)
706733
scene = context.scene
707734
client = _get_bridge_client(context)
735+
group_id, project_id = _get_selected_target(state)
708736
response = client.run_render_preflight(
709737
_get_still_frame(context),
710738
int(scene.frame_start),
@@ -714,6 +742,8 @@ def _run_preflight(context):
714742
_collect_render_options(context),
715743
_collect_tile_options(state),
716744
_collect_video_options(state),
745+
group_id,
746+
project_id,
717747
)
718748
_apply_preflight_response(state, response)
719749
return response
@@ -2699,13 +2729,13 @@ def _finish_launch(self, context):
26992729
return {"FINISHED"}
27002730
except BridgeClientError as ex:
27012731
state.last_error = str(ex)
2702-
state.status_message = str(ex)
2703-
self.report({"ERROR"}, str(ex))
2732+
state.status_message = _short_error_message(ex)
2733+
self.report({"ERROR"}, _short_error_message(ex))
27042734
return {"CANCELLED"}
27052735
except Exception as ex:
27062736
state.last_error = str(ex)
27072737
state.status_message = "Render launch failed."
2708-
self.report({"ERROR"}, str(ex))
2738+
self.report({"ERROR"}, _short_error_message(ex))
27092739
return {"CANCELLED"}
27102740
finally:
27112741
_launch_in_progress = False

OutWit.Render.BlenderBridge/Channels/BlenderBridgeChannel.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,51 @@ public Task<RenderPreflightResponse> RunRenderPreflightAsync(
153153
video);
154154
}
155155

156+
public Task<RenderValidateBlendResponse> RunRenderValidateBlendScopedAsync(
157+
Guid sceneBlobId,
158+
List<RenderSceneAttachmentRefData> attachedFiles,
159+
string selectedClientGroupId,
160+
string selectedProjectId)
161+
{
162+
return RenderLaunchService.RunRenderValidateBlendAsync(
163+
sceneBlobId,
164+
attachedFiles,
165+
ParseScopeId(selectedClientGroupId),
166+
ParseScopeId(selectedProjectId));
167+
}
168+
169+
public Task<RenderPreflightResponse> RunRenderPreflightScopedAsync(
170+
int frame,
171+
int startFrame,
172+
int endFrame,
173+
int tilesX,
174+
int tilesY,
175+
RenderOptionsData options,
176+
TileOptionsData tileOptions,
177+
VideoOptionsData video,
178+
string selectedClientGroupId,
179+
string selectedProjectId)
180+
{
181+
return RenderLaunchService.RunRenderPreflightAsync(
182+
frame,
183+
startFrame,
184+
endFrame,
185+
tilesX,
186+
tilesY,
187+
options,
188+
tileOptions,
189+
video,
190+
ParseScopeId(selectedClientGroupId),
191+
ParseScopeId(selectedProjectId));
192+
}
193+
194+
// "" / whitespace / unparseable / Guid.Empty all mean "no scope of this kind" — the string
195+
// transport exists so one method covers group/project/none (REST binds by name + count).
196+
private static Guid? ParseScopeId(string? value)
197+
{
198+
return Guid.TryParse((value ?? "").Trim(), out var id) && id != Guid.Empty ? id : null;
199+
}
200+
156201
public Task<RunRenderStillResponse> RunRenderStillAsync(Guid sceneBlobId, int frame, RenderOptionsData options)
157202
{
158203
return RenderLaunchService.RunRenderStillAsync(sceneBlobId, frame, options);

OutWit.Render.BlenderBridge/Channels/Interfaces/IBlenderBridgeChannel.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,34 @@ Task<RenderPreflightResponse> RunRenderPreflightAsync(
108108
TileOptionsData tileOptions,
109109
VideoOptionsData video);
110110

111+
/// <summary>
112+
/// Validates the blend scoped to the selected render target — utility submits must carry
113+
/// the same scope as the render, or a non-admin account is rejected ("all clients") before
114+
/// the render is ever reached. Ids travel as strings ("" = none; both set is an error) so
115+
/// ONE method covers group/project/none without a Guid-arity explosion in the REST binder.
116+
/// </summary>
117+
Task<RenderValidateBlendResponse> RunRenderValidateBlendScopedAsync(
118+
Guid sceneBlobId,
119+
List<RenderSceneAttachmentRefData> attachedFiles,
120+
string selectedClientGroupId,
121+
string selectedProjectId);
122+
123+
/// <summary>
124+
/// Runs the render preflight scoped to the selected render target (see
125+
/// <see cref="RunRenderValidateBlendScopedAsync"/> for the string-id contract).
126+
/// </summary>
127+
Task<RenderPreflightResponse> RunRenderPreflightScopedAsync(
128+
int frame,
129+
int startFrame,
130+
int endFrame,
131+
int tilesX,
132+
int tilesY,
133+
RenderOptionsData options,
134+
TileOptionsData tileOptions,
135+
VideoOptionsData video,
136+
string selectedClientGroupId,
137+
string selectedProjectId);
138+
111139
/// <summary>
112140
/// Launches the bundled RenderStill script.
113141
/// </summary>

OutWit.Render.BlenderBridge/Services/Render/BridgeRenderLaunchService.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,14 @@ public partial class BridgeRenderLaunchService : IBridgeRenderLaunchService
7777

7878
public async Task<RenderValidateBlendResponse> RunRenderValidateBlendAsync(Guid sceneBlobId, CancellationToken cancellationToken = default)
7979
{
80-
return await RunRenderValidateBlendAsync(sceneBlobId, null, cancellationToken);
80+
return await RunRenderValidateBlendAsync(sceneBlobId, null, cancellationToken: cancellationToken);
8181
}
8282

8383
public async Task<RenderValidateBlendResponse> RunRenderValidateBlendAsync(
8484
Guid sceneBlobId,
8585
IReadOnlyList<RenderSceneAttachmentRefData>? attachedFiles,
86+
Guid? selectedClientGroupId = null,
87+
Guid? selectedProjectId = null,
8688
CancellationToken cancellationToken = default)
8789
{
8890
ThrowIfSceneBlobIdMissing(sceneBlobId);
@@ -93,7 +95,13 @@ public async Task<RenderValidateBlendResponse> RunRenderValidateBlendAsync(
9395
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
9496
timeoutSource.CancelAfter(DEFAULT_TIMEOUT);
9597

96-
var handle = await client.Scripts.RunAsync(RENDER_VALIDATE_BLEND_SCRIPT, scene);
98+
// Scoped like the render itself: an unscoped utility submit is an ALL-CLIENTS submit,
99+
// which the engine (rightly) rejects for accounts without the global grant — validation
100+
// then fails before the correctly-scoped render is ever reached (live-found on the
101+
// first non-admin self-serve run). HasScopedTarget also rejects group+project both set.
102+
_ = HasScopedTarget(selectedClientGroupId, selectedProjectId);
103+
var handle = await client.Scripts.SubmitAsync(
104+
RENDER_VALIDATE_BLEND_SCRIPT, new object?[] { scene }, selectedClientGroupId, selectedProjectId, ct: cancellationToken);
97105
var waited = await handle.WaitAsync<string>("result", pollInterval: POLL_INTERVAL, ct: timeoutSource.Token);
98106

99107
if (waited.Status == ProcessingJobStatus.Completed)
@@ -141,6 +149,8 @@ public async Task<RenderPreflightResponse> RunRenderPreflightAsync(
141149
RenderOptionsData options,
142150
TileOptionsData tileOptions,
143151
VideoOptionsData video,
152+
Guid? selectedClientGroupId = null,
153+
Guid? selectedProjectId = null,
144154
CancellationToken cancellationToken = default)
145155
{
146156
ArgumentNullException.ThrowIfNull(options);
@@ -149,9 +159,13 @@ public async Task<RenderPreflightResponse> RunRenderPreflightAsync(
149159

150160
var client = await GetRequiredClientAsync(cancellationToken);
151161

162+
// Scoped like the render itself (see RunRenderValidateBlendAsync) — an unscoped
163+
// preflight is an all-clients submit a non-admin account cannot make.
164+
_ = HasScopedTarget(selectedClientGroupId, selectedProjectId);
165+
152166
var runtimeDiagnostics = await RunAndGetResultAsync<RenderRuntimeDiagnosticsData>(
153167
client,
154-
s => s.RunAsync(RENDER_RUNTIME_DIAGNOSTICS_SCRIPT),
168+
s => s.SubmitAsync(RENDER_RUNTIME_DIAGNOSTICS_SCRIPT, [], selectedClientGroupId, selectedProjectId),
155169
"Bridge runtime diagnostics",
156170
cancellationToken);
157171

@@ -167,22 +181,22 @@ public async Task<RenderPreflightResponse> RunRenderPreflightAsync(
167181
RuntimeDiagnostics = runtimeDiagnostics,
168182
Still = await RunAndGetResultAsync<RenderPreflightFramesData>(
169183
client,
170-
s => s.RunAsync(RENDER_PREFLIGHT_STILL_SCRIPT, frame, options),
184+
s => s.SubmitAsync(RENDER_PREFLIGHT_STILL_SCRIPT, new object?[] { frame, options }, selectedClientGroupId, selectedProjectId),
171185
"Bridge still preflight",
172186
cancellationToken),
173187
Frames = await RunAndGetResultAsync<RenderPreflightFramesData>(
174188
client,
175-
s => s.RunAsync(RENDER_PREFLIGHT_FRAMES_SCRIPT, startFrame, endFrame, options),
189+
s => s.SubmitAsync(RENDER_PREFLIGHT_FRAMES_SCRIPT, new object?[] { startFrame, endFrame, options }, selectedClientGroupId, selectedProjectId),
176190
"Bridge frame-range preflight",
177191
cancellationToken),
178192
StillTiled = await RunAndGetResultAsync<RenderPreflightStillTiledData>(
179193
client,
180-
s => s.RunAsync(RENDER_PREFLIGHT_STILL_TILED_SCRIPT, tilesX, tilesY, options, tileOptions),
194+
s => s.SubmitAsync(RENDER_PREFLIGHT_STILL_TILED_SCRIPT, new object?[] { tilesX, tilesY, options, tileOptions }, selectedClientGroupId, selectedProjectId),
181195
"Bridge tiled-still preflight",
182196
cancellationToken),
183197
Video = await RunAndGetResultAsync<RenderPreflightVideoData>(
184198
client,
185-
s => s.RunAsync(RENDER_PREFLIGHT_VIDEO_SCRIPT, options, video),
199+
s => s.SubmitAsync(RENDER_PREFLIGHT_VIDEO_SCRIPT, new object?[] { options, video }, selectedClientGroupId, selectedProjectId),
186200
"Bridge video preflight",
187201
cancellationToken)
188202
};

0 commit comments

Comments
 (0)