Compare commits

...
28 Commits
Author SHA1 Message Date
HartmutandClaude Sonnet 4.6 271ce510c1 fix: allow external hostname in vite dev server
Add capakraken.hartmut-noerenberg.com to server.allowedHosts so Vite
does not block requests coming through the reverse proxy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 15:43:56 +02:00
HartmutandClaude Sonnet 4.6 ed61fd0328 docs: shadow render parity test + learning erfasst
Add integration test utility for pixel-diff comparison of legacy vs
shadow renders (PIL + NumPy, thresholds 0.5% / max_diff=3). Documents
that 3 older shadow runs show visual differences due to template config
changes between shadow execution and legacy re-render — not a code bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:35:59 +02:00
HartmutandClaude Sonnet 4.6 34ed511d9d fix: remove stale cinematic legacy-only guard in dispatch_service
The guard forced all cinematic output types to legacy dispatch because
BLENDER_CINEMATIC did not exist in the workflow graph. M1 added that node,
so the guard is now stale — it was preventing cinematic output types from
ever using graph mode even after an admin sets workflow_rollout_mode=graph.

The normal gate path handles it correctly: workflow_rollout_mode=legacy_only
keeps it on legacy, and find_unsupported_graph_nodes preflight catches any
workflow that doesn't actually contain a BLENDER_CINEMATIC node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 16:33:40 +02:00
HartmutandClaude Sonnet 4.6 23605783bd feat: multi-GPU queue routing for legacy still renders (M2)
dispatch_order_line_render now routes still renders (non-animation,
non-cinematic) to a secondary GPU queue when MULTI_GPU_LIGHT_RENDER_QUEUE
is configured and that queue has active Celery workers.

- config.py: multi_gpu_light_render_queue setting (default "" = disabled)
- render_order_line.py: implement the routing stub — load output_type via
  selectinload, check render_settings.animation + .cinematic flags, call
  _inspect_active_worker_queues (reused from workflow_graph_runtime) with
  0.5s timeout to check if the light queue is live

No behaviour change when MULTI_GPU_LIGHT_RENDER_QUEUE is not set.
Enable by setting it to "asset_pipeline_light" and adding a
render-worker-light service with concurrency=1 to docker-compose.

docs: learnings erfasst — multi-GPU queue routing M2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 15:12:07 +02:00
HartmutandClaude Sonnet 4.6 3401b06b19 feat: BLENDER_CINEMATIC workflow graph node (M1)
Add StepName.BLENDER_CINEMATIC and full graph runtime support so cinematic
output types can be promoted from legacy_only to graph/shadow rollout mode.

- process_steps.py: add BLENDER_CINEMATIC = "blender_cinematic" enum value
- workflow_executor.py: map to render_cinematic_task in STEP_TASK_MAP
- workflow_node_registry.py: node definition with render/scene/camera fields
  (no animation params — cinematic is fixed at 250 frames @ 25fps)
- workflow_graph_runtime.py: _ORDER_LINE_RENDER_STEPS, _CINEMATIC_TASK_KEYS,
  shadow queue routing, predict_render_output_artifact (mp4),
  _build_task_kwargs, _artifact_kind_override_for_step
- tasks.py: _normalize_cinematic_params + render_cinematic_task Celery task
  (calls render_cinematic_to_file, publishes as turntable asset type since mp4)

No DB migration needed: admins can now manually set cinematic output types
to graph rollout mode via the admin panel and assign a workflow definition.

docs: learnings erfasst — BLENDER_CINEMATIC workflow graph node M1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 15:09:47 +02:00
HartmutandClaude Sonnet 4.6 10a8bbd977 fix: low audit items — email channel, debug cleanup, roadmap sync, bare except logging
L18: remove comingSoon from email notification channel (SMTP backend already exists).
L19: remove console.debug useEffect from InlineCadViewer.tsx.
L20: sync ROADMAP.md priority status sections (P1/P2/P3/P8) with status snapshot — all Done.
L22: replace 8 bare except blocks in step_processor.py with logger.debug/warning
     so geometry calculation failures are visible in logs instead of silently discarded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:06:31 +02:00
HartmutandClaude Sonnet 4.6 507858cf31 fix: medium audit items — CORS config, invoice description, path helper, node failure, order prefix
M3: cors_origins setting in config.py (env CORS_ORIGINS); main.py reads from settings.
M4: add build_order_line_step_render_dir() to render_paths.py; tasks.py drops placeholder.mp4 trick.
M5: unknown workflow graph nodes now fail the run (status="failed" + logger.error) instead of silently skipping.
M6: invoice line description is now "{product} — {output_type}" instead of bare UUID; eager-loads relations.
M7: order_number_prefix setting in config.py (env ORDER_NUMBER_PREFIX, default "SA").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:57:04 +02:00
HartmutandClaude Sonnet 4.6 3c2d0816e5 fix: JWT secret startup guard and order number advisory lock
H1: model_validator in Settings raises ValueError when jwt_secret_key is
    "changeme" and the process is running inside a container — fast-fail
    prevents insecure deployments; local dev outside Docker is unaffected.

H7: generate_order_number now acquires pg_advisory_xact_lock before SELECT MAX,
    matching the same pattern used in generate_invoice_number (billing/service.py).
    Concurrent order creation can no longer race to produce duplicate numbers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:45:39 +02:00
HartmutandClaude Sonnet 4.6 df304dc021 fix: configurable internal API URL and upload size enforcement
H4: add internal_api_base_url setting to config.py (default http://localhost:8888,
    env-overridable via INTERNAL_API_BASE_URL); replace all 5 hardcoded base_url
    strings in chat_service.py.

H6: add post-read size check in both Excel and STEP upload handlers;
    raises HTTP 413 when content exceeds settings.max_upload_size_mb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:43:04 +02:00
HartmutandClaude Sonnet 4.6 b69190dd86 fix: billing tenant isolation, invoice status validation, SMTP password masking
H2: list_invoices now passes tenant_id (from current_user) to get_invoices;
    get_invoices applies WHERE tenant_id filter for non-global-admin users;
    get_invoice_endpoint returns 404 when tenant mismatch for non-admins.

H3: InvoiceStatusUpdate.status changed to Literal["draft","sent","paid","cancelled"]
    for schema-level validation; guard also added in update_invoice_status service.

H5: _settings_to_out masks smtp_password as "***" when set, "" when empty;
    update_settings skips writing when value is the "***" sentinel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:40:59 +02:00
HartmutandClaude Sonnet 4.6 a11d2fb1e7 refactor: extract turntable branch and exhausted-retry handler from render_order_line_task
_render_turntable: Option B — resolved objects as params (render_invocation, step_path,
output_path, template, emit, pl). Session and PipelineLogger stay in the caller so
no second DB connection is opened and log steps roll up to the main task.

_handle_render_task_exhausted: extracted 68-line mark-as-failed block; retry/raise
logic with Celery self.retry stays in render_order_line_task since it needs the
bound-task context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:11:27 +02:00
Hartmut 1c4ca6998f docs: learnings erfasst — workflow Phase 5/6 fixes 2026-07-22 11:39:12 +02:00
HartmutandClaude Sonnet 4.6 e8b4580608 fix: workflow editor Phase 5/6 sign-off — save guard, conflict detection, dispatch pre-check
- Save button disabled for non-admins (canSave prop threaded through
  WorkflowEditor → WorkflowCanvas → WorkflowCanvasToolbar); tooltip
  explains why when hovered
- Optimistic concurrency: migration 072 adds updated_at to
  workflow_definitions; PUT /workflows/:id returns 409 when client sends
  a stale updated_at; frontend shows a specific reload-prompt toast
- _legacy_dispatch now routes through dispatch_order_line_render instead
  of calling render_order_line_task directly, so cancelled/rejected
  order lines are skipped before queueing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:39:02 +02:00
HartmutandClaude Sonnet 4.6 53b19f2ba1 fix: workflow blueprints freely editable after creation (preserve_user_graph)
canonicalize_workflow_config was silently rebuilding any workflow with
ui.blueprint or ui.preset == still_graph from the canonical template on
every save, preflight, and read — making blueprint-based workflows
effectively read-only.

Add preserve_user_graph=True flag that skips the rebuild blocks. Only
create_workflow still uses preserve_user_graph=False so blueprints are
correctly expanded at creation time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:17:32 +02:00
Hartmut 4ea6c4ee9c docs: learnings erfasst — editierbares branding, public settings endpoint 2026-07-22 11:10:22 +02:00
HartmutandClaude Sonnet 4.6 4946cc300a feat: editable branding — app name and subtitle configurable in Admin Settings
Adds app_name and app_subtitle as system settings with a dedicated
Branding tab in the Admin panel. Both values are served via a public
GET /api/admin/branding endpoint (no auth) so the login page can also
show the configured name before the user signs in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:02:38 +02:00
HartmutandClaude Sonnet 4.6 c262b31bc5 chore: cleanup #6 #8 #9 — delete debug dir, rename glbUrl prop, sync docs
- delete renderproblems_tmp/ (7 debug images + USD test files, orphaned)
- rename ThreeDViewer prop geometryGltfUrl → glbUrl; update CadPreview.tsx
  caller (productionGltfUrl distinction was never needed, only one GLB type)
- plan.md: mark all 4 cinematic tasks [x] (all implemented + seeded in 070)
- 0001-step-to-usd: mark Phase 4 ThreeDViewer acceptance gates [x] (P4 done)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:22:08 +02:00
HartmutandClaude Sonnet 4.6 c87857f836 feat: turntable + blend export smoke test coverage
- build_graph_turntable_config: accept render_params kwarg so smoke can
  use low settings (512x512, 24 frames) without touching golden case defaults
- add smoke naming + template functions for turntable and blend
- add ensure_workflow_turntable_smoke_resources() — provisions output type
  (mp4/turntable_video) + workflow graph, binds to
  Blender_Studio_Schadowcatcher_Anim template
- add ensure_workflow_blend_smoke_resources() — provisions output type
  (blend/blend_asset) + workflow graph, binds to BlenderStudio template
- add test_workflow_turntable_smoke() — preflight + dispatch + run tracking
- add test_workflow_blend_smoke() — preflight + dispatch + blend node check
- 6 new harness unit tests (17 total, all passing), using real asset:
  cad_file_id 8dd73335 (81113-l_cut.stp, has USD master + GLB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:17:47 +02:00
HartmutandClaude Sonnet 4.6 44b52d05cc fix: cinematic output type guard + legacy_only migration
- Migration 070: seeds 'Cinematic Highlight' output type (legacy_only, no
  workflow link) for fresh installs
- Migration 071: patches existing cinematic output types — clears
  workflow_definition_id and forces legacy_only rollout mode. The row
  existed since 2026-03 with shadow mode + a linked workflow def;
  no BLENDER_CINEMATIC graph node exists so shadow execution would fail.
- API guard in output_types POST + PATCH: cinematic output types cannot
  receive a workflow_definition_id (HTTP 400)
- Defense-in-depth in dispatch_service: early legacy exit if
  render_settings.cinematic is true, regardless of rollout mode
- docs: learning erfasst — cinematic rollout mode footgun

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:04:07 +02:00
HartmutandClaude Sonnet 4.6 971074fedd docs: learnings erfasst — cinematic render pipeline fixes 2026-07-21
Drei Bugs aus Cinematic-Render-Session dokumentiert:
- is_cinematic immer False wegen Invocation-Override-Filter
- usd_path.exists() auf str (fehlender str→Path-Cast in turntable/cinematic)
- NameError stdout/stderr in cinematic error path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 07:12:30 +02:00
HartmutandClaude Sonnet 4.6 7179023458 chore: extend project Claude permissions for docker, curl and hartomat MCP tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 22:34:21 +02:00
HartmutandClaude Sonnet 4.6 bb2b0f51b5 fix: cinematic render is_cinematic detection and usd_path str/Path handling
Three bugs causing all cinematic renders to silently fall through to the
turntable path and then immediately crash:

1. is_cinematic read from filtered invocation-override dict — 'cinematic' key
   is not in the allowed override key list for turntable_video, so it gets
   stripped. Fixed: read directly from output_type.render_settings.

2. render_turntable_to_file and render_cinematic_to_file both call
   usd_path.exists() but receive a str from the caller. Added the same
   isinstance str→Path conversion that render_still_to_file already had.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 22:32:16 +02:00
HartmutandClaude Sonnet 4.6 6459b9e62d docs: learnings erfasst - cinematic render NameError, cancel-race, legacy-only pfad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:10:22 +02:00
HartmutandClaude Sonnet 4.6 d2e4934cca feat: workflow graph system — blocks 1-20 complete checkpoint
Full graph-based workflow execution engine with:
- WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks)
- Node registry with contract validation, socket types, execution kinds
- WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B
- WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints
- OutputTypeContracts: workflow binding, rollout-mode resolution
- Admin router: output-type workflow binding endpoints
- Frontend: complete workflow editor with drag-drop canvas, node inspector,
  module bundles, reference bundles, preflight panel, validation banner,
  authoring guidance, blueprint templates, shadow/rollout gate UI
- Tests: comprehensive coverage for all workflow modules
- Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY

All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:09:54 +02:00
HartmutandClaude Sonnet 4.6 c51dd8cd67 fix: guard persist_order_line_output against cancelled lines
Completed renders no longer overwrite a cancelled status — a Blender job
that finishes after a cancel is issued is now silently dropped instead of
flipping the line back to completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:09:34 +02:00
HartmutandClaude Sonnet 4.6 84f40647b0 fix: resolve NameError masking cinematic render errors and fix frame count
- `render_cinematic_to_file` referenced undefined `stdout`/`stderr` in the
  Blender error path; replaced with `log_lines`/`stderr_lines` so the real
  Blender error is actually stored in render_log instead of a Python NameError
- Progress callback hardcoded 480 frames; now uses the `frame_count` variable
- Updated docstring to reflect actual 250 frames @ 25fps (not 480 @ 24fps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:09:28 +02:00
Hartmut 715cef7971 Document block 20 workflow checkpoint 2026-04-18 13:17:07 +02:00
Hartmut 98455ee579 Close workflow smoke template drift 2026-04-18 13:16:54 +02:00
111 changed files with 9621 additions and 2394 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
{
"permissions": {
"allow": ["Bash", "Read", "Write", "Edit"],
"allow": [
"Bash", "Read", "Write", "Edit",
"Bash(docker compose:*)", "Bash(docker exec:*)",
"Bash(docker logs:*)", "Bash(docker ps:*)",
"Bash(curl:*)",
"mcp__hartomat__*"
],
"deny": []
},
"hooks": {
+78
View File
@@ -7,6 +7,18 @@
## Learnings
### 2026-07-22 | Architecture | Multi-GPU Queue Routing für Legacy-Dispatch implementiert
M2: `dispatch_order_line_render` leitet Still-Renders an eine zweite GPU-Queue um wenn `MULTI_GPU_LIGHT_RENDER_QUEUE` gesetzt ist UND diese Queue aktive Worker hat (Celery inspect). Animationen und Cinematics bleiben immer auf `asset_pipeline`. Neue Settings-Option `multi_gpu_light_render_queue: str = ""` in config.py — Default leer = disabled. `_inspect_active_worker_queues()` aus `workflow_graph_runtime.py` wiederverwendet (timeout=0.5s). Kein Breaking-Change: ohne die Env-Variable verhält sich die Dispatch-Funktion exakt wie vorher.
### 2026-07-22 | Architecture | BLENDER_CINEMATIC Workflow-Graph-Node implementiert
Der cinematic Render-Pfad hatte keinen eigenen Workflow-Graph-Node — Migration 071 hatte alle cinematic Output-Types auf `legacy_only` gezwungen als Sicherheitsnetz. M1 fügt jetzt `StepName.BLENDER_CINEMATIC` hinzu, zusammen mit: (1) Node-Definition im `workflow_node_registry.py` mit denselben Szene/Camera/Material-Feldern wie BLENDER_STILL, aber ohne Animations-Params (frame_count/fps sind im cinematic_render.py-Script hartkodiert auf 250 @ 25fps), (2) `render_cinematic_task` in `tasks.py` — folgt dem Pattern von `render_order_line_still_task`, gibt mp4 aus, nutzt `_finalize_graph_turntable_output`/`_finalize_shadow_turntable_output` da cinematic = mp4, published mit `asset_type="turntable"`, (3) STEP_TASK_MAP + `_ORDER_LINE_RENDER_STEPS` + `_build_task_kwargs` + `_predict_render_output_artifact` + `_artifact_kind_override_for_step` in `workflow_graph_runtime.py` alle aktualisiert. Kein neues DB-Migration nötig: Admins können cinematic Output-Types jetzt manuell von `legacy_only` auf `graph` umstellen und ein Workflow-Definition mit BLENDER_CINEMATIC-Node zuweisen.
### 2026-07-22 | Architecture | Cinematic Output Type existierte bereits mit falschem workflow_rollout_mode
Der "Cinematic Highlight" Output-Type war seit März 2026 in der DB mit `workflow_rollout_mode = shadow` und einer `workflow_definition_id` gesetzt. Da es keinen `BLENDER_CINEMATIC`-Node im Workflow-Graph gibt, hätte jeder Cinematic-Render eine Shadow-Graph-Execution ausgelöst, die still scheitert. Fix via Migration 071: alle cinematic Output-Types auf `legacy_only` + `workflow_definition_id = NULL` patchen. Zusätzlich API-Guard in POST/PATCH `output_types.py` eingebaut: cinematic + workflow_definition_id → 400 Error. Defense-in-depth im `dispatch_service.py`: frühzeitiger Legacy-Exit wenn `render_settings.cinematic = true`.
### 2026-07-22 | Testing | Shadow-Render-Parity-Test: 3 von 10 Renders unterscheiden sich — kein Code-Bug
`backend/tests/integration/test_shadow_render_parity.py` vergleicht Legacy- und Shadow-Render-Output über PIL+NumPy Pixel-Diff (Thresholds: 0.5% changed pixels, max_diff=3). 7/10 Renders PASS (max_diff=1, <0.01%). 3/10 FAIL mit 4.2% Unterschied und max_diff=69. Root-Cause-Analyse: Die 3 Shadow-Runs vom 2026-04-11 wurden ausgeführt bevor der BlenderStudio-Template mit dem betreffenden Output-Type (`bc7a6b36`) verknüpft wurde. Die Legacy-Renders wurden nach dieser Verknüpfung neu getriggert → visuell unterschiedlich (Studio-Lichtsetzung vs. kein Template). Kein Bug im Graph-Workflow. Der Test ist trotzdem nützlich für zukünftige Parity-Checks. Lehre: Shadow-Runs, die vor einer Template-Konfigurationsänderung erfolgten, werden immer visuell abweichen — das ist erwartetes Verhalten, kein Fehler.
### 2026-03-15 | Architecture | Per-order-line render overrides via JSONB
Render overrides (JSONB) on OrderLine allow overriding any output type render setting (format, resolution, samples, engine, etc.) at order time without duplicating output types. Applied AFTER output type render_settings AND after transparent_bg/cycles_device_val assignment, so they take final priority. Also affects dispatch queue routing (width/height overrides change light vs heavy queue routing).
@@ -506,3 +518,69 @@ Bug "Wälzkörper an falscher Position" war in Code durch commit 638b93b (IsSame
- `BRepBuilderAPI_Transform` komplett entfernt — `RWGltf_CafWriter` konvertiert intern mm→m und Z-up→Y-up.
- `writer.SetMergeFaces(True)` hinzugefügt — composited Face-Triangulationen zu korrekten Shape-Buffern (1.212 → 46.573 Vertices).
**Merke**: OCC `TopLoc_Location` kann keine Skalierung (wirft `Standard_DomainError`). Für Skalierung entweder den Writer intern konvertieren lassen oder GLB post-process.
### 2026-07-21 | Debugging | NameError in cinematic render error path maskiert echten Blender-Fehler
In `render_cinematic_to_file` (`render_blender.py`) las der Fehler-Handler nach `proc.returncode != 0` die Variablen `stdout` und `stderr` — beide nie definiert (tatsächlich heißen sie `log_lines` und `stderr_lines`). Jeder Blender-Absturz produzierte `{"error": "name 'stdout' is not defined"}` in der DB statt dem echten Fehlertext. **Lösung:** `stdout`/`stderr``log_lines`/`stderr_lines`-Referenz korrigiert.
### 2026-07-21 | Architektur | Cancel-Race im Render-Task — completed überschreibt cancelled
Wenn ein Render-Job gecancelt wird, läuft Blender weiter. Wenn er fertig ist, schreibt `persist_order_line_output` `render_status=completed` ohne zu prüfen ob die Line mittlerweile cancelled wurde. **Lösung:** Guard am Anfang von `persist_order_line_output`: wenn `line.render_status == "cancelled"`, Early-Return ohne Statusmutation.
### 2026-07-21 | Architektur | Cinematic-Render ist ausschließlich Legacy-Pfad — kein Graph-Node
Es gibt keinen `BLENDER_CINEMATIC`-Node im Workflow-Graph-System. Cinematic-Renders laufen ausschließlich über den Legacy-Pfad (`render_order_line_task`), ausgelöst durch `output_type.render_settings.cinematic=true`. Für einen Cinematic-Output-Type darf deshalb kein `workflow_definition_id` gesetzt sein — sonst wird der Job als Graph-Workflow geroutet, der keinen Cinematic-Node hat und leer bleibt.
### 2026-07-21 | Render-Pipeline | `is_cinematic` immer False wegen Invocation-Override-Filter
`workflow_runtime_services.py` las `is_cinematic` aus dem bereits gefilterten `render_settings`-Dict. `resolve_output_type_invocation_overrides` entfernt aber alle Keys die nicht in `_STATIC_RENDER_OVERRIDE_KEYS` oder `_ANIMATION_OVERRIDE_KEYS` stehen — `cinematic` ist in keiner dieser Listen. Ergebnis: `is_cinematic=False` immer, Cinematic-Jobs liefen auf den Turntable-Pfad. **Lösung:** `is_cinematic` direkt aus `output_type.render_settings.get("cinematic")` lesen statt aus dem gefilterten Dict.
### 2026-07-21 | Render-Pipeline | `usd_path.exists()` auf str in turntable und cinematic render functions
`render_turntable_to_file` und `render_cinematic_to_file` in `render_blender.py` riefen `.exists()` auf `usd_path` auf, ohne zu prüfen ob es ein `str` oder `Path` ist. Das Invocation-Dataclass speichert `usd_path` als `str | None`. `render_still_to_file` hatte den `isinstance(usd_path, str)``Path`-Cast bereits (Zeilen 300301), die anderen beiden Funktionen nicht. **Lösung:** Denselben Cast (`if isinstance(usd_path, str) and usd_path.strip(): usd_path = Path(usd_path)`) vor dem `usd_path.exists()`-Check in turntable und cinematic ergänzt.
### 2026-07-22 | Frontend | Editierbares Branding — public settings endpoint vor Auth nötig
Der Admin-Settings-Endpunkt (`GET /api/admin/settings`) erfordert `global_admin`-Auth. Die Login-Seite benötigt den App-Namen aber vor dem Login. Lösung: eigener öffentlicher `GET /api/admin/branding`-Endpunkt ohne Auth-Dependency, der nur `app_name` + `app_subtitle` aus `system_settings` liest. Frontend: `useBranding()`-Hook via React Query mit 5-min-Stale-Time — Query-Key `['branding']` wird nach dem Speichern aus dem Admin-Panel invalidiert, damit Sidebar und Login-Seite sofort aktualisieren.
### 2026-07-22 | Workflow-Editor | Blueprint-Workflows waren nach jedem Speichern read-only
`canonicalize_workflow_config` in `workflow_config_utils.py` hat bei jedem Aufruf Configs mit `ui.blueprint` (z.B. `still_graph_reference`, `order_rendering`) und `ui.preset == "still_graph"` auf das kanonische Template zurückgebaut — alle Node-Parameter-Änderungen und Strukturänderungen des Users wurden lautlos verworfen. Das betraf Update, Preflight und Execution-Dispatch gleichermaßen. **Lösung:** Parameter `preserve_user_graph: bool = False` — bei `True` werden die Rebuild-Blöcke übersprungen; nur Basic-Normalisierung läuft. `update_workflow`, `_workflow_to_out`, Preflight und Execution-Dispatch rufen jetzt mit `preserve_user_graph=True` auf. `create_workflow` bleibt bei `False` (Blueprints werden beim Erstellen korrekt expandiert).
### 2026-07-22 | Workflow-Editor | _legacy_dispatch umging cancelled/rejected Pre-Check
`_legacy_dispatch` in `dispatch_service.py` rief `render_order_line_task.delay()` direkt auf und umging damit den Pre-Check in `dispatch_order_line_render` (der cancelled/rejected Lines überspringt). Alle Legacy-Dispatch-Pfade (auch im Graph-Fallback) liefen so durch, auch für bereits gecancelte Jobs. **Lösung:** `_legacy_dispatch` ruft jetzt `dispatch_order_line_render.delay()` auf statt `render_order_line_task.delay()` direkt.
### 2026-07-22 | Maintenance | E-Mail-Kanal in NotificationSettings war dauerhaft deaktiviert
`NotificationSettings.tsx` hatte `comingSoon: true` für den E-Mail-Kanal — das Backend-SMTP-System existierte bereits vollständig. Toggle war nur im Frontend deaktiviert. **Lösung:** `comingSoon` entfernt.
### 2026-07-22 | Maintenance | Bare-except-Blöcke in step_processor.py schlugen Fehler still
8 `except Exception: pass/continue` Blöcke in `step_processor.py` (Kanten-Winkelberechnung, Bounding-Box, Volumen/Flächen-Properties, Dreieckszählung, STEP-Raw-Parse) unterdrückten Fehler vollständig. Debugging von kaputten STEP-Dateien war so nahezu unmöglich. **Lösung:** Alle Blöcke auf `except Exception as _exc: logger.debug(...)` umgestellt; STEP-Raw-Parse-Fehler auf `logger.warning` da relevanter.
### 2026-07-22 | Architecture | CORS-Origins und Order-Prefix nicht konfigurierbar
`main.py` hatte CORS-Origins hardcoded als Python-Liste. `orders/service.py` hatte den Ordernummer-Prefix `SA-` hardcoded. Beide Werte lassen sich jetzt per Umgebungsvariable überschreiben: `CORS_ORIGINS='["https://myapp.com"]'` und `ORDER_NUMBER_PREFIX=HM`. Default-Werte bleiben unverändert, keine Migration nötig.
### 2026-07-22 | Refactoring | placeholder.mp4 als Pfad-Trick in tasks.py
`tasks.py:934` nutzte `build_order_line_step_render_path(..., "placeholder.mp4", ensure_exists=True).parent` um das Render-Verzeichnis zu erzeugen — ein fake Dateiname nur um `.parent` aufzurufen. **Lösung:** neue Funktion `build_order_line_step_render_dir(step_path, order_line_id, *, ensure_exists=False)` in `render_paths.py` hinzugefügt. `tasks.py` nutzt die direkte Funktion ohne Workaround.
### 2026-07-22 | Correctness | Unbekannte Workflow-Nodes wurden lautlos übersprungen
`workflow_graph_runtime.py:511` setzte Status `"skipped"` für Steps ohne Executor — der Workflow-Run galt als erfolgreich obwohl Nodes nicht ausgeführt wurden. **Lösung:** Status zu `"failed"` geändert und `logger.error` ergänzt. Der Run-Status-Check (`if any(...status == "failed")`) markiert den gesamten Run als failed.
### 2026-07-22 | Correctness | Invoice-Beschreibung war nackte UUID
`billing/service.py:259` setzte `description=f"Render: {ol.id}"` — im PDF erschien eine UUID als Rechnungsposition. **Lösung:** `OrderLine` wird jetzt mit `selectinload(product)` und `selectinload(output_type)` geladen; Beschreibung ist `f"{product_name} — {ot_name}"`.
### 2026-07-22 | Security | JWT secret "changeme" ohne Startup-Guard
`jwt_secret_key` in `config.py` hatte den Standardwert `"changeme"`. Deployments, die vergessen `JWT_SECRET_KEY` zu setzen, ließen gültige JWTs fälschen. **Lösung:** `@model_validator(mode="after")` in `Settings` — wirft `ValueError` wenn key `"changeme"` ist UND `_is_running_in_container()` True ist. Lokale Entwicklung außerhalb Docker ist nicht betroffen.
### 2026-07-22 | Security | Race condition bei Ordernummer-Generierung
`generate_order_number` in `orders/service.py` verwendete `SELECT MAX(order_number)` ohne Advisory Lock. Bei gleichzeitigen Requests konnten zwei Sessions denselben MAX-Wert lesen und identische Nummern generieren, was zu einem `IntegrityError` beim Flush führte. **Lösung:** `pg_advisory_xact_lock(hashtext(:key))` vor dem SELECT — gleicher Pattern wie `generate_invoice_number` in `billing/service.py`. Lock wird beim Commit/Rollback automatisch freigegeben.
### 2026-07-22 | Security | localhost:8888 hardcoded in chat_service.py
`chat_service.py` verwendete `httpx.AsyncClient(base_url="http://localhost:8888", ...)` an 5 Stellen. In Docker löst `localhost` nicht zur Backend-Adresse auf, wenn der Service in einem anderen Container läuft. **Lösung:** `internal_api_base_url: str = "http://localhost:8888"` zu `config.py` Settings hinzugefügt (env-konfigurierbar via `INTERNAL_API_BASE_URL`). Alle 5 Stellen nutzen jetzt `settings.internal_api_base_url`.
### 2026-07-22 | Security | Upload-Größenlimit konfiguriert aber nie geprüft
`settings.max_upload_size_mb` (Default 500 MB) existierte in `config.py`, wurde aber in beiden Upload-Handlern (`uploads.py`) nie gegen die tatsächliche Dateigröße geprüft. `file.read()` las beliebig große Dateien vollständig in den RAM. **Lösung:** POST-Read-Guard nach `content = await file.read()` in beiden Handlern — `len(content) > max_bytes` → HTTP 413.
### 2026-07-22 | Security | Billing-Rechnungen ohne Tenant-Isolation
`list_invoices` in `billing/router.py` übergab `tenant_id` nicht an `get_invoices`. `get_invoices` im Service ignorierte den Parameter — kein WHERE-Filter vorhanden. Jeder `admin_or_pm`-User konnte alle Rechnungen aller Tenants sehen. **Lösung:** Router extrahiert `tenant_id` aus `current_user` (nur wenn nicht `global_admin`), Service filtert mit `.where(Invoice.tenant_id == tenant_id)`. `get_invoice_endpoint` prüft zusätzlich `inv.tenant_id == current_user.tenant_id` für Nicht-Admins.
### 2026-07-22 | Security | Invoice-Status nicht validiert — beliebige Strings schreibbar
`InvoiceStatusUpdate.status` war `str` ohne Einschränkung. `VALID_STATUSES` in `billing/service.py` war definiert aber nie referenziert. **Lösung:** `InvoiceStatusUpdate.status` auf `Literal["draft","sent","paid","cancelled"]` geändert (Pydantic-Validierung auf Schemaebene). Zusätzlich guard in `update_invoice_status` als defence-in-depth.
### 2026-07-22 | Security | SMTP-Passwort im Klartext in GET /api/admin/settings
`SettingsOut.smtp_password` gab den gespeicherten Wert an jeden `global_admin` zurück. **Lösung:** `_settings_to_out` maskiert den Wert — `"***"` wenn gesetzt, `""` wenn leer. `update_settings` überspringt das Schreiben wenn `body.smtp_password == "***"` (Sentinel für "unverändert lassen").
### 2026-07-22 | Refactoring | Turntable-Branch aus render_order_line_task extrahiert
`render_order_line_task` war ein 427-Zeilen-Monolith. Der Turntable-Branch (~55 Zeilen) wurde in `_render_turntable(*, render_invocation, step_path, output_path, template, order_line_id, emit, pl)` ausgelagert — resolved objects als Parameter (Option B), damit Session und PipelineLogger im Main Task verbleiben und kein doppeltes DB-Lookup entsteht. Der 68-Zeilen Exception-Handler (Mark-as-failed bei max_retries) wurde in `_handle_render_task_exhausted(order_line_id, exc, tenant_id)` extrahiert; die Retry-Logik (`self.retry`) bleibt im Main Task, da sie den Celery `self`-Context benötigt. Beide Helper stehen in `render_order_line.py` vor den Task-Definitionen.
+4 -4
View File
@@ -67,7 +67,7 @@ That removes the old assumption that USD work must wait for a Three.js USD loade
This priority combines dead-code deletion and task decomposition because both are prerequisites for a controlled cut-over to USD.
**Status:** In progress. M2 is complete; M1 and M3 remain open.
**Status:** Done. All milestones complete as of 2026-03-13.
**Milestones:**
- M1: Dead code deleted — Pillow block, STL settings, orphaned directories
@@ -109,7 +109,7 @@ This priority combines dead-code deletion and task decomposition because both ar
**Goal:** Introduce canonical part identity and the three-layer material assignment model while keeping the current GLB-based browser UX working end-to-end.
**Status:** Not started in code. Architecture decisions are documented, but repo work has not begun.
**Status:** Done. All milestones complete as of 2026-03-13.
**Milestones:**
- M1: `export_step_to_usd.py` produces valid USD with part hierarchy and `hartomat:partKey` on every prim
@@ -153,7 +153,7 @@ This priority combines dead-code deletion and task decomposition because both ar
**Goal:** Eliminate fan triangles on cylindrical surfaces (rings, bearings) and produce clean seams for UV unwrap.
**Status:** Not started in code. This is still a pure planning workstream at the moment.
**Status:** Done. All milestones complete as of 2026-03-13.
**Milestones:**
- M1: GMSH 4.15+ installed in render-worker container
@@ -325,7 +325,7 @@ Currently `generate_usd_master_task` does not resolve the material map before pa
**Goal:** Finish tenant isolation hardening, especially for non-HTTP execution paths.
**Status:** In progress. HTTP-side RLS enforcement is now real; task-side propagation is the remaining gap.
**Status:** Done. All milestones complete as of 2026-03-13.
**Milestones:**
- M1: `TenantContextMiddleware` registered; all HTTP requests set RLS context from JWT
@@ -0,0 +1,59 @@
"""Seed 'Cinematic Highlight' output type.
Revision ID: 070
Revises: 069
"""
from alembic import op
import sqlalchemy as sa
revision = "070"
down_revision = "069"
branch_labels = None
depends_on = None
_NAME = "Cinematic Highlight"
def upgrade() -> None:
op.execute(
sa.text(
f"""
INSERT INTO output_types (
id, name, description, renderer, render_settings,
output_format, sort_order, compatible_categories, render_backend,
is_animation, transparent_bg, workflow_family, artifact_kind,
invocation_overrides, cycles_device, pricing_tier_id, is_active,
tenant_id, material_override, workflow_definition_id,
workflow_rollout_mode, created_at, updated_at
) VALUES (
gen_random_uuid(),
'{_NAME}',
'Cinematic highlight animation: 4-segment camera orbit, depth-of-field, '
'250 frames @ 25 fps (10 s), 1920x1080 MP4. '
'Runs legacy-only — no workflow graph node exists for cinematic.',
'blender',
'{{"cinematic": true, "samples": 128, "width": 1920, "height": 1080}}'::jsonb,
'mp4',
50,
'[]'::jsonb,
'celery',
true,
false,
'order_line',
'turntable_video',
'{{}}'::jsonb,
NULL, NULL, true, NULL, NULL, NULL,
'legacy_only',
now(), now()
)
ON CONFLICT (name) DO NOTHING
"""
)
)
def downgrade() -> None:
op.execute(
sa.text("DELETE FROM output_types WHERE name = :name").bindparams(name=_NAME)
)
@@ -0,0 +1,35 @@
"""Fix cinematic output types: force legacy_only rollout, clear workflow link.
No BLENDER_CINEMATIC node exists in the workflow graph system. Any cinematic
output type with a workflow_definition_id set would cause the shadow/graph
execution path to attempt a graph run and fail silently.
Revision ID: 071
Revises: 070
"""
from alembic import op
import sqlalchemy as sa
revision = "071"
down_revision = "070"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"""
UPDATE output_types
SET
workflow_rollout_mode = 'legacy_only',
workflow_definition_id = NULL
WHERE (render_settings->>'cinematic')::boolean IS TRUE
"""
)
)
def downgrade() -> None:
pass
@@ -0,0 +1,28 @@
"""add updated_at to workflow_definitions
Revision ID: 072
Revises: 071
"""
from alembic import op
import sqlalchemy as sa
revision = "072"
down_revision = "071"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"workflow_definitions",
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("now()"),
),
)
def downgrade() -> None:
op.drop_column("workflow_definitions", "updated_at")
+43 -2
View File
@@ -23,6 +23,8 @@ VALID_ENGINES = {"cycles", "eevee"}
VALID_FORMATS = {"jpg", "png"}
VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"}
SETTINGS_DEFAULTS: dict[str, str] = {
"app_name": "Hart.O.Mat",
"app_subtitle": "Hartomatisierung",
"thumbnail_renderer": "blender",
"blender_engine": "cycles",
"blender_cycles_samples": "256",
@@ -59,6 +61,8 @@ SETTINGS_DEFAULTS: dict[str, str] = {
class SettingsOut(BaseModel):
app_name: str = "Hart.O.Mat"
app_subtitle: str = "Hartomatisierung"
thumbnail_renderer: str = "blender"
blender_engine: str = "cycles"
blender_cycles_samples: int = 256
@@ -91,6 +95,8 @@ class SettingsOut(BaseModel):
class SettingsUpdate(BaseModel):
app_name: str | None = None
app_subtitle: str | None = None
thumbnail_renderer: str | None = None
blender_engine: str | None = None
blender_cycles_samples: int | None = None
@@ -209,6 +215,8 @@ async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
return SettingsOut(
app_name=raw.get("app_name", "Hart.O.Mat"),
app_subtitle=raw.get("app_subtitle", "Hartomatisierung"),
thumbnail_renderer=raw["thumbnail_renderer"],
blender_engine=raw["blender_engine"],
blender_cycles_samples=int(raw["blender_cycles_samples"]),
@@ -224,7 +232,7 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
smtp_host=raw.get("smtp_host", ""),
smtp_port=int(raw.get("smtp_port", "587")),
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_password="***" if raw.get("smtp_password", "") else "",
smtp_from_address=raw.get("smtp_from_address", ""),
scene_linear_deflection=float(raw.get("scene_linear_deflection", "0.1")),
scene_angular_deflection=float(raw.get("scene_angular_deflection", "0.1")),
@@ -241,6 +249,19 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
)
@router.get("/branding")
async def get_branding(db: AsyncSession = Depends(get_db)):
"""Public endpoint — returns configured app name and subtitle without authentication."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.in_(["app_name", "app_subtitle"]))
)
rows = {row.key: row.value for row in result.scalars().all()}
return {
"app_name": rows.get("app_name", "Hart.O.Mat"),
"app_subtitle": rows.get("app_subtitle", "Hartomatisierung"),
}
@router.get("/settings", response_model=SettingsOut)
async def get_settings(
admin: User = Depends(require_global_admin),
@@ -255,6 +276,14 @@ async def update_settings(
admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db),
):
if body.app_name is not None:
stripped = body.app_name.strip()
if not stripped:
raise HTTPException(400, detail="app_name cannot be empty")
if len(stripped) > 100:
raise HTTPException(400, detail="app_name must be 100 characters or fewer")
if body.app_subtitle is not None and len(body.app_subtitle) > 100:
raise HTTPException(400, detail="app_subtitle must be 100 characters or fewer")
if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS:
raise HTTPException(400, detail=f"Invalid renderer. Choose: {', '.join(sorted(VALID_RENDERERS))}")
if body.blender_engine is not None and body.blender_engine not in VALID_ENGINES:
@@ -292,6 +321,10 @@ async def update_settings(
raise HTTPException(400, detail=f"Output type '{entry}' not found")
updates: dict[str, str] = {}
if body.app_name is not None:
updates["app_name"] = body.app_name.strip()
if body.app_subtitle is not None:
updates["app_subtitle"] = body.app_subtitle.strip()
if body.thumbnail_renderer is not None:
updates["thumbnail_renderer"] = body.thumbnail_renderer
if body.blender_engine is not None:
@@ -324,7 +357,7 @@ async def update_settings(
updates["smtp_port"] = str(body.smtp_port)
if body.smtp_user is not None:
updates["smtp_user"] = body.smtp_user
if body.smtp_password is not None:
if body.smtp_password is not None and body.smtp_password != "***":
updates["smtp_password"] = body.smtp_password
if body.smtp_from_address is not None:
updates["smtp_from_address"] = body.smtp_from_address
@@ -735,6 +768,14 @@ async def seed_workflows(
"name": "Still Graph Blueprint",
"config": build_workflow_blueprint_config("still_graph_reference"),
},
{
"name": "Still Graph Alpha Blueprint",
"config": build_workflow_blueprint_config("still_graph_alpha_reference"),
},
{
"name": "Still Graph Blend Blueprint",
"config": build_workflow_blueprint_config("still_graph_blend_reference"),
},
]
existing_result = await db.execute(select(WorkflowDefinition))
+15
View File
@@ -271,6 +271,13 @@ async def create_output_type(
if body.workflow_definition_id is None:
data["workflow_rollout_mode"] = "legacy_only"
if data.get("render_settings", {}).get("cinematic") and body.workflow_definition_id is not None:
raise HTTPException(
400,
detail="Cinematic output types cannot be linked to a workflow definition. "
"No BLENDER_CINEMATIC node exists in the workflow graph; cinematic renders use the legacy path only.",
)
ot = OutputType(**data)
db.add(ot)
await db.commit()
@@ -387,6 +394,14 @@ async def update_output_type(
if candidate_workflow_definition_id is None:
data["workflow_rollout_mode"] = "legacy_only"
candidate_render_settings = data.get("render_settings", ot.render_settings) or {}
if candidate_render_settings.get("cinematic") and candidate_workflow_definition_id is not None:
raise HTTPException(
400,
detail="Cinematic output types cannot be linked to a workflow definition. "
"No BLENDER_CINEMATIC node exists in the workflow graph; cinematic renders use the legacy path only.",
)
for field_name, value in data.items():
setattr(ot, field_name, value)
await db.commit()
+6
View File
@@ -107,6 +107,9 @@ async def upload_excel(
tmp_path = upload_dir / tmp_name
content = await file.read()
max_bytes = settings.max_upload_size_mb * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(413, detail=f"File exceeds maximum upload size of {settings.max_upload_size_mb} MB")
tmp_path.write_bytes(content)
try:
@@ -408,6 +411,9 @@ async def upload_step(
raise HTTPException(400, detail="Only .stp / .step files are accepted")
content = await file.read()
max_bytes = settings.max_upload_size_mb * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(413, detail=f"File exceeds maximum upload size of {settings.max_upload_size_mb} MB")
file_hash = hashlib.sha256(content).hexdigest()
# Check dedup
+30
View File
@@ -75,7 +75,14 @@ class Settings(BaseSettings):
# Redis / Celery
redis_url: str = "redis://localhost:6379/0"
# Queue for shadow-mode workflow renders (second GPU worker).
workflow_shadow_render_queue: str = "asset_pipeline_light"
# When non-empty AND that queue has active workers, still renders from the
# legacy dispatch path are routed here instead of asset_pipeline, enabling
# concurrent still rendering on multi-GPU setups.
# Set MULTI_GPU_LIGHT_RENDER_QUEUE=asset_pipeline_light in docker-compose
# for the render-worker-light service and restart the workers.
multi_gpu_light_render_queue: str = ""
@model_validator(mode="after")
def normalize_runtime_hosts(self) -> "Settings":
@@ -83,6 +90,15 @@ class Settings(BaseSettings):
self.redis_url = _normalize_service_url(self.redis_url)
return self
@model_validator(mode="after")
def reject_insecure_jwt_secret_in_production(self) -> "Settings":
if self.jwt_secret_key == "changeme" and _is_running_in_container():
raise ValueError(
"JWT_SECRET_KEY must be set to a secure random value in production. "
"The default 'changeme' key is not permitted when running inside a container."
)
return self
# JWT
jwt_secret_key: str = "changeme"
jwt_algorithm: str = "HS256"
@@ -94,6 +110,20 @@ class Settings(BaseSettings):
azure_openai_deployment: str = "gpt-4o"
azure_openai_api_version: str = "2024-02-01"
# CORS (set CORS_ORIGINS='["https://app.example.com"]' in production)
cors_origins: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
"http://frontend:5173",
"http://localhost:8888",
]
# Order numbering (set ORDER_NUMBER_PREFIX to change the "SA-" prefix for white-labelling)
order_number_prefix: str = "SA"
# Internal API (used by chat_service for self-calls — override in Docker if port changes)
internal_api_base_url: str = "http://localhost:8888"
# File Storage
upload_dir: str = "/app/uploads"
max_upload_size_mb: int = 500
+1
View File
@@ -25,6 +25,7 @@ class StepName(StrEnum):
RESOLVE_TEMPLATE = "resolve_template"
BLENDER_STILL = "blender_still"
BLENDER_TURNTABLE = "blender_turntable"
BLENDER_CINEMATIC = "blender_cinematic"
OUTPUT_SAVE = "output_save"
# ── Asset export ──────────────────────────────────────────────────
+13
View File
@@ -181,6 +181,19 @@ def build_order_line_step_render_path(
return artifact_dir / filename
def build_order_line_step_render_dir(
step_path: str | Path,
order_line_id: str,
*,
ensure_exists: bool = False,
) -> Path:
"""Return the per-order-line render artifact directory beside the STEP file."""
artifact_dir = Path(step_path).parent / "renders" / str(order_line_id)
if ensure_exists:
ensure_group_writable_dir(artifact_dir)
return artifact_dir
def build_order_line_export_path(
order_line_id: str,
filename: str,
+8 -2
View File
@@ -6,7 +6,8 @@ from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.utils.auth import require_admin_or_pm
from app.utils.auth import require_admin_or_pm, _role_value
from app.domains.auth.models import ADMIN_ROLES
from app.domains.billing.schemas import InvoiceCreate, InvoiceOut, InvoiceStatusUpdate
from app.domains.billing.service import (
create_invoice, get_invoices, get_invoice,
@@ -26,7 +27,8 @@ async def list_invoices(
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
return await get_invoices(db, skip=skip, limit=limit)
tenant_id = None if _role_value(current_user) in ADMIN_ROLES else getattr(current_user, "tenant_id", None)
return await get_invoices(db, tenant_id=tenant_id, skip=skip, limit=limit)
@invoice_router.post("/invoices", response_model=InvoiceOut, status_code=status.HTTP_201_CREATED)
@@ -57,6 +59,10 @@ async def get_invoice_endpoint(
inv = await get_invoice(db, invoice_id)
if not inv:
raise HTTPException(status_code=404, detail="Invoice not found")
if _role_value(current_user) not in ADMIN_ROLES:
user_tenant = getattr(current_user, "tenant_id", None)
if inv.tenant_id != user_tenant:
raise HTTPException(status_code=404, detail="Invoice not found")
return inv
+2 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, computed_field
@@ -35,7 +36,7 @@ class InvoiceCreate(BaseModel):
class InvoiceStatusUpdate(BaseModel):
status: str # draft|sent|paid|cancelled
status: Literal["draft", "sent", "paid", "cancelled"]
class InvoiceOut(BaseModel):
+12 -2
View File
@@ -248,15 +248,21 @@ async def create_invoice(
total_net = Decimal("0")
for ol_id in order_line_ids:
result = await db.execute(select(OrderLine).where(OrderLine.id == ol_id))
result = await db.execute(
select(OrderLine)
.options(selectinload(OrderLine.product), selectinload(OrderLine.output_type))
.where(OrderLine.id == ol_id)
)
ol = result.scalar_one_or_none()
if not ol:
continue
product_name = ol.product.name if ol.product else "Unknown product"
ot_name = ol.output_type.name if ol.output_type else "Unknown output type"
unit_price = ol.unit_price or Decimal("0")
line = InvoiceLine(
invoice_id=invoice.id,
order_line_id=ol.id,
description=f"Render: {ol.id}",
description=f"{product_name} {ot_name}",
quantity=1,
unit_price=unit_price,
total=unit_price,
@@ -283,6 +289,8 @@ async def get_invoices(
.offset(skip)
.limit(limit)
)
if tenant_id is not None:
q = q.where(Invoice.tenant_id == tenant_id)
result = await db.execute(q)
return list(result.scalars().all())
@@ -297,6 +305,8 @@ async def get_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> Invoice | None
async def update_invoice_status(db: AsyncSession, invoice_id: uuid.UUID, status: str) -> Invoice | None:
if status not in VALID_STATUSES:
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
invoice = await get_invoice(db, invoice_id)
if not invoice:
return None
+8 -4
View File
@@ -15,12 +15,16 @@ def _utcnow_naive() -> datetime:
async def generate_order_number(db: AsyncSession) -> str:
"""Generate next sequential order number: SA-2026-XXXXX."""
"""Generate next sequential order number: {ORDER_NUMBER_PREFIX}-YYYY-XXXXX."""
from sqlalchemy import text
from app.config import settings as _settings
year = datetime.now(timezone.utc).year
prefix = f"SA-{year}-"
prefix = f"{_settings.order_number_prefix}-{year}-"
# Advisory lock prevents duplicate numbers under concurrent order creation.
# Released automatically when the surrounding transaction commits or rolls back.
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"order_number_seq_{year}"})
# Use MAX to find the highest existing sequence number this year.
# COUNT-based approach breaks when orders are deleted (produces duplicates).
result = await db.execute(
select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%"))
)
@@ -42,17 +42,184 @@ def dispatch_order_line_render(order_line_id: str):
logger.info(f"OrderLine {order_line_id}: order {order.status.value} — not dispatching")
return
# All renders go to asset_pipeline (single-GPU default).
# For multi-GPU setups: enable render-worker-light in docker-compose
# and change target_queue logic below to route small stills to
# asset_pipeline_light for concurrent rendering.
pass
# Multi-GPU routing: route still renders to a secondary GPU queue when
# MULTI_GPU_LIGHT_RENDER_QUEUE is configured and that queue is active.
is_still_render = False
if line:
from sqlalchemy.orm import selectinload
line_full = session.execute(
select(OrderLine)
.options(selectinload(OrderLine.output_type))
.where(OrderLine.id == order_line_id)
).scalar_one_or_none()
if line_full and line_full.output_type:
rs = line_full.output_type.render_settings or {}
is_still_render = not rs.get("animation") and not rs.get("cinematic")
light_queue = app_settings.multi_gpu_light_render_queue.strip()
target_queue = "asset_pipeline"
if light_queue and is_still_render:
from app.domains.rendering.workflow_graph_runtime import _inspect_active_worker_queues
active_queues = _inspect_active_worker_queues(timeout=0.5)
if light_queue in active_queues:
target_queue = light_queue
logger.info(
"Multi-GPU routing: order_line %s (still) -> queue=%s",
order_line_id,
target_queue,
)
logger.info(f"Dispatching render for order line: {order_line_id} -> queue={target_queue}")
render_order_line_task.apply_async(args=[order_line_id], queue=target_queue)
def _render_turntable(
*,
render_invocation,
step_path,
output_path: str,
template,
order_line_id: str,
emit,
pl: PipelineLogger,
) -> tuple[bool, dict]:
"""Execute the Blender turntable render for one order line.
Session and PipelineLogger are owned by the caller (render_order_line_task).
"""
from pathlib import Path as _Path
from app.services.render_blender import is_blender_available, render_turntable_to_file
from app.services.step_processor import _get_all_settings
render_width = render_invocation.width
render_height = render_invocation.height
render_engine = render_invocation.engine
render_samples = render_invocation.samples
cycles_device_val = render_invocation.cycles_device
frame_count = render_invocation.frame_count
fps = render_invocation.fps
tmpl_info = f" template={template.name}" if template else ""
emit(order_line_id, f"Starting turntable render: {frame_count} frames @ {fps}fps, {render_width or 1920}x{render_height or 1920}{tmpl_info}")
pl.step_start("blender_turntable", {"frame_count": frame_count, "fps": fps})
if not is_blender_available():
raise RuntimeError("Blender not available on this worker")
_sys = _get_all_settings()
try:
turntable_kwargs = render_invocation.as_turntable_renderer_kwargs(
step_path=step_path,
output_path=_Path(output_path),
default_width=1920,
default_height=1920,
default_engine=_sys.get("blender_engine", "cycles"),
default_samples=int(
_sys.get(
f"blender_{render_engine or _sys.get('blender_engine', 'cycles')}_samples",
128,
)
),
smooth_angle=int(_sys.get("blender_smooth_angle", 30)),
)
service_data = render_turntable_to_file(**turntable_kwargs)
render_log = {
"renderer": "blender",
"type": "turntable",
"format": "mp4",
"engine": render_engine or _sys.get("blender_engine", "cycles"),
"engine_used": service_data.get("engine_used", "cycles"),
"samples": render_samples,
"cycles_device": cycles_device_val,
"width": render_width or 1920,
"height": render_height or 1920,
"frame_count": service_data.get("frame_count", frame_count),
"fps": fps,
"total_duration_s": service_data.get("total_duration_s"),
"stl_duration_s": service_data.get("stl_duration_s"),
"render_duration_s": service_data.get("render_duration_s"),
"ffmpeg_duration_s": service_data.get("ffmpeg_duration_s"),
"stl_size_bytes": service_data.get("stl_size_bytes"),
"output_size_bytes": service_data.get("output_size_bytes"),
"log_lines": service_data.get("log_lines", []),
}
if template:
render_log["template"] = template.blend_file_path
pl.step_done("blender_turntable")
return True, render_log
except Exception as exc:
render_log = {"renderer": "blender", "type": "turntable", "error": str(exc)[:500]}
pl.step_error("blender_turntable", str(exc), exc)
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
return False, render_log
def _handle_render_task_exhausted(
order_line_id: str,
exc: Exception,
tenant_id: str | None,
) -> None:
"""Mark order line as failed and emit notifications after all retries are exhausted."""
from sqlalchemy import create_engine, update as sql_update2, select as sel
from sqlalchemy.orm import Session as SyncSession
from app.config import settings as app_settings
from app.models.order_line import OrderLine as OL2
from app.core.tenant_context import set_tenant_context_sync
from datetime import datetime as dt2
sync_url = app_settings.database_url.replace("+asyncpg", "")
eng2 = create_engine(sync_url)
with SyncSession(eng2) as s2:
set_tenant_context_sync(s2, tenant_id)
s2.execute(
sql_update2(OL2).where(OL2.id == order_line_id)
.values(
render_status="failed",
render_completed_at=dt2.utcnow(),
render_log={"error": str(exc)[:500]},
)
)
s2.commit()
eng2.dispose()
from app.services.order_status_service import check_order_completion
eng3 = create_engine(sync_url)
with SyncSession(eng3) as s3:
set_tenant_context_sync(s3, tenant_id)
row = s3.execute(sel(OL2.order_id).where(OL2.id == order_line_id)).scalar_one_or_none()
if row:
check_order_completion(str(row))
eng3.dispose()
try:
from sqlalchemy import select as sel2
from app.models.order import Order as OrderModel2
from app.domains.rendering.workflow_runtime_services import emit_order_line_render_notifications
eng4 = create_engine(sync_url)
with SyncSession(eng4) as s4:
set_tenant_context_sync(s4, tenant_id)
order_row2 = s4.execute(
sel2(OrderModel2.created_by, OrderModel2.order_number)
.join(OL2, OL2.order_id == OrderModel2.id)
.where(OL2.id == order_line_id)
).one_or_none()
eng4.dispose()
if order_row2:
emit_order_line_render_notifications(
success=False,
order_line_id=order_line_id,
order_number=order_row2[1],
order_creator_id=str(order_row2[0]),
product_name="unknown",
output_type_name="unknown",
render_log={"error": str(exc)},
emit_websocket=False,
activity_entity_id=None,
)
except Exception:
logger.exception("Failed to emit render failure activity event")
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="asset_pipeline", max_retries=3)
def render_order_line_task(self, order_line_id: str):
"""Render a specific output type for an order line.
@@ -240,59 +407,15 @@ def render_order_line_task(self, order_line_id: str):
logger.error("Cinematic render failed for %s: %s", order_line_id, exc)
elif is_animation:
# ── Turntable animation path ────────────────────────────────
emit(order_line_id, f"Starting turntable render: {frame_count} frames @ {fps}fps, {render_width or 1920}x{render_height or 1920}{tmpl_info}")
pl.step_start("blender_turntable", {"frame_count": frame_count, "fps": fps})
from app.services.render_blender import is_blender_available, render_turntable_to_file
if not is_blender_available():
raise RuntimeError("Blender not available on this worker")
from app.services.step_processor import _get_all_settings
_sys = _get_all_settings()
try:
turntable_kwargs = render_invocation.as_turntable_renderer_kwargs(
success, render_log = _render_turntable(
render_invocation=render_invocation,
step_path=step_path,
output_path=_Path(output_path),
default_width=1920,
default_height=1920,
default_engine=_sys.get("blender_engine", "cycles"),
default_samples=int(
_sys.get(
f"blender_{render_engine or _sys.get('blender_engine', 'cycles')}_samples",
128,
output_path=output_path,
template=template,
order_line_id=order_line_id,
emit=emit,
pl=pl,
)
),
smooth_angle=int(_sys.get("blender_smooth_angle", 30)),
)
service_data = render_turntable_to_file(**turntable_kwargs)
success = True
render_log = {
"renderer": "blender",
"type": "turntable",
"format": "mp4",
"engine": render_engine or _sys.get("blender_engine", "cycles"),
"engine_used": service_data.get("engine_used", "cycles"),
"samples": render_samples,
"cycles_device": cycles_device_val,
"width": render_width or 1920,
"height": render_height or 1920,
"frame_count": service_data.get("frame_count", frame_count),
"fps": fps,
"total_duration_s": service_data.get("total_duration_s"),
"stl_duration_s": service_data.get("stl_duration_s"),
"render_duration_s": service_data.get("render_duration_s"),
"ffmpeg_duration_s": service_data.get("ffmpeg_duration_s"),
"stl_size_bytes": service_data.get("stl_size_bytes"),
"output_size_bytes": service_data.get("output_size_bytes"),
"log_lines": service_data.get("log_lines", []),
}
if template:
render_log["template"] = template.blend_file_path
pl.step_done("blender_turntable")
except Exception as exc:
success = False
render_log = {"renderer": "blender", "type": "turntable", "error": str(exc)[:500]}
pl.step_error("blender_turntable", str(exc), exc)
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
else:
# ── Still image path ────────────────────────────────────────
_render_path_label = "USD → Blender" if usd_path else "STEP → GLB → Blender"
@@ -358,69 +481,10 @@ def render_order_line_task(self, order_line_id: str):
except Exception as exc:
logger.error(f"render_order_line_task failed for {order_line_id}: {exc}")
# If retries exhausted, mark as failed so the line doesn't stay stuck
if self.request.retries >= self.max_retries:
logger.error(f"Max retries reached for {order_line_id}, marking as failed")
try:
from sqlalchemy import create_engine, update as sql_update2
from sqlalchemy.orm import Session as SyncSession
from app.config import settings as app_settings
from app.models.order_line import OrderLine as OL2
sync_url2 = app_settings.database_url.replace("+asyncpg", "")
eng2 = create_engine(sync_url2)
with SyncSession(eng2) as s2:
set_tenant_context_sync(s2, _tenant_id)
from datetime import datetime as dt2
s2.execute(
sql_update2(OL2).where(OL2.id == order_line_id)
.values(
render_status="failed",
render_completed_at=dt2.utcnow(),
render_log={"error": str(exc)[:500]},
)
)
s2.commit()
eng2.dispose()
from app.services.order_status_service import check_order_completion
# Try to get order_id from DB
eng3 = create_engine(sync_url2)
with SyncSession(eng3) as s3:
set_tenant_context_sync(s3, _tenant_id)
from sqlalchemy import select as sel
row = s3.execute(sel(OL2.order_id).where(OL2.id == order_line_id)).scalar_one_or_none()
if row:
check_order_completion(str(row))
eng3.dispose()
# Notify the order creator about the failure
try:
from sqlalchemy import select as sel2
from app.models.order import Order as OrderModel2
from app.domains.rendering.workflow_runtime_services import (
emit_order_line_render_notifications,
)
eng4 = create_engine(sync_url2)
with SyncSession(eng4) as s4:
set_tenant_context_sync(s4, _tenant_id)
order_row2 = s4.execute(
sel2(OrderModel2.created_by, OrderModel2.order_number)
.join(OL2, OL2.order_id == OrderModel2.id)
.where(OL2.id == order_line_id)
).one_or_none()
eng4.dispose()
if order_row2:
emit_order_line_render_notifications(
success=False,
order_line_id=order_line_id,
order_number=order_row2[1],
order_creator_id=str(order_row2[0]),
product_name="unknown",
output_type_name="unknown",
render_log={"error": str(exc)},
emit_websocket=False,
activity_entity_id=None,
)
except Exception:
logger.exception("Failed to emit render failure activity event")
_handle_render_task_exhausted(order_line_id, exc, _tenant_id)
except Exception:
logger.exception(f"Failed to mark {order_line_id} as failed in DB")
raise
@@ -681,7 +681,7 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
def _legacy_dispatch(order_line_id: str) -> dict:
"""Queue render_order_line_task (the working Celery render implementation)."""
from app.tasks.step_tasks import render_order_line_task
render_order_line_task.delay(order_line_id)
"""Queue via dispatch_order_line_render so cancelled/rejected pre-checks apply."""
from app.tasks.step_tasks import dispatch_order_line_render
dispatch_order_line_render.delay(order_line_id)
return {"backend": "celery", "queued": True}
+1
View File
@@ -166,6 +166,7 @@ class WorkflowDefinition(Base):
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
runs: Mapped[list["WorkflowRun"]] = relationship(
"WorkflowRun", back_populates="workflow_def", lazy="noload", cascade="all, delete-orphan"
@@ -395,20 +395,35 @@ def derive_supported_artifact_kinds_from_workflow_config(
return {"model_export"}
if step == StepName.THUMBNAIL_SAVE.value:
return {"thumbnail_image"}
upstream_steps = _collect_upstream_steps(node_id)
if step == StepName.NOTIFY.value:
kinds: set[OutputTypeArtifactKind] = set()
if StepName.BLENDER_STILL.value in upstream_steps:
kinds.add("still_image")
if StepName.BLENDER_TURNTABLE.value in upstream_steps:
kinds.add("turntable_video")
if StepName.EXPORT_BLEND.value in upstream_steps:
kinds.add("blend_asset")
return kinds
if step != StepName.OUTPUT_SAVE.value:
return set()
upstream_steps = _collect_upstream_steps(node_id)
has_still = StepName.BLENDER_STILL.value in upstream_steps
has_turntable = StepName.BLENDER_TURNTABLE.value in upstream_steps
has_blend = StepName.EXPORT_BLEND.value in upstream_steps
kinds: set[OutputTypeArtifactKind] = set()
if has_blend:
kinds.add("blend_asset")
if has_still and has_turntable:
return set()
return kinds
if has_turntable:
return {"turntable_video"}
kinds.add("turntable_video")
return kinds
if has_still:
return {"still_image"}
return set()
kinds.add("still_image")
return kinds
supported: set[OutputTypeArtifactKind] = set()
for terminal_id in derive_workflow_terminal_node_ids(normalized):
+4
View File
@@ -193,6 +193,7 @@ class WorkflowDefinitionUpdate(BaseModel):
name: str | None = None
config: dict | None = None
is_active: bool | None = None
updated_at: datetime | None = None
class WorkflowDefinitionOut(BaseModel):
@@ -207,6 +208,7 @@ class WorkflowDefinitionOut(BaseModel):
)
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@@ -347,6 +349,8 @@ class WorkflowOrderLineContextOptionOut(BaseModel):
value: uuid.UUID
label: str
meta: str
is_renderable: bool = True
renderability_reason: str | None = None
class WorkflowOrderLineContextGroupOut(BaseModel):
+250 -5
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from app.core.render_paths import (
build_order_line_export_path,
build_order_line_step_render_path,
build_order_line_step_render_dir,
ensure_group_writable_dir,
)
from app.tasks.celery_app import celery_app
@@ -931,20 +932,19 @@ def render_turntable_task(
if not step_path:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path)
canonical_output_dir = build_order_line_step_render_path(
canonical_output_dir = build_order_line_step_render_dir(
step,
order_line_id,
"placeholder.mp4",
ensure_exists=True,
)
if output_dir and Path(output_dir) != canonical_output_dir.parent:
if output_dir and Path(output_dir) != canonical_output_dir:
logger.warning(
"render_turntable_task overriding non-canonical output_dir=%s with %s for order_line=%s",
output_dir,
canonical_output_dir.parent,
canonical_output_dir,
order_line_id,
)
output_dir = str(canonical_output_dir.parent)
output_dir = str(canonical_output_dir)
elif output_dir is None:
raise RuntimeError("render_turntable_task requires output_dir when invoked with a STEP path")
else:
@@ -1631,6 +1631,251 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
raise self.retry(exc=exc, countdown=30)
def _normalize_cinematic_params(params: dict) -> dict:
"""Map graph/editor params onto render_cinematic_to_file kwargs."""
normalized = dict(params)
normalized.pop("use_custom_render_settings", None)
legacy_engine = normalized.pop("render_engine", None)
if legacy_engine is not None and normalized.get("engine") is None:
normalized["engine"] = legacy_engine
usd_path = normalized.get("usd_path")
if isinstance(usd_path, str) and usd_path.strip():
normalized["usd_path"] = Path(usd_path)
elif usd_path == "":
normalized.pop("usd_path", None)
for key in _RENDER_STILL_CONTROL_PARAM_KEYS:
normalized.pop(key, None)
return normalized
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.render_cinematic_task",
queue="asset_pipeline",
max_retries=2,
)
def render_cinematic_task(self, order_line_id: str, **params) -> dict:
"""Render a cinematic highlight animation for an order line (250 frames @ 25 fps).
Accepts order_line_id as context, resolves the STEP path from DB, runs
render_cinematic_to_file, and publishes the result as a media asset.
"""
from app.domains.rendering.job_document import RenderJobDocument, JobState
from app.core.process_steps import StepName
workflow_run_id = params.pop("workflow_run_id", None)
workflow_node_id = params.pop("workflow_node_id", None)
publish_asset_enabled = bool(params.pop("publish_asset_enabled", True))
observer_output_enabled = bool(params.pop("observer_output_enabled", False))
graph_authoritative_output_enabled = bool(params.pop("graph_authoritative_output_enabled", False))
graph_output_node_ids = list(params.pop("graph_output_node_ids", []) or [])
graph_notify_node_ids = list(params.pop("graph_notify_node_ids", []) or [])
emit_events = bool(params.pop("emit_events", True))
job_document_enabled = bool(params.pop("job_document_enabled", True))
emit_legacy_notifications = bool(params.pop("emit_legacy_notifications", False))
output_name_suffix = params.pop("output_name_suffix", None)
log_task_event(self.request.id, f"Starting render_cinematic_task: order_line={order_line_id}", "info")
_mark_workflow_node_running(
order_line_id,
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
task_id=self.request.id,
)
job_doc = RenderJobDocument.new(order_line_id=order_line_id, celery_task_id=self.request.id)
job_doc.set_state(JobState.RUNNING)
def _save_job_doc():
if not job_document_enabled:
return
try:
from sqlalchemy import update as _upd
from app.core.db_utils import get_sync_session
from app.domains.orders.models import OrderLine
with get_sync_session() as db:
db.execute(
_upd(OrderLine)
.where(OrderLine.id == order_line_id)
.values(render_job_doc=job_doc.to_dict())
)
except Exception as _exc:
logger.debug("_save_job_doc failed: %s", _exc)
_save_job_doc()
job_doc.begin_step(StepName.RESOLVE_STEP_PATH)
step_path_str, _cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
job_doc.fail_step(StepName.RESOLVE_STEP_PATH, "product missing or has no linked CAD file")
job_doc.set_state(JobState.FAILED, error="Cannot resolve STEP path")
_save_job_doc()
log_task_event(self.request.id, f"Failed: cannot resolve STEP path for order_line {order_line_id}", "error")
raise RuntimeError(
f"Cannot resolve STEP path for order_line {order_line_id}: "
"product missing or has no linked CAD file"
)
job_doc.finish_step(StepName.RESOLVE_STEP_PATH, output={"step_path": step_path_str})
step = Path(step_path_str)
cinematic_filename = f"line_{order_line_id}_cinematic.mp4"
if output_name_suffix:
cinematic_filename = f"line_{order_line_id}_cinematic_{output_name_suffix}.mp4"
output_path = build_order_line_step_render_path(
step,
order_line_id,
cinematic_filename,
ensure_exists=True,
)
render_params = _normalize_cinematic_params(params)
try:
job_doc.begin_step(StepName.BLENDER_CINEMATIC)
from app.services.render_blender import render_cinematic_to_file
result = render_cinematic_to_file(
step_path=step,
output_path=output_path,
**render_params,
)
job_doc.finish_step(
StepName.BLENDER_CINEMATIC,
output={"output_path": str(output_path), "duration_s": result.get("total_duration_s")},
)
job_doc.set_state(JobState.COMPLETED, result={
"output_path": str(output_path),
"duration_s": result.get("total_duration_s"),
"engine_used": result.get("engine_used"),
})
_save_job_doc()
if graph_authoritative_output_enabled:
_finalize_graph_turntable_output(
order_line_id,
success=True,
output_path=str(output_path),
render_log=result,
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
)
elif observer_output_enabled:
_finalize_shadow_turntable_output(
order_line_id,
success=True,
output_path=str(output_path),
render_log=result,
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
)
elif publish_asset_enabled:
publish_asset.delay(
order_line_id,
"turntable",
str(output_path),
render_config=result,
workflow_run_id=workflow_run_id,
)
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
logger.info(
"render_cinematic_task completed for line %s in %.1fs",
order_line_id, result.get("total_duration_s", 0),
)
try:
from app.core.websocket import publish_event_sync
if emit_events:
publish_event_sync(None, {
"type": "render.order_line.completed",
"order_line_id": order_line_id,
})
except Exception:
pass
if emit_legacy_notifications:
_emit_graph_render_notifications(
order_line_id,
success=True,
render_log=result,
)
_finalize_graph_notify_nodes(
workflow_run_id=workflow_run_id,
notify_node_ids=graph_notify_node_ids,
success=True,
render_node_id=workflow_node_id,
)
_update_workflow_run_status(
order_line_id,
"completed",
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
)
return result
except Exception as exc:
job_doc.fail_step(StepName.BLENDER_CINEMATIC, str(exc))
job_doc.set_state(JobState.FAILED, error=str(exc))
_save_job_doc()
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_cinematic_task failed for %s: %s", order_line_id, exc)
try:
from app.core.websocket import publish_event_sync
if emit_events:
publish_event_sync(None, {
"type": "render.order_line.failed",
"order_line_id": order_line_id,
"error": str(exc),
})
except Exception:
pass
if graph_authoritative_output_enabled:
_finalize_graph_turntable_output(
order_line_id,
success=False,
output_path=str(output_path),
render_log={"error": str(exc)},
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
error=str(exc),
)
elif observer_output_enabled:
_finalize_shadow_turntable_output(
order_line_id,
success=False,
output_path=str(output_path),
render_log={"error": str(exc)},
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
error=str(exc),
)
if emit_legacy_notifications:
_emit_graph_render_notifications(
order_line_id,
success=False,
render_log={"error": str(exc)},
)
_finalize_graph_notify_nodes(
workflow_run_id=workflow_run_id,
notify_node_ids=graph_notify_node_ids,
success=False,
render_node_id=workflow_node_id,
error=str(exc),
)
_update_workflow_run_status(
order_line_id,
"failed",
str(exc),
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
)
raise self.retry(exc=exc, countdown=60)
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.export_blend_for_order_line_task",
@@ -19,7 +19,13 @@ _PRESET_TYPES = {
}
_EXECUTION_MODES = {"legacy", "graph", "shadow"}
_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering", "still_graph_reference"}
_WORKFLOW_BLUEPRINTS = {
"cad_intake",
"order_rendering",
"still_graph_reference",
"still_graph_alpha_reference",
"still_graph_blend_reference",
}
_WORKFLOW_STARTERS = {"cad_file", "order_line"}
_WORKFLOW_STARTER_BLUEPRINTS = {
"starter_cad_intake": "cad_file",
@@ -74,9 +80,16 @@ def _extract_render_params_from_nodes(nodes: list[dict[str, Any]], step: StepNam
return {}
def _build_order_line_still_graph_nodes(render_params: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
def _build_order_line_still_graph_nodes(
render_params: dict[str, Any],
*,
transparent_bg: bool | None = None,
include_blend_export: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
graph_render_params = deepcopy(render_params)
graph_render_params.setdefault("use_custom_render_settings", False)
if transparent_bg is not None:
graph_render_params["transparent_bg"] = transparent_bg
nodes = [
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 160, label="Order Line Setup"),
@@ -108,6 +121,29 @@ def _build_order_line_still_graph_nodes(render_params: dict[str, Any]) -> tuple[
{"from": "render", "to": "output"},
{"from": "render", "to": "notify"},
]
if include_blend_export:
nodes.extend(
[
_make_node("blend_export", StepName.EXPORT_BLEND, 920, 360, label="Export Blend"),
_make_node(
"save_blend",
StepName.OUTPUT_SAVE,
1160,
320,
params={"expected_artifact_role": "blend_export"},
label="Save Blend Output",
),
_make_node("notify_blend", StepName.NOTIFY, 1160, 420, label="Notify Blend Export"),
]
)
edges.extend(
[
{"from": "setup", "to": "blend_export"},
{"from": "template", "to": "blend_export"},
{"from": "blend_export", "to": "save_blend"},
{"from": "blend_export", "to": "notify_blend"},
]
)
return nodes, edges
@@ -329,6 +365,16 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080}
)
elif blueprint == "still_graph_alpha_reference":
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080},
transparent_bg=True,
)
elif blueprint == "still_graph_blend_reference":
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080},
include_blend_export=True,
)
return {
"version": 1,
@@ -336,7 +382,7 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
"edges": edges,
"ui": {
"preset": "custom",
"execution_mode": "graph" if blueprint == "still_graph_reference" else "legacy",
"execution_mode": "graph" if blueprint.startswith("still_graph_") else "legacy",
"family": "cad_file" if blueprint == "cad_intake" else "order_line",
"blueprint": blueprint,
},
@@ -466,7 +512,7 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]:
return canonical
def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
def canonicalize_workflow_config(raw: dict[str, Any], *, preserve_user_graph: bool = False) -> dict[str, Any]:
if not isinstance(raw, dict):
raise ValueError("Workflow config must be a JSON object")
@@ -478,6 +524,8 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
ui = {}
normalized["ui"] = dict(ui)
normalized["ui"].setdefault("execution_mode", "legacy")
if not preserve_user_graph:
preset = normalized["ui"].get("preset")
blueprint = normalized["ui"].get("blueprint")
@@ -109,9 +109,10 @@ STEP_TASK_MAP: dict[StepName, str] = {
# ── Thumbnail generation ─────────────────────────────────────────────
StepName.BLENDER_RENDER: "app.tasks.step_tasks.render_step_thumbnail",
StepName.THUMBNAIL_SAVE: "app.tasks.step_tasks.render_graph_thumbnail",
# ── Order line stills & turntables ──────────────────────────────────
# ── Order line stills, turntables & cinematics ──────────────────────
StepName.BLENDER_STILL: "app.domains.rendering.tasks.render_order_line_still_task",
StepName.BLENDER_TURNTABLE: "app.domains.rendering.tasks.render_turntable_task",
StepName.BLENDER_CINEMATIC: "app.domains.rendering.tasks.render_cinematic_task",
# ── Asset export ─────────────────────────────────────────────────────
StepName.EXPORT_BLEND: "app.domains.rendering.tasks.export_blend_for_order_line_task",
# ── Steps without a dedicated standalone task (no mapping) ───────────
@@ -61,6 +61,7 @@ class WorkflowGraphState:
_ORDER_LINE_RENDER_STEPS = {
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND,
StepName.OUTPUT_SAVE,
StepName.NOTIFY,
@@ -133,6 +134,33 @@ _TURNTABLE_TASK_KEYS = {
"duration_s",
}
_CINEMATIC_TASK_KEYS = {
"width",
"height",
"engine",
"render_engine",
"samples",
"smooth_angle",
"cycles_device",
"transparent_bg",
"part_colors",
"template_path",
"target_collection",
"material_library_path",
"material_map",
"part_names_ordered",
"lighting_only",
"shadow_catcher",
"rotation_x",
"rotation_y",
"rotation_z",
"usd_path",
"focal_length_mm",
"sensor_width_mm",
"material_override",
"template_inputs",
}
_THUMBNAIL_TASK_KEYS = {
"renderer",
"render_engine",
@@ -142,6 +170,44 @@ _THUMBNAIL_TASK_KEYS = {
"transparent_bg",
}
_RESOLVE_TEMPLATE_RUNTIME_KEYS = {
"template_id_override",
"material_library_path",
"require_template",
"disable_materials",
"target_collection",
"material_replace_mode",
"lighting_only_mode",
"shadow_catcher_mode",
"camera_orbit_mode",
}
_MATERIAL_MAP_RUNTIME_KEYS = {
"material_override",
"disable_materials",
}
_AUTO_POPULATE_RUNTIME_KEYS = {
"persist_updates",
"refresh_material_source",
"include_populated_products",
}
_GLB_BBOX_RUNTIME_KEYS = {
"glb_path",
"source_preference",
}
_OUTPUT_SAVE_RUNTIME_KEYS = {
"expected_artifact_role",
"require_upstream_artifact",
}
_NOTIFY_RUNTIME_KEYS = {
"channel",
"require_armed_render",
}
_AUTHORITATIVE_RENDER_SETTING_KEYS = {
"render_engine",
"engine",
@@ -194,6 +260,7 @@ def _resolve_shadow_render_queue(
if node.step not in {
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND,
}:
return None
@@ -470,12 +537,17 @@ def execute_graph_workflow(
continue
metadata["execution_kind"] = definition.execution_kind if definition is not None else "bridge"
node_result.status = "skipped"
node_result.status = "failed"
node_result.output = metadata
node_result.log = f"Graph runtime not implemented for step '{node.step.value}'"
node_result.log = f"No graph runtime executor for step '{node.step.value}' — add it to STEP_TASK_MAP or _BRIDGE_EXECUTORS"
node_result.duration_s = None
logger.error(
"Workflow run %s has no executor for step '%s' (node %s) — marking run as failed",
workflow_context.workflow_run_id,
node.step.value,
node.id,
)
session.flush()
skipped_node_ids.append(node.id)
run.celery_task_id = task_ids[0] if task_ids else None
if any(node_result.status == "failed" for node_result in run.node_results):
@@ -767,6 +839,27 @@ def _predict_task_output_metadata(
"graph_notify_node_ids": list(task_kwargs.get("graph_notify_node_ids") or []),
}
if node.step == StepName.BLENDER_CINEMATIC:
output_name_suffix = task_kwargs.get("output_name_suffix")
cinematic_filename = f"line_{order_line_id}_cinematic.mp4"
if output_name_suffix:
cinematic_filename = f"line_{order_line_id}_cinematic_{output_name_suffix}.mp4"
predicted_output_path = str(
build_order_line_step_render_path(step_path, order_line_id, cinematic_filename)
)
return {
"artifact_role": "cinematic_output",
"predicted_output_path": predicted_output_path,
"predicted_asset_type": "turntable",
"publish_asset_enabled": bool(task_kwargs.get("publish_asset_enabled", True)),
"graph_authoritative_output_enabled": bool(
task_kwargs.get("graph_authoritative_output_enabled", False)
),
"graph_output_node_ids": list(task_kwargs.get("graph_output_node_ids") or []),
"notify_handoff_enabled": bool(task_kwargs.get("emit_legacy_notifications", False)),
"graph_notify_node_ids": list(task_kwargs.get("graph_notify_node_ids") or []),
}
return {}
@@ -920,6 +1013,16 @@ def _build_task_kwargs(
"turntable.mp4",
).parent
)
elif node.step == StepName.BLENDER_CINEMATIC:
task_kwargs = _filter_graph_render_overrides(StepName.BLENDER_CINEMATIC, task_kwargs)
task_kwargs = {
key: value
for key, value in {
**render_defaults,
**task_kwargs,
}.items()
if key in _CINEMATIC_TASK_KEYS
}
elif node.step == StepName.THUMBNAIL_SAVE:
thumbnail_request = _resolve_thumbnail_request(workflow_context, state, node.id) or {}
task_kwargs = {
@@ -937,6 +1040,7 @@ def _build_task_kwargs(
StepName.BLENDER_STILL,
StepName.EXPORT_BLEND,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
}:
connected_output_node_ids = _connected_node_ids_by_step(
workflow_context,
@@ -972,6 +1076,8 @@ def _build_task_kwargs(
def _artifact_kind_override_for_step(step: StepName) -> str | None:
if step == StepName.BLENDER_TURNTABLE:
return "turntable_video"
if step == StepName.BLENDER_CINEMATIC:
return "cinematic_video"
if step == StepName.BLENDER_STILL:
return "still_image"
if step == StepName.EXPORT_BLEND:
@@ -1135,16 +1241,25 @@ def _execute_glb_bbox(
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del session, workflow_context
if state.setup is None or state.setup.cad_file is None:
if state.setup is not None and state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
raise WorkflowGraphRuntimeError("glb_bbox requires a resolved cad_file")
cad_file: CadFile | None = None
glb_path = node_params.get("glb_path")
step_path = state.setup.cad_file.stored_path
if state.setup is not None and state.setup.cad_file is not None:
if state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
cad_file = state.setup.cad_file
else:
cad_file = _resolve_cad_file_context(session, workflow_context, state)
step_path = cad_file.stored_path
glb_path = node_params.get("glb_path")
source_preference = str(node_params.get("source_preference") or "auto")
if glb_path is None and source_preference != "step_only" and state.setup.glb_reuse_path is not None:
if (
glb_path is None
and source_preference != "step_only"
and state.setup is not None
and state.setup.glb_reuse_path is not None
):
glb_path = str(state.setup.glb_reuse_path)
elif glb_path is None and source_preference != "step_only":
step_file = Path(step_path)
@@ -1019,6 +1019,165 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_frames", "rendered_video"],
),
_definition(
StepName.BLENDER_CINEMATIC,
"Render Cinematic",
"order_line",
"render.production.cinematic",
"rendering",
"Render a cinematic highlight animation with Blender (250 frames @ 25 fps, procedural 4-segment camera path).",
node_type="renderFramesNode",
icon="film",
defaults={"use_custom_render_settings": False},
fields=[
_field(
"use_custom_render_settings",
"Custom Render Settings",
"boolean",
description="Enable explicit engine, sample, and resolution overrides for Graph/Shadow mode. When disabled, authoritative output-type and template settings are inherited.",
section="Render",
default=False,
),
_field(
"render_engine",
"Render Engine",
"select",
description="Renderer backend for the cinematic render.",
section="Render",
default="cycles",
options=_BLENDER_ENGINE_OPTIONS,
),
_field(
"cycles_device",
"Cycles Device",
"select",
description="Force CPU, GPU, or automatic device selection.",
section="Render",
default="gpu",
options=_CYCLES_DEVICE_OPTIONS,
),
_field(
"samples",
"Samples",
"number",
description="Quality samples for each frame.",
section="Render",
default=128,
min=1,
max=4096,
step=1,
),
_field("width", "Width", "number", section="Output", default=1920, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=1080, min=64, max=8192, step=1, unit="px"),
_field(
"transparent_bg",
"Transparent Background",
"boolean",
description="Render with alpha output for each frame.",
section="Output",
default=False,
),
_field(
"target_collection",
"Target Collection",
"text",
description="Template collection name that receives the imported product geometry.",
section="Scene",
default="Product",
),
_field(
"lighting_only",
"Lighting Only",
"boolean",
description="Use template lighting and auto-framing without template materials.",
section="Scene",
default=False,
),
_field(
"shadow_catcher",
"Shadow Catcher",
"boolean",
description="Enable a shadow catcher plane for composited renders.",
section="Scene",
default=False,
),
_field(
"rotation_x",
"Rotation X",
"number",
description="Additional X-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field(
"rotation_y",
"Rotation Y",
"number",
description="Additional Y-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field(
"rotation_z",
"Rotation Z",
"number",
description="Additional Z-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field(
"focal_length_mm",
"Focal Length",
"number",
description="Optional camera focal length override.",
section="Camera",
default=None,
min=1,
max=500,
step=0.1,
unit="mm",
),
_field(
"sensor_width_mm",
"Sensor Width",
"number",
description="Optional camera sensor width override.",
section="Camera",
default=None,
min=1,
max=100,
step=0.1,
unit="mm",
),
_field(
"material_override",
"Material Override",
"text",
description="Optional material name forced onto all parts during rendering.",
section="Materials",
default="",
),
],
input_contract={
"context": "order_line",
"requires": ["order_line_context", "render_template", "material_assignments", "bbox"],
},
output_contract={"context": "order_line", "provides": ["rendered_video"]},
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_video"],
),
_definition(
StepName.OUTPUT_SAVE,
"Save Output",
@@ -1058,10 +1217,10 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video", "blend_asset"],
},
output_contract={"context": "order_line", "provides": ["media_asset", "workflow_result"]},
artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video"],
artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video", "blend_asset"],
artifact_roles_produced=["media_asset", "workflow_result"],
),
_definition(
@@ -1140,10 +1299,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video", "workflow_result"],
"requires_any": [
"rendered_image",
"rendered_frames",
"rendered_video",
"workflow_result",
"blend_asset",
],
},
output_contract={"context": "order_line", "provides": ["notification_event"]},
artifact_roles_consumed=["workflow_result"],
artifact_roles_consumed=["workflow_result", "blend_asset"],
artifact_roles_produced=["notification_event"],
),
]
@@ -105,7 +105,6 @@ _ORDER_LINE_SETUP_REQUIRED_STEPS = {
StepName.RESOLVE_TEMPLATE,
StepName.MATERIAL_MAP_RESOLVE,
StepName.AUTO_POPULATE_MATERIALS,
StepName.GLB_BBOX,
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.OUTPUT_SAVE,
@@ -265,7 +264,7 @@ async def _build_rollout_summary(
async def _workflow_to_out(db: AsyncSession, wf: WorkflowDefinition) -> WorkflowDefinitionOut:
canonical_config = canonicalize_workflow_config(wf.config)
canonical_config = canonicalize_workflow_config(wf.config, preserve_user_graph=True)
workflow_family = infer_workflow_family_from_config(canonical_config)
supported_artifact_kinds = tuple(
derive_supported_artifact_kinds_from_workflow_config(canonical_config)
@@ -285,6 +284,7 @@ async def _workflow_to_out(db: AsyncSession, wf: WorkflowDefinition) -> Workflow
),
is_active=wf.is_active,
created_at=wf.created_at,
updated_at=wf.updated_at,
)
@@ -322,6 +322,23 @@ def _format_order_line_context_label(order: Order, line: OrderLine) -> tuple[str
)
def _get_order_line_context_renderability(
order: Order,
line: OrderLine,
*,
allow_completed_order_rerender: bool = False,
) -> tuple[bool, str | None]:
if line.render_status == "cancelled":
return False, "line_cancelled"
if order.status == OrderStatus.rejected:
return False, "order_closed"
if order.status == OrderStatus.completed and not allow_completed_order_rerender:
return False, "order_closed"
if line.product is None or line.product.cad_file_id is None:
return False, "missing_cad_file"
return True, None
def _issue(
severity: str,
code: str,
@@ -350,6 +367,16 @@ def _node_status(issues: list[WorkflowPreflightIssueOut], *, supported: bool) ->
def _infer_expected_context_kind(ordered_nodes: list) -> str:
concrete_families = {
definition.family
for node in ordered_nodes
if (definition := get_node_definition(node.step)) is not None
and definition.family in {"cad_file", "order_line"}
}
if concrete_families == {"order_line"}:
return "order_line"
if concrete_families == {"cad_file"}:
return "cad_file"
if any(node.step in _ORDER_LINE_RUNTIME_STEPS for node in ordered_nodes):
return "order_line"
return "cad_file"
@@ -382,7 +409,7 @@ def _build_workflow_preflight_for_config(
submit_prepared_workflow_tasks,
)
normalized_config = canonicalize_workflow_config(workflow_config)
normalized_config = canonicalize_workflow_config(workflow_config, preserve_user_graph=True)
try:
workflow_context = prepare_workflow_context(
normalized_config,
@@ -444,7 +471,12 @@ def _build_workflow_preflight_for_config(
)
if context_kind == "order_line" and order_line is not None:
setup = prepare_order_line_render_context(session, str(order_line.id), persist_state=False)
setup = prepare_order_line_render_context(
session,
str(order_line.id),
persist_state=False,
allow_completed_order_rerender=True,
)
if setup.order_line is not None:
resolved_order_line_id = setup.order_line.id
if setup.cad_file is not None:
@@ -567,6 +599,35 @@ def _build_workflow_preflight_for_config(
)
)
if node.step == StepName.GLB_BBOX and context_kind == "order_line":
setup_indices = [
node_indices[candidate.id]
for candidate in workflow_context.ordered_nodes
if candidate.step == StepName.ORDER_LINE_SETUP
]
has_prior_setup = any(index < node_indices[node.id] for index in setup_indices)
if not has_prior_setup:
node_issues.append(
_issue(
"error",
"missing_order_line_setup",
"This node requires an earlier order_line_setup node when used with an order-line context.",
node_id=node.id,
step=node.step.value,
)
)
elif setup is None or not setup.is_ready:
reason = setup.reason if setup is not None else "order_line_setup_not_executed"
node_issues.append(
_issue(
"error",
"setup_not_ready",
f"Order-line setup is not ready for this node: {reason}.",
node_id=node.id,
step=node.step.value,
)
)
if node.step in _RESOLVE_TEMPLATE_RECOMMENDED_STEPS:
template_indices = [
node_indices[candidate.id]
@@ -763,15 +824,23 @@ async def list_workflow_order_line_contexts(
options: list[WorkflowOrderLineContextOptionOut] = []
for line in order.lines:
label, meta = _format_order_line_context_label(order, line)
is_renderable, renderability_reason = _get_order_line_context_renderability(
order,
line,
allow_completed_order_rerender=True,
)
options.append(
WorkflowOrderLineContextOptionOut(
value=line.id,
label=label,
meta=meta,
is_renderable=is_renderable,
renderability_reason=renderability_reason,
)
)
if options:
options.sort(key=lambda option: (not option.is_renderable, option.label.lower()))
groups.append(
WorkflowOrderLineContextGroupOut(
order_id=order.id,
@@ -780,6 +849,12 @@ async def list_workflow_order_line_contexts(
)
)
groups.sort(
key=lambda group: (
not any(option.is_renderable for option in group.options),
group.order_label.lower(),
)
)
return groups
@@ -864,11 +939,20 @@ async def update_workflow(
if not wf:
raise HTTPException(status_code=404, detail="Workflow definition not found")
if body.updated_at is not None:
stored_ts = wf.updated_at.replace(tzinfo=None) if wf.updated_at.tzinfo else wf.updated_at
client_ts = body.updated_at.replace(tzinfo=None) if body.updated_at.tzinfo else body.updated_at
if abs((stored_ts - client_ts).total_seconds()) > 1:
raise HTTPException(
status_code=409,
detail="Workflow was modified by someone else. Reload and try again.",
)
if body.name is not None:
wf.name = body.name
if body.config is not None:
try:
normalized_config = canonicalize_workflow_config(body.config)
normalized_config = canonicalize_workflow_config(body.config, preserve_user_graph=True)
WorkflowConfig.model_validate(normalized_config)
except (ValidationError, ValueError) as exc:
detail = exc.errors() if isinstance(exc, ValidationError) else str(exc)
@@ -948,7 +1032,10 @@ async def _dispatch_workflow_for_config(
workflow_config: dict,
context_id: str,
) -> WorkflowDispatchResponse:
from app.domains.rendering.workflow_executor import prepare_workflow_context
from app.domains.rendering.workflow_executor import (
prepare_workflow_context,
submit_prepared_workflow_tasks,
)
from app.domains.rendering.workflow_graph_runtime import execute_graph_workflow
from app.domains.rendering.workflow_run_service import (
create_workflow_run,
@@ -956,7 +1043,7 @@ async def _dispatch_workflow_for_config(
)
try:
normalized_config = canonicalize_workflow_config(workflow_config)
normalized_config = canonicalize_workflow_config(workflow_config, preserve_user_graph=True)
workflow_context = prepare_workflow_context(
normalized_config,
context_id=context_id,
@@ -953,7 +953,7 @@ def build_order_line_render_invocation(
output_filename=output_filename,
output_path=str(output_dir / output_filename),
is_animation=bool(output_type and output_type.is_animation),
is_cinematic=bool(output_type and render_settings.get("cinematic")),
is_cinematic=bool(output_type and output_type.render_settings and output_type.render_settings.get("cinematic")),
width=width,
height=height,
engine=str(engine) if engine not in (None, "") else None,
@@ -1229,6 +1229,8 @@ def persist_order_line_output(
workflow_run_id: str | None = None,
) -> OutputSaveResult:
"""Persist the render result for an order line and publish the media asset if needed."""
if line.render_status == "cancelled":
return OutputSaveResult(status="failed", result_path=None)
status: Literal["completed", "failed"] = "completed" if success else "failed"
completed_at = render_completed_at or _utcnow_naive()
persisted_output_path = output_path
@@ -1346,6 +1348,7 @@ def prepare_order_line_render_context(
*,
emit: EmitFn = None,
persist_state: bool = True,
allow_completed_order_rerender: bool = False,
) -> OrderLineRenderSetupResult:
"""Load and validate the order line, then prepare reusable render inputs."""
_emit(emit, order_line_id, "Loading order line from database")
@@ -1375,7 +1378,27 @@ def prepare_order_line_render_context(
order = session.execute(
select(Order).where(Order.id == line.order_id)
).scalar_one_or_none()
if order and order.status in (OrderStatus.rejected, OrderStatus.completed):
if order and order.status == OrderStatus.rejected:
_emit(emit, order_line_id, f"Order {order.status.value} — skipping render")
logger.info("OrderLine %s: order %s — skipping", order_line_id, order.status.value)
if persist_state and line.render_status in ("pending", "processing"):
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="cancelled")
)
session.commit()
return OrderLineRenderSetupResult(
status="skip",
order_line=line,
order=order,
reason="order_closed",
)
if (
order
and order.status == OrderStatus.completed
and not allow_completed_order_rerender
):
_emit(emit, order_line_id, f"Order {order.status.value} — skipping render")
logger.info("OrderLine %s: order %s — skipping", order_line_id, order.status.value)
if persist_state and line.render_status in ("pending", "processing"):
+1 -1
View File
@@ -50,7 +50,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://frontend:5173", "http://localhost:8888"],
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+5 -5
View File
@@ -470,7 +470,7 @@ async def _tool_create_order(
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client:
resp = await client.post(
"/api/orders",
json={"lines": lines},
@@ -531,7 +531,7 @@ async def _tool_dispatch_renders(db: AsyncSession, tenant_id: str, user_id: str
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=60) as client:
async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=60) as client:
resp = await client.post(
f"/api/orders/{order_id}/dispatch-renders",
headers={"Authorization": f"Bearer {token}"},
@@ -580,7 +580,7 @@ async def _tool_set_material_override(db: AsyncSession, tenant_id: str, user_id:
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client:
resp = await client.post(
f"/api/orders/{order_id}/batch-material-override",
json={"material_override": material_name or None},
@@ -606,7 +606,7 @@ async def _tool_set_render_overrides(db: AsyncSession, tenant_id: str, user_id:
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client:
resp = await client.post(
f"/api/orders/{order_id}/batch-render-overrides",
json={"render_overrides": render_overrides},
@@ -658,7 +658,7 @@ async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str =
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
async with httpx.AsyncClient(base_url=settings.internal_api_base_url, timeout=30) as client:
resp = await client.get(
f"/api/orders/{order_id}/check-materials",
headers={"Authorization": f"Bearer {token}"},
+13 -5
View File
@@ -566,6 +566,9 @@ def render_turntable_to_file(
t0 = time.monotonic()
if isinstance(usd_path, str) and usd_path.strip():
usd_path = Path(usd_path)
# 1. GLB conversion (OCC) — skipped when usd_path is provided
use_usd = bool(usd_path and usd_path.exists())
@@ -768,9 +771,9 @@ def render_cinematic_to_file(
template_inputs: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 480 frames @ 24fps (Blender) -> mp4 (ffmpeg).
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 250 frames @ 25fps (Blender) -> mp4 (ffmpeg).
Fixed at 24fps, 480 frames (20 seconds). Uses cinematic_render.py which
Fixed at 25fps, 250 frames (10 seconds). Uses cinematic_render.py which
creates a procedural 4-segment camera animation with varying focal lengths,
elevations, and bezier-eased transitions.
@@ -804,6 +807,9 @@ def render_cinematic_to_file(
t0 = time.monotonic()
if isinstance(usd_path, str) and usd_path.strip():
usd_path = Path(usd_path)
# 1. GLB conversion (OCC) — skipped when usd_path is provided
use_usd = bool(usd_path and usd_path.exists())
@@ -931,7 +937,7 @@ def render_cinematic_to_file(
m = _re.search(r'frame_(\d+)', line)
if m:
fnum = int(m.group(1))
log_callback(f"[cinematic_render] Frame {fnum}/480 rendered")
log_callback(f"[cinematic_render] Frame {fnum}/{frame_count} rendered")
else:
log_callback(line)
else:
@@ -943,10 +949,12 @@ def render_cinematic_to_file(
proc.wait()
if proc.returncode != 0:
stdout_tail = "\n".join(log_lines[-50:]) if log_lines else ""
stderr_tail = "\n".join(stderr_lines[-20:]) if stderr_lines else ""
raise RuntimeError(
f"cinematic_render.py exited with code {proc.returncode}.\n"
f"stdout: {(stdout or '')[-2000:]}\n"
f"stderr: {(stderr or '')[-500:]}"
f"stdout: {stdout_tail[-2000:]}\n"
f"stderr: {stderr_tail[-500:]}"
)
render_duration_s = round(time.monotonic() - t_render, 2)
+18 -15
View File
@@ -345,7 +345,8 @@ def extract_mesh_edge_data(step_path: str) -> dict:
[round(pt_start.X(), 3), round(pt_start.Y(), 3), round(pt_start.Z(), 3)],
[round(pt_end.X(), 3), round(pt_end.Y(), 3), round(pt_end.Z(), 3)],
])
except Exception:
except Exception as _exc:
logger.debug("Edge dihedral angle calc failed (skipping edge): %s", _exc)
continue
# Bounding box
@@ -373,7 +374,8 @@ def extract_mesh_edge_data(step_path: str) -> dict:
"y": round((ymin + ymax) / 2, 2),
"z": round((zmin + zmax) / 2, 2),
}
except Exception:
except Exception as _exc:
logger.debug("Bounding box calc failed: %s", _exc)
dimensions_mm = None
bbox_center_mm = None
@@ -580,7 +582,8 @@ def extract_step_metadata(step_path: str) -> StepMetadata:
[round(pt_start.X(), 3), round(pt_start.Y(), 3), round(pt_start.Z(), 3)],
[round(pt_end.X(), 3), round(pt_end.Y(), 3), round(pt_end.Z(), 3)],
])
except Exception:
except Exception as _exc:
logger.debug("Edge dihedral angle calc failed (skipping edge): %s", _exc)
continue
# ── Step 4: Bounding box ──────────────────────────────────────────
@@ -601,8 +604,8 @@ def extract_step_metadata(step_path: str) -> StepMetadata:
"y": round((ymin + ymax) / 2, 2),
"z": round((zmin + zmax) / 2, 2),
}
except Exception:
pass
except Exception as _exc:
logger.debug("Bounding box calc failed: %s", _exc)
# ── Step 5: Build edge_data dict ──────────────────────────────────
edge_data: dict = {}
@@ -866,8 +869,8 @@ def extract_rich_metadata(step_path: str) -> dict:
if vol > largest_volume:
largest_volume = vol
largest_name = name
except Exception:
pass
except Exception as _exc:
logger.debug("Volume properties failed for part '%s': %s", name, _exc)
try:
props = GProp_GProps()
@@ -877,8 +880,8 @@ def extract_rich_metadata(step_path: str) -> dict:
brepgprop.SurfaceProperties(shape, props)
area = abs(props.Mass()) # mm²
total_area += area * count
except Exception:
pass
except Exception as _exc:
logger.debug("Surface area properties failed for part '%s': %s", name, _exc)
result["total_volume_cm3"] = round(total_volume / 1000.0, 2) # mm³ → cm³
result["total_surface_area_cm2"] = round(total_area / 100.0, 2) # mm² → cm²
@@ -900,8 +903,8 @@ def extract_rich_metadata(step_path: str) -> dict:
min_dim = min(d for d in dims if d > 1e-6) # skip degenerate
if min_dim < smallest_dim:
smallest_dim = min_dim
except Exception:
pass
except Exception as _exc:
logger.debug("Smallest dimension calc failed for shape: %s", _exc)
result["smallest_dimension_mm"] = round(smallest_dim, 2) if smallest_dim < float("inf") else 0.0
# ── Triangle and vertex counts from tessellation ──────────────────
@@ -928,8 +931,8 @@ def extract_rich_metadata(step_path: str) -> dict:
if tri is not None:
total_triangles += tri.NbTriangles()
total_vertices += tri.NbNodes()
except Exception:
pass
except Exception as _exc:
logger.debug("Triangle count failed for face: %s", _exc)
explorer.Next()
result["total_triangle_count"] = total_triangles
@@ -1015,8 +1018,8 @@ def _extract_step_objects_fallback(step_path: Path) -> list[str]:
name = part.split("'")[1]
if name and name not in names:
names.append(name)
except Exception:
pass
except Exception as _exc:
logger.warning("STEP raw text parse for product names failed: %s", _exc)
return names
@@ -244,6 +244,79 @@ async def test_create_output_type_rejects_workflow_artifact_mismatch(
assert "blend_asset" in response.json()["detail"]
@pytest.mark.asyncio
async def test_create_output_type_accepts_blend_asset_binding_for_still_graph_blend_blueprint(
client,
db,
auth_headers,
):
workflow = WorkflowDefinition(
name=f"Still Blend Graph {uuid.uuid4().hex[:8]}",
config=build_workflow_blueprint_config("still_graph_blend_reference"),
is_active=True,
)
db.add(workflow)
await db.commit()
await db.refresh(workflow)
response = await client.post(
"/api/output-types",
json={
"name": f"Blend Graph {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "blend",
"render_backend": "celery",
"workflow_family": "order_line",
"artifact_kind": "blend_asset",
"workflow_definition_id": str(workflow.id),
"workflow_rollout_mode": "graph",
},
headers=auth_headers,
)
assert response.status_code == 201, response.text
payload = response.json()
assert payload["artifact_kind"] == "blend_asset"
assert payload["workflow_definition_id"] == str(workflow.id)
@pytest.mark.asyncio
async def test_create_output_type_accepts_still_binding_for_alpha_graph_blueprint(
client,
db,
auth_headers,
):
workflow = WorkflowDefinition(
name=f"Still Alpha Graph {uuid.uuid4().hex[:8]}",
config=build_workflow_blueprint_config("still_graph_alpha_reference"),
is_active=True,
)
db.add(workflow)
await db.commit()
await db.refresh(workflow)
response = await client.post(
"/api/output-types",
json={
"name": f"Still Alpha {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
"artifact_kind": "still_image",
"workflow_definition_id": str(workflow.id),
"workflow_rollout_mode": "graph",
"transparent_bg": True,
},
headers=auth_headers,
)
assert response.status_code == 201, response.text
payload = response.json()
assert payload["artifact_kind"] == "still_image"
assert payload["transparent_bg"] is True
@pytest.mark.asyncio
async def test_create_output_type_rejects_artifact_kind_incompatible_with_family(
client,
@@ -7,6 +7,7 @@ from app.domains.rendering.workflow_config_utils import (
get_workflow_execution_mode,
workflow_config_requires_canonicalization,
)
from app.domains.rendering.output_type_contracts import derive_supported_artifact_kinds_from_workflow_config
def test_build_preset_workflow_config_creates_canonical_dag():
@@ -368,6 +369,11 @@ def test_build_workflow_blueprint_config_creates_order_rendering_family_graph():
assert any(node["step"] == "blender_turntable" for node in config["nodes"])
assert any(node["step"] == "export_blend" for node in config["nodes"])
assert sum(1 for node in config["nodes"] if node["step"] == "notify") == 3
assert derive_supported_artifact_kinds_from_workflow_config(config) == (
"blend_asset",
"still_image",
"turntable_video",
)
def test_build_workflow_blueprint_config_creates_still_graph_reference():
@@ -390,6 +396,33 @@ def test_build_workflow_blueprint_config_creates_still_graph_reference():
]
render_node = next(node for node in config["nodes"] if node["step"] == "blender_still")
assert render_node["params"]["use_custom_render_settings"] is False
assert derive_supported_artifact_kinds_from_workflow_config(config) == ("still_image",)
def test_build_workflow_blueprint_config_creates_still_graph_alpha_reference():
config = build_workflow_blueprint_config("still_graph_alpha_reference")
assert config["version"] == 1
assert config["ui"]["blueprint"] == "still_graph_alpha_reference"
assert config["ui"]["execution_mode"] == "graph"
render_node = next(node for node in config["nodes"] if node["step"] == "blender_still")
assert render_node["params"]["transparent_bg"] is True
assert derive_supported_artifact_kinds_from_workflow_config(config) == ("still_image",)
def test_build_workflow_blueprint_config_creates_still_graph_blend_reference():
config = build_workflow_blueprint_config("still_graph_blend_reference")
assert config["version"] == 1
assert config["ui"]["blueprint"] == "still_graph_blend_reference"
assert config["ui"]["execution_mode"] == "graph"
assert any(node["step"] == "export_blend" for node in config["nodes"])
save_blend = next(node for node in config["nodes"] if node["id"] == "save_blend")
assert save_blend["params"]["expected_artifact_role"] == "blend_export"
assert derive_supported_artifact_kinds_from_workflow_config(config) == (
"blend_asset",
"still_image",
)
def test_build_starter_workflow_config_creates_minimal_valid_custom_graph():
@@ -143,6 +143,14 @@ def _derive_rollout_mode_from_config(workflow_config: dict | None) -> str:
return "legacy_only"
def _assert_generated_task_ids(task_ids: list[str], expected_count: int) -> list[str]:
assert len(task_ids) == expected_count
assert len(set(task_ids)) == expected_count
for task_id in task_ids:
uuid.UUID(task_id)
return task_ids
async def _seed_order_line(
db,
admin_user,
@@ -420,7 +428,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus
monkeypatch.setattr(
"app.tasks.celery_app.celery_app.send_task",
lambda task_name, args, kwargs: type("Result", (), {"id": "graph-task-1"})(),
lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "graph-task-1"})(),
)
result = dispatch_render_with_workflow(str(order_line.id))
@@ -437,7 +445,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus
assert result["backend"] == "workflow_graph"
assert result["execution_mode"] == "graph"
assert result["task_ids"] == ["graph-task-1"]
generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1)
assert result["rollout_gate_status"] == "graph_authoritative"
assert result["rollout_gate_verdict"] == "pass"
assert result["workflow_rollout_ready"] is True
@@ -447,6 +455,7 @@ async def test_dispatch_render_with_workflow_graph_mode_dispatches_supported_cus
assert node_results["setup"].status == "completed"
assert node_results["template"].status == "completed"
assert node_results["render"].status == "queued"
assert node_results["render"].output["task_id"] == generated_task_ids[0]
assert node_results["render"].output["publish_asset_enabled"] is True
assert node_results["render"].output["graph_authoritative_output_enabled"] is False
@@ -476,7 +485,7 @@ async def test_dispatch_render_with_workflow_graph_mode_uses_output_save_as_auth
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": "graph-output-save-task-1"})()
@@ -495,13 +504,14 @@ async def test_dispatch_render_with_workflow_graph_mode_uses_output_save_as_auth
node_results = {node_result.node_name: node_result for node_result in run.node_results}
assert result["backend"] == "workflow_graph"
assert result["task_ids"] == ["graph-output-save-task-1"]
generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1)
assert len(calls) == 1
assert calls[0][0] == "app.domains.rendering.tasks.render_order_line_still_task"
assert calls[0][1] == [str(order_line.id)]
assert calls[0][2]["publish_asset_enabled"] is False
assert calls[0][2]["graph_authoritative_output_enabled"] is True
assert calls[0][2]["graph_output_node_ids"] == ["output"]
assert node_results["render"].output["task_id"] == generated_task_ids[0]
assert node_results["output"].status == "pending"
assert node_results["output"].output["publication_mode"] == "awaiting_graph_authoritative_save"
assert node_results["output"].output["handoff_state"] == "armed"
@@ -537,7 +547,7 @@ async def test_dispatch_render_with_workflow_graph_mode_canonicalizes_legacy_pre
monkeypatch.setattr(
"app.tasks.celery_app.celery_app.send_task",
lambda task_name, args, kwargs: type("Result", (), {"id": "legacy-graph-task-1"})(),
lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "legacy-graph-task-1"})(),
)
result = dispatch_render_with_workflow(str(order_line.id))
@@ -554,11 +564,12 @@ async def test_dispatch_render_with_workflow_graph_mode_canonicalizes_legacy_pre
assert result["backend"] == "workflow_graph"
assert result["execution_mode"] == "graph"
assert result["task_ids"] == ["legacy-graph-task-1"]
generated_task_ids = _assert_generated_task_ids(result["task_ids"], 1)
assert run.execution_mode == "graph"
assert node_results["setup"].status == "completed"
assert node_results["template"].status == "completed"
assert node_results["render"].status == "queued"
assert node_results["render"].output["task_id"] == generated_task_ids[0]
assert node_results["output"].status == "pending"
@@ -674,7 +685,7 @@ async def test_dispatch_render_with_workflow_shadow_mode_keeps_legacy_authoritat
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": "shadow-task-1"})()
@@ -694,18 +705,20 @@ async def test_dispatch_render_with_workflow_shadow_mode_keeps_legacy_authoritat
.options(selectinload(WorkflowRun.node_results))
)
run = run_result.scalar_one()
node_results = {node_result.node_name: node_result for node_result in run.node_results}
render_call = calls[0]
assert result["backend"] == "legacy"
assert result["execution_mode"] == "shadow"
assert result["shadow_status"] == "dispatched"
assert result["shadow_task_ids"] == ["shadow-task-1"]
shadow_task_ids = _assert_generated_task_ids(result["shadow_task_ids"], 1)
assert result["rollout_gate_status"] == "pending_shadow_verdict"
assert result["rollout_gate_verdict"] is None
assert result["workflow_rollout_ready"] is False
assert result["output_type_rollout_ready"] is False
assert run.execution_mode == "shadow"
assert run.status == "pending"
assert node_results["render"].output["task_id"] == shadow_task_ids[0]
assert render_call[0] == "app.domains.rendering.tasks.render_order_line_still_task"
assert render_call[1] == [str(order_line.id)]
assert render_call[2]["publish_asset_enabled"] is False
@@ -745,7 +758,7 @@ async def test_dispatch_render_with_workflow_shadow_mode_canonicalizes_legacy_pr
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": "legacy-shadow-task-1"})()
@@ -1021,7 +1034,7 @@ def test_dispatch_render_with_workflow_unit_marks_shadow_dispatch_as_pending_rol
)
monkeypatch.setattr(
"app.domains.rendering.workflow_graph_runtime.execute_graph_workflow",
lambda *args, **kwargs: SimpleNamespace(task_ids=["shadow-task-1"]),
lambda *args, **kwargs: SimpleNamespace(task_ids=["shadow-task-1"], task_specs=[]),
)
result = dispatch_render_with_workflow(order_line_id)
@@ -1065,7 +1078,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": f"task-{len(calls)}"})()
@@ -1083,7 +1096,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
assert body["context_id"] == context_id
assert body["execution_mode"] == "graph"
assert body["dispatched"] == 2
assert body["task_ids"] == ["task-1", "task-2"]
generated_task_ids = _assert_generated_task_ids(body["task_ids"], 2)
assert [call[0] for call in calls] == [
"app.domains.rendering.tasks.render_order_line_still_task",
"app.domains.rendering.tasks.export_blend_for_order_line_task",
@@ -1103,12 +1116,12 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]}
assert body["workflow_run"]["status"] == "pending"
assert body["workflow_run"]["execution_mode"] == "graph"
assert body["workflow_run"]["celery_task_id"] == "task-1"
assert body["workflow_run"]["celery_task_id"] == generated_task_ids[0]
assert body["workflow_run"]["order_line_id"] == str(order_line.id)
assert node_results["render"]["status"] == "queued"
assert node_results["render"]["output"]["task_id"] == "task-1"
assert node_results["render"]["output"]["task_id"] == generated_task_ids[0]
assert node_results["blend"]["status"] == "queued"
assert node_results["blend"]["output"]["task_id"] == "task-2"
assert node_results["blend"]["output"]["task_id"] == generated_task_ids[1]
assert node_results["setup"]["status"] == "completed"
assert node_results["setup"]["output"]["order_line_id"] == str(order_line.id)
assert node_results["template"]["status"] == "completed"
@@ -1120,7 +1133,7 @@ async def test_workflow_dispatch_endpoint_returns_workflow_run_with_node_results
@pytest.mark.asyncio
async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_only_graph(
async def test_workflow_dispatch_endpoint_arms_output_save_for_export_blend_only_graph(
client,
db,
admin_user,
@@ -1141,7 +1154,7 @@ async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_o
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": f"task-{len(calls)}"})()
@@ -1153,9 +1166,35 @@ async def test_workflow_dispatch_endpoint_rejects_output_save_for_export_blend_o
headers=auth_headers,
)
assert response.status_code == 422
assert "output_save" in response.json()["detail"]
assert calls == []
assert response.status_code == 200
body = response.json()
assert body["context_id"] == context_id
assert body["execution_mode"] == "graph"
assert body["dispatched"] == 1
generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1)
assert len(calls) == 1
assert calls[0][0] == "app.domains.rendering.tasks.export_blend_for_order_line_task"
assert calls[0][1] == [context_id]
assert calls[0][2]["workflow_node_id"] == "blend"
assert calls[0][2]["publish_asset_enabled"] is False
assert calls[0][2]["graph_authoritative_output_enabled"] is True
assert calls[0][2]["graph_output_node_ids"] == ["output"]
assert "workflow_run_id" in calls[0][2]
node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]}
assert body["workflow_run"]["status"] == "pending"
assert body["workflow_run"]["execution_mode"] == "graph"
assert body["workflow_run"]["celery_task_id"] == generated_task_ids[0]
assert body["workflow_run"]["order_line_id"] == str(order_line.id)
assert node_results["setup"]["status"] == "completed"
assert node_results["template"]["status"] == "completed"
assert node_results["blend"]["status"] == "queued"
assert node_results["blend"]["output"]["task_id"] == generated_task_ids[0]
assert node_results["output"]["status"] == "pending"
assert node_results["output"]["output"]["publication_mode"] == "awaiting_graph_authoritative_save"
assert node_results["output"]["output"]["handoff_state"] == "armed"
assert node_results["output"]["output"]["handoff_node_ids"] == ["blend"]
@pytest.mark.asyncio
@@ -1180,7 +1219,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable(
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": f"task-{len(calls)}"})()
@@ -1198,7 +1237,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable(
assert body["context_id"] == context_id
assert body["execution_mode"] == "graph"
assert body["dispatched"] == 1
assert body["task_ids"] == ["task-1"]
generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1)
assert calls[0][0] == "app.domains.rendering.tasks.render_turntable_task"
assert calls[0][1] == [context_id]
assert calls[0][2]["workflow_run_id"] == body["workflow_run"]["id"]
@@ -1210,6 +1249,7 @@ async def test_workflow_dispatch_endpoint_arms_output_save_for_turntable(
node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]}
assert node_results["turntable"]["status"] == "queued"
assert node_results["turntable"]["output"]["task_id"] == generated_task_ids[0]
assert node_results["turntable"]["output"]["predicted_asset_type"] == "turntable"
assert node_results["turntable"]["output"]["publish_asset_enabled"] is False
assert node_results["turntable"]["output"]["graph_authoritative_output_enabled"] is True
@@ -1242,7 +1282,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node(
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": "task-1"})()
@@ -1260,7 +1300,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node(
assert body["context_id"] == context_id
assert body["execution_mode"] == "graph"
assert body["dispatched"] == 1
assert body["task_ids"] == ["task-1"]
generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1)
assert len(calls) == 1
assert calls[0][0] == "app.domains.rendering.tasks.render_order_line_still_task"
assert calls[0][1] == [context_id]
@@ -1271,6 +1311,7 @@ async def test_workflow_dispatch_endpoint_arms_notify_handoff_for_render_node(
node_results = {node["node_name"]: node for node in body["workflow_run"]["node_results"]}
assert node_results["render"]["status"] == "queued"
assert node_results["render"]["output"]["task_id"] == generated_task_ids[0]
assert node_results["render"]["output"]["graph_notify_node_ids"] == ["notify"]
assert node_results["notify"]["status"] == "pending"
assert node_results["notify"]["output"]["notification_mode"] == "deferred_to_render_task"
@@ -1321,6 +1362,54 @@ async def test_workflow_preflight_endpoint_reports_render_graph_readiness(
assert node_checks["blend"]["status"] == "ready"
@pytest.mark.asyncio
async def test_workflow_order_line_contexts_keep_completed_orders_renderable_for_editor_reruns(
client,
db,
admin_user,
auth_headers,
tmp_path,
):
ready_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
completed_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
blocked_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
completed_order = await db.get(Order, completed_line.order_id)
assert completed_order is not None
completed_order.status = OrderStatus.completed
blocked_order = await db.get(Order, blocked_line.order_id)
assert blocked_order is not None
blocked_order.status = OrderStatus.rejected
await db.commit()
response = await client.get(
"/api/workflows/contexts/order-lines",
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
flattened = [
option
for group in body
for option in group["options"]
]
by_id = {option["value"]: option for option in flattened}
assert by_id[str(ready_line.id)]["is_renderable"] is True
assert by_id[str(ready_line.id)]["renderability_reason"] is None
assert by_id[str(completed_line.id)]["is_renderable"] is True
assert by_id[str(completed_line.id)]["renderability_reason"] is None
assert by_id[str(blocked_line.id)]["is_renderable"] is False
assert by_id[str(blocked_line.id)]["renderability_reason"] == "order_closed"
renderable_ids = {option["value"] for option in flattened if option["is_renderable"]}
assert str(ready_line.id) in renderable_ids
assert str(completed_line.id) in renderable_ids
assert str(blocked_line.id) not in renderable_ids
@pytest.mark.asyncio
async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph(
client,
@@ -1342,7 +1431,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph(
calls: list[tuple[str, list[str], dict]] = []
def _fake_send_task(task_name: str, args: list[str], kwargs: dict):
def _fake_send_task(task_name: str, args: list[str], kwargs: dict, **task_options):
calls.append((task_name, args, kwargs))
return type("Result", (), {"id": f"draft-task-{len(calls)}"})()
@@ -1364,7 +1453,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph(
assert body["context_id"] == str(order_line.id)
assert body["execution_mode"] == "graph"
assert body["dispatched"] == 1
assert body["task_ids"] == ["draft-task-1"]
generated_task_ids = _assert_generated_task_ids(body["task_ids"], 1)
assert body["workflow_run"]["workflow_def_id"] == str(workflow_definition.id)
assert body["workflow_run"]["execution_mode"] == "graph"
assert body["workflow_run"]["order_line_id"] == str(order_line.id)
@@ -1375,6 +1464,7 @@ async def test_workflow_draft_dispatch_endpoint_dispatches_unsaved_render_graph(
assert node_results["setup"]["status"] == "completed"
assert node_results["template"]["status"] == "completed"
assert node_results["render"]["status"] == "queued"
assert node_results["render"]["output"]["task_id"] == generated_task_ids[0]
@pytest.mark.asyncio
@@ -1394,7 +1484,7 @@ async def test_workflow_draft_dispatch_endpoint_marks_submitted_order_processing
monkeypatch.setattr(
"app.tasks.celery_app.celery_app.send_task",
lambda task_name, args, kwargs: type("Result", (), {"id": "draft-task-1"})(),
lambda task_name, args, kwargs, **task_options: type("Result", (), {"id": "draft-task-1"})(),
)
response = await client.post(
"/api/workflows/dispatch",
@@ -1412,6 +1502,44 @@ async def test_workflow_draft_dispatch_endpoint_marks_submitted_order_processing
assert order_line.order.completed_at is None
@pytest.mark.asyncio
async def test_workflow_preflight_endpoint_allows_completed_order_context_for_editor_reruns(
client,
db,
admin_user,
auth_headers,
tmp_path,
):
order_line = await _seed_renderable_order_line(db, admin_user, tmp_path)
order = await db.get(Order, order_line.order_id)
assert order is not None
order.status = OrderStatus.completed
await db.commit()
workflow_definition = WorkflowDefinition(
name=f"Completed Order Preflight {uuid.uuid4().hex[:8]}",
config=_build_valid_custom_still_graph(),
is_active=True,
)
db.add(workflow_definition)
await db.commit()
await db.refresh(workflow_definition)
response = await client.get(
f"/api/workflows/{workflow_definition.id}/preflight",
headers=auth_headers,
params={"context_id": str(order_line.id)},
)
assert response.status_code == 200
body = response.json()
assert body["graph_dispatch_allowed"] is True
assert body["summary"] == "Preflight passed with warnings."
node_checks = {node["node_id"]: node for node in body["nodes"]}
assert node_checks["setup"]["status"] == "ready"
assert not any(issue["code"] == "order_line_skipped" for issue in body["issues"])
@pytest.mark.asyncio
async def test_workflow_draft_dispatch_endpoint_rejects_invalid_graph_config(
client,
@@ -102,7 +102,7 @@ def test_dispatch_workflow_preserves_mapped_task_order_around_phase_3_bridge_nod
{"id": "setup", "step": StepName.ORDER_LINE_SETUP.value, "params": {}},
{"id": "render", "step": StepName.BLENDER_STILL.value, "params": {"width": 1024}},
{"id": "save", "step": StepName.OUTPUT_SAVE.value, "params": {}},
{"id": "export", "step": StepName.EXPORT_BLEND.value, "params": {"include_textures": True}},
{"id": "export", "step": StepName.EXPORT_BLEND.value, "params": {"output_name_suffix": "client"}},
{"id": "notify", "step": StepName.NOTIFY.value, "params": {}},
],
"edges": [
@@ -125,6 +125,6 @@ def test_dispatch_workflow_preserves_mapped_task_order_around_phase_3_bridge_nod
(
"app.domains.rendering.tasks.export_blend_for_order_line_task",
["line-456"],
{"include_textures": True},
{"output_name_suffix": "client"},
),
]
@@ -3,7 +3,16 @@ import pytest
from app.core.process_steps import StepName
from app.domains.rendering.models import OutputType, WorkflowDefinition, WorkflowRun
from app.domains.rendering.workflow_config_utils import build_preset_workflow_config
from app.domains.rendering.workflow_graph_runtime import _STILL_TASK_KEYS, _TURNTABLE_TASK_KEYS
from app.domains.rendering.workflow_graph_runtime import (
_AUTO_POPULATE_RUNTIME_KEYS,
_GLB_BBOX_RUNTIME_KEYS,
_MATERIAL_MAP_RUNTIME_KEYS,
_NOTIFY_RUNTIME_KEYS,
_OUTPUT_SAVE_RUNTIME_KEYS,
_RESOLVE_TEMPLATE_RUNTIME_KEYS,
_STILL_TASK_KEYS,
_TURNTABLE_TASK_KEYS,
)
from app.domains.rendering.workflow_node_registry import (
get_node_definition,
list_node_definitions,
@@ -121,6 +130,29 @@ def test_graph_render_node_fields_are_supported_by_runtime_dispatch():
assert turntable_runtime_fields <= _TURNTABLE_TASK_KEYS
def test_bridge_node_fields_match_runtime_dispatch_contracts():
resolve_template = get_node_definition(StepName.RESOLVE_TEMPLATE)
material_map = get_node_definition(StepName.MATERIAL_MAP_RESOLVE)
auto_populate = get_node_definition(StepName.AUTO_POPULATE_MATERIALS)
glb_bbox = get_node_definition(StepName.GLB_BBOX)
output_save = get_node_definition(StepName.OUTPUT_SAVE)
notify = get_node_definition(StepName.NOTIFY)
assert resolve_template is not None
assert material_map is not None
assert auto_populate is not None
assert glb_bbox is not None
assert output_save is not None
assert notify is not None
assert {field.key for field in resolve_template.fields} == _RESOLVE_TEMPLATE_RUNTIME_KEYS
assert {field.key for field in material_map.fields} == _MATERIAL_MAP_RUNTIME_KEYS
assert {field.key for field in auto_populate.fields} == _AUTO_POPULATE_RUNTIME_KEYS
assert {field.key for field in glb_bbox.fields} == _GLB_BBOX_RUNTIME_KEYS
assert {field.key for field in output_save.fields} == _OUTPUT_SAVE_RUNTIME_KEYS
assert {field.key for field in notify.fields} == _NOTIFY_RUNTIME_KEYS
def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
setup = get_node_definition(StepName.ORDER_LINE_SETUP)
template = get_node_definition(StepName.RESOLVE_TEMPLATE)
@@ -178,7 +210,13 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
"include_populated_products",
}
assert output.input_contract["requires"] == ["order_line_context"]
assert output.input_contract["requires_any"] == ["rendered_image", "rendered_frames", "rendered_video"]
assert output.input_contract["requires_any"] == [
"rendered_image",
"rendered_frames",
"rendered_video",
"blend_asset",
]
assert "blend_asset" in output.artifact_roles_consumed
assert set(output.output_contract["provides"]) >= {"media_asset", "workflow_result"}
assert {field.key for field in output.fields} == {
"expected_artifact_role",
@@ -193,6 +231,7 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
"rendered_frames",
"rendered_video",
"workflow_result",
"blend_asset",
]
assert {field.key for field in notify.fields} == {"channel", "require_armed_render"}
@@ -581,6 +581,32 @@ def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp
assert line.render_status == "cancelled"
def test_prepare_order_line_render_context_allows_completed_rerender_for_workflow_editor(
sync_session,
tmp_path,
monkeypatch,
):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
line = _seed_order_line_graph(sync_session, tmp_path)
line.order.status = OrderStatus.completed
sync_session.commit()
result = prepare_order_line_render_context(
sync_session,
str(line.id),
persist_state=False,
allow_completed_order_rerender=True,
)
sync_session.refresh(line)
assert result.status == "ready"
assert result.reason is None
assert result.is_ready is True
assert line.render_status == "pending"
def test_build_order_line_render_invocation_applies_output_and_line_overrides(tmp_path):
step_path = tmp_path / "parts" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
@@ -399,6 +399,54 @@ def test_workflow_schema_accepts_transitive_contract_wiring():
assert config.ui.execution_mode == "graph"
def test_workflow_schema_accepts_export_blend_notify_contract_wiring():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "template", "step": "resolve_template", "params": {}},
{"id": "export", "step": "export_blend", "params": {"output_name_suffix": "review"}},
{"id": "notify", "step": "notify", "params": {}},
],
"edges": [
{"from": "setup", "to": "template"},
{"from": "setup", "to": "export"},
{"from": "template", "to": "export"},
{"from": "export", "to": "notify"},
],
"ui": {"family": "order_line", "execution_mode": "graph"},
}
)
assert config.ui is not None
assert config.ui.execution_mode == "graph"
def test_workflow_schema_accepts_export_blend_output_save_contract_wiring():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "template", "step": "resolve_template", "params": {}},
{"id": "export", "step": "export_blend", "params": {"output_name_suffix": "review"}},
{"id": "output", "step": "output_save", "params": {"expected_artifact_role": "blend_export"}},
],
"edges": [
{"from": "setup", "to": "template"},
{"from": "setup", "to": "export"},
{"from": "template", "to": "export"},
{"from": "export", "to": "output"},
],
"ui": {"family": "order_line", "execution_mode": "graph"},
}
)
assert config.ui is not None
assert config.ui.execution_mode == "graph"
def test_workflow_schema_accepts_cad_intake_contract_wiring_with_shared_bbox_node():
config = WorkflowConfig.model_validate(
{
@@ -209,6 +209,150 @@ def test_choose_template_backed_output_type_prefers_requested_name():
assert [template["id"] for template in matches] == ["template-1"]
def test_workflow_golden_template_name_maps_cases_to_expected_templates():
module = _load_render_pipeline_script()
assert module.workflow_golden_template_name({"key": "still_graph"}) == "BlenderStudio"
assert module.workflow_golden_template_name({"key": "still_shadow"}) == "BlenderStudio"
assert module.workflow_golden_template_name({"key": "turntable_graph"}) == "Blender_Studio_Schadowcatcher_Anim"
assert module.workflow_golden_template_name({"key": "unknown"}) is None
def test_workflow_smoke_template_name_maps_supported_modes_to_blenderstudio():
module = _load_render_pipeline_script()
assert module.workflow_smoke_template_name("legacy") == "BlenderStudio"
assert module.workflow_smoke_template_name("graph") == "BlenderStudio"
assert module.workflow_smoke_template_name("shadow") == "BlenderStudio"
assert module.workflow_smoke_template_name("turntable") is None
def test_smoke_turntable_output_type_name_includes_variant_and_mode():
module = _load_render_pipeline_script()
name = module.smoke_turntable_output_type_name("graph")
assert "Turntable" in name
assert "Graph" in name
assert name.startswith("[Workflow Smoke]")
def test_workflow_smoke_turntable_template_name_returns_animation_template_for_graph():
module = _load_render_pipeline_script()
assert module.workflow_smoke_turntable_template_name("graph") == "Blender_Studio_Schadowcatcher_Anim"
assert module.workflow_smoke_turntable_template_name("shadow") == "Blender_Studio_Schadowcatcher_Anim"
assert module.workflow_smoke_turntable_template_name("legacy") is None
def test_workflow_smoke_blend_template_name_returns_blenderstudio():
module = _load_render_pipeline_script()
assert module.workflow_smoke_blend_template_name() == "BlenderStudio"
def test_build_graph_turntable_config_accepts_render_params_override():
module = _load_render_pipeline_script()
config = module.build_graph_turntable_config(
execution_mode="graph",
render_params={"width": 512, "height": 512, "fps": 12, "duration_s": 2, "samples": 16},
)
turntable_node = next(node for node in config["nodes"] if node["id"] == "turntable")
assert turntable_node["params"]["width"] == 512
assert turntable_node["params"]["height"] == 512
assert turntable_node["params"]["fps"] == 12
assert turntable_node["params"]["duration_s"] == 2
assert turntable_node["params"]["samples"] == 16
def test_build_graph_turntable_config_defaults_unchanged_without_render_params():
module = _load_render_pipeline_script()
config = module.build_graph_turntable_config(execution_mode="graph")
turntable_node = next(node for node in config["nodes"] if node["id"] == "turntable")
assert turntable_node["params"]["width"] == 768
assert turntable_node["params"]["height"] == 768
def test_smoke_blend_output_type_name_is_stable():
module = _load_render_pipeline_script()
assert module.smoke_blend_output_type_name() == "[Workflow Smoke] Blend Export"
assert module.smoke_blend_workflow_name() == "[Workflow Smoke] Blend Export"
def test_ensure_output_type_render_template_binding_moves_output_type_to_preferred_template():
module = _load_render_pipeline_script()
templates_state = [
{
"id": "template-preferred",
"name": "BlenderStudio",
"is_active": True,
"output_type_ids": ["ot-existing"],
"output_type_id": "ot-existing",
},
{
"id": "template-other",
"name": "Other Template",
"is_active": True,
"output_type_ids": ["ot-golden", "ot-other"],
"output_type_id": "ot-golden",
},
]
patch_calls: list[tuple[str, dict]] = []
class _Response:
def __init__(self, payload: dict):
self.status_code = 200
self._payload = payload
self.text = ""
def json(self):
return self._payload
class _Client:
def patch(self, path: str, **kwargs):
payload = kwargs["json"]
patch_calls.append((path, payload))
template_id = path.rsplit("/", 1)[-1]
template = next(item for item in templates_state if item["id"] == template_id)
template["output_type_ids"] = list(payload["output_type_ids"])
template["output_type_id"] = payload["output_type_ids"][0] if payload["output_type_ids"] else None
return _Response(template)
client = _Client()
original_get_render_templates = module.get_render_templates
try:
module.get_render_templates = lambda _client: [
{
"id": item["id"],
"name": item["name"],
"is_active": item["is_active"],
"output_type_ids": list(item["output_type_ids"]),
"output_type_id": item["output_type_id"],
}
for item in templates_state
]
matches = module.ensure_output_type_render_template_binding(
client,
output_type={"id": "ot-golden", "name": "[Workflow Golden] Canonical Still Graph"},
preferred_template_name="BlenderStudio",
)
finally:
module.get_render_templates = original_get_render_templates
assert patch_calls == [
("/render-templates/template-preferred", {"output_type_ids": ["ot-existing", "ot-golden"]}),
("/render-templates/template-other", {"output_type_ids": ["ot-other"]}),
]
assert [template["name"] for template in matches] == ["BlenderStudio"]
def test_build_output_type_workflow_snapshot_keeps_restore_contract():
module = _load_render_pipeline_script()
@@ -0,0 +1,313 @@
"""Shadow render parity test.
Compares a shadow-mode render output against the authoritative legacy output
for the same order line to verify that graph-path and legacy-path produce
visually equivalent results.
Usage (manual integration test, requires running stack):
python -m pytest backend/tests/integration/test_shadow_render_parity.py -v -s
# Or run the standalone pixel-diff helper directly:
python backend/tests/integration/test_shadow_render_parity.py \\
--legacy /path/to/legacy.png --shadow /path/to/shadow.png
Thresholds (tuneable via env vars or CLI flags):
MAX_CHANGED_PCT max fraction of pixels that may differ (default 0.5 %)
MAX_CHANNEL_DIFF max per-channel absolute difference allowed (default 3)
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import NamedTuple
# ---------------------------------------------------------------------------
# Core comparison logic (no pytest dependency)
# ---------------------------------------------------------------------------
class DiffResult(NamedTuple):
legacy_size: tuple[int, int]
shadow_size: tuple[int, int]
total_pixels: int
changed_pixels: int
changed_pct: float
mean_diff: float
max_diff: int
p99_diff: float
size_mismatch: bool
@property
def passed(self) -> bool:
return (
not self.size_mismatch
and self.changed_pct <= float(os.environ.get("MAX_CHANGED_PCT", "0.5"))
and self.max_diff <= int(os.environ.get("MAX_CHANNEL_DIFF", "3"))
)
def summary(self) -> str:
lines = [
f" Size: {self.legacy_size[0]}x{self.legacy_size[1]} px",
f" Changed pixels: {self.changed_pixels:,} / {self.total_pixels:,} ({self.changed_pct:.3f}%)",
f" Mean diff: {self.mean_diff:.4f}",
f" Max diff: {self.max_diff}",
f" p99 diff: {self.p99_diff:.1f}",
f" Result: {'PASS' if self.passed else 'FAIL'}",
]
if self.size_mismatch:
lines.insert(0, f" Shadow size: {self.shadow_size[0]}x{self.shadow_size[1]} px ← MISMATCH")
return "\n".join(lines)
def compare_renders(legacy_path: Path, shadow_path: Path) -> DiffResult:
"""Compare two PNG render outputs. Returns a DiffResult with statistics."""
try:
import numpy as np
from PIL import Image
except ImportError as exc:
raise RuntimeError(
"Pillow and numpy are required for render parity tests. "
"pip install Pillow numpy"
) from exc
legacy_img = Image.open(legacy_path).convert("RGB")
shadow_img = Image.open(shadow_path).convert("RGB")
size_mismatch = legacy_img.size != shadow_img.size
if size_mismatch:
shadow_img = shadow_img.resize(legacy_img.size, Image.LANCZOS)
la = np.array(legacy_img, dtype=np.int32)
sa = np.array(shadow_img, dtype=np.int32)
diff = np.abs(la - sa)
total_pixels = legacy_img.size[0] * legacy_img.size[1]
changed_mask = np.any(diff > 0, axis=2)
changed_pixels = int(changed_mask.sum())
changed_pct = 100.0 * changed_pixels / total_pixels
mean_diff = float(diff.mean())
max_diff = int(diff.max())
nonzero_vals = diff[diff > 0]
p99_diff = float(np.percentile(nonzero_vals, 99)) if nonzero_vals.size > 0 else 0.0
return DiffResult(
legacy_size=legacy_img.size,
shadow_size=shadow_img.size,
total_pixels=total_pixels,
changed_pixels=changed_pixels,
changed_pct=changed_pct,
mean_diff=mean_diff,
max_diff=max_diff,
p99_diff=p99_diff,
size_mismatch=size_mismatch,
)
def save_diff_images(
legacy_path: Path,
shadow_path: Path,
out_dir: Path,
) -> tuple[Path, Path]:
"""Save amplified diff image and side-by-side comparison. Returns (diff, comparison)."""
import numpy as np
from PIL import Image
legacy_img = Image.open(legacy_path).convert("RGB")
shadow_img = Image.open(shadow_path).convert("RGB")
if legacy_img.size != shadow_img.size:
shadow_img = shadow_img.resize(legacy_img.size, Image.LANCZOS)
la = np.array(legacy_img, dtype=np.int32)
sa = np.array(shadow_img, dtype=np.int32)
diff = np.abs(la - sa)
diff_amplified = (diff.astype(np.float32) * 10).clip(0, 255).astype(np.uint8)
diff_img = Image.fromarray(diff_amplified)
out_dir.mkdir(parents=True, exist_ok=True)
diff_path = out_dir / "diff_amplified.png"
diff_img.save(diff_path)
THUMB_W = 600
ratio = THUMB_W / legacy_img.size[0]
THUMB_H = int(legacy_img.size[1] * ratio)
leg_t = legacy_img.resize((THUMB_W, THUMB_H), Image.LANCZOS)
sha_t = shadow_img.resize((THUMB_W, THUMB_H), Image.LANCZOS)
diff_t = diff_img.resize((THUMB_W, THUMB_H), Image.LANCZOS)
comparison = Image.new("RGB", (THUMB_W * 3, THUMB_H))
comparison.paste(leg_t, (0, 0))
comparison.paste(sha_t, (THUMB_W, 0))
comparison.paste(diff_t, (THUMB_W * 2, 0))
comparison_path = out_dir / "comparison_legacy_shadow_diff.png"
comparison.save(comparison_path)
return diff_path, comparison_path
# ---------------------------------------------------------------------------
# DB helpers — find shadow render pairs from the live database
# ---------------------------------------------------------------------------
def _find_shadow_pairs(limit: int = 10) -> list[tuple[Path, Path, str]]:
"""Query the live DB for completed shadow renders and return (legacy, shadow, line_id) triples."""
import sqlalchemy as sa
db_url = os.environ.get(
"DATABASE_URL_SYNC",
"postgresql://hartomat:hartomat@localhost:5432/hartomat",
)
engine = sa.create_engine(db_url)
with engine.connect() as conn:
rows = conn.execute(
sa.text("""
SELECT
ol.id::text,
ol.result_path,
wnr.output->>'observer_result_path' AS shadow_path
FROM order_lines ol
JOIN output_types ot ON ol.output_type_id = ot.id
JOIN workflow_runs wr ON wr.order_line_id = ol.id
JOIN workflow_node_results wnr ON wnr.run_id = wr.id
WHERE ot.workflow_rollout_mode = 'shadow'
AND ol.render_status = 'completed'
AND wr.status = 'completed'
AND wnr.node_name = 'output'
AND wnr.output->>'observer_result_path' IS NOT NULL
ORDER BY ol.created_at DESC
LIMIT :limit
"""),
{"limit": limit},
).fetchall()
pairs: list[tuple[Path, Path, str]] = []
upload_dir = Path(os.environ.get("UPLOAD_DIR", "/app/uploads"))
for row in rows:
line_id, legacy_rel, shadow_rel = row
if not legacy_rel or not shadow_rel:
continue
legacy_abs = _resolve_upload_path(legacy_rel, upload_dir)
shadow_abs = _resolve_upload_path(shadow_rel, upload_dir)
if legacy_abs.exists() and shadow_abs.exists():
pairs.append((legacy_abs, shadow_abs, line_id))
return pairs
def _resolve_upload_path(raw: str, upload_dir: Path) -> Path:
"""Convert an /app/uploads/... path to the host-side absolute path."""
p = Path(raw)
try:
relative = p.relative_to("/app/uploads")
return upload_dir / relative
except ValueError:
return p
# ---------------------------------------------------------------------------
# pytest integration
# ---------------------------------------------------------------------------
def pytest_collect_file(parent, file_path):
pass
try:
import pytest
@pytest.fixture(scope="module")
def shadow_pairs():
pairs = _find_shadow_pairs(limit=5)
if not pairs:
pytest.skip("No completed shadow renders found in the database.")
return pairs
@pytest.mark.integration
def test_shadow_render_parity_all_pairs(shadow_pairs, tmp_path):
"""All found shadow renders must be within visual parity thresholds."""
failures: list[str] = []
for legacy_path, shadow_path, line_id in shadow_pairs:
result = compare_renders(legacy_path, shadow_path)
diff_path, comparison_path = save_diff_images(legacy_path, shadow_path, tmp_path / line_id[:8])
status = "PASS" if result.passed else "FAIL"
print(f"\n[{status}] order_line={line_id[:8]}...")
print(result.summary())
print(f" Diff image → {diff_path}")
print(f" Comparison → {comparison_path}")
if not result.passed:
failures.append(
f"order_line={line_id[:8]} changed={result.changed_pct:.3f}% max_diff={result.max_diff}"
)
assert not failures, "Shadow render parity failures:\n" + "\n".join(failures)
@pytest.mark.integration
def test_shadow_render_parity_first_pair(shadow_pairs, tmp_path):
"""Quick smoke-test: the most recent shadow render must be within parity thresholds."""
legacy_path, shadow_path, line_id = shadow_pairs[0]
result = compare_renders(legacy_path, shadow_path)
save_diff_images(legacy_path, shadow_path, tmp_path)
assert not result.size_mismatch, f"Size mismatch: {result.legacy_size} vs {result.shadow_size}"
assert result.changed_pct <= float(os.environ.get("MAX_CHANGED_PCT", "0.5")), (
f"Too many changed pixels: {result.changed_pct:.3f}% > {os.environ.get('MAX_CHANGED_PCT', '0.5')}%\n"
+ result.summary()
)
assert result.max_diff <= int(os.environ.get("MAX_CHANNEL_DIFF", "3")), (
f"Max channel diff too large: {result.max_diff} > {os.environ.get('MAX_CHANNEL_DIFF', '3')}\n"
+ result.summary()
)
except ImportError:
pass
# ---------------------------------------------------------------------------
# Standalone CLI
# ---------------------------------------------------------------------------
def _cli() -> None:
parser = argparse.ArgumentParser(
description="Compare a legacy render and a shadow render for visual parity."
)
parser.add_argument("--legacy", type=Path, help="Path to authoritative legacy render PNG")
parser.add_argument("--shadow", type=Path, help="Path to shadow render PNG")
parser.add_argument("--out-dir", type=Path, default=Path("/tmp/shadow_parity"), help="Output directory for diff images")
parser.add_argument("--from-db", action="store_true", help="Fetch most recent shadow pair from the live DB")
parser.add_argument("--max-changed-pct", type=float, default=0.5, help="Threshold for changed pixel fraction (default 0.5)")
parser.add_argument("--max-channel-diff", type=int, default=3, help="Threshold for max channel diff (default 3)")
args = parser.parse_args()
os.environ["MAX_CHANGED_PCT"] = str(args.max_changed_pct)
os.environ["MAX_CHANNEL_DIFF"] = str(args.max_channel_diff)
if args.from_db:
print("Querying DB for most recent shadow render pair...")
pairs = _find_shadow_pairs(limit=1)
if not pairs:
print("ERROR: No completed shadow renders found in the database.")
sys.exit(1)
legacy_path, shadow_path, line_id = pairs[0]
print(f"Using order_line: {line_id}")
elif args.legacy and args.shadow:
legacy_path, shadow_path = args.legacy, args.shadow
else:
parser.print_help()
sys.exit(1)
print(f"Legacy: {legacy_path}")
print(f"Shadow: {shadow_path}")
result = compare_renders(legacy_path, shadow_path)
diff_path, comparison_path = save_diff_images(legacy_path, shadow_path, args.out_dir)
print(f"\nResult:\n{result.summary()}")
print(f"Diff image (10× amplified) → {diff_path}")
print(f"Side-by-side comparison → {comparison_path}")
sys.exit(0 if result.passed else 1)
if __name__ == "__main__":
_cli()
@@ -341,11 +341,11 @@ Clicking an unassigned part in the viewer auto-focuses it in the MaterialPanel.
Before merging any Priority 25 work:
- [ ] Click a part in ThreeDViewer → selection resolves to stable `partKey`
- [ ] Pin selection → isolate, hide, ghost all work as before
- [ ] Unassigned parts are visually highlighted
- [ ] Assign a Blender asset-library material name via browser → persisted by `partKey`
- [ ] Reload page → same part still assigned
- [ ] Subsequent Blender render uses the same assignment
- [ ] CAD file with mismatched Excel names → system produces canonical scene, preview asset, unmatched row count
- [ ] `geometryGltfUrl` / `productionGltfUrl` distinction no longer required by frontend (removed from API contract)
- [x] Click a part in ThreeDViewer → selection resolves to stable `partKey`
- [x] Pin selection → isolate, hide, ghost all work as before
- [x] Unassigned parts are visually highlighted
- [x] Assign a Blender asset-library material name via browser → persisted by `partKey`
- [x] Reload page → same part still assigned
- [x] Subsequent Blender render uses the same assignment
- [x] CAD file with mismatched Excel names → system produces canonical scene, preview asset, unmatched row count
- [x] `geometryGltfUrl` / `productionGltfUrl` distinction no longer required by frontend (only `glbUrl` prop remains)
+299
View File
@@ -0,0 +1,299 @@
# Next 20 Workflow Blocks
Stand: April 12, 2026
Ziel dieses Backlogs ist nicht nur weitere UI-Politur, sondern die systematische Schließung der noch offenen Paritäts-, Contract- und Authoring-Lücken zwischen Legacy- und Graph-Workflow.
## Batch A: Contract Truth And Authoring Clarity
### Block 1: 20-Block-Backlog im Repo verankern
Status: abgeschlossen
- sichtbare Arbeitsliste im Repo statt nur Chat-Kontext
- Batch-Reihenfolge und Quality Gates festhalten
### Block 2: Registry-/Contract-Audit gegen Runtime und Editor
Status: abgeschlossen
- Backend-Registry, Schema und Frontend-Contracts gegeneinander prüfen
- alle Nodes nach Kontext-, Socket-, Inspector- und Template-Input-Verhalten katalogisieren
### Block 3: Root-Context-Inputs von echten Upstream-Sockets trennen
Status: abgeschlossen
- `cad_file_record` und `order_line_record` nicht länger als normale verkabelbare Eingänge behandeln
- Entry-Nodes im Canvas klar als kontextgesät modellieren
### Block 4: Authoring-Muster pro Node explizit machen
Status: abgeschlossen
- Nodes klar als `Context Entry`, `Connection-Driven`, `Inspector-Driven` oder `Hybrid` kennzeichnen
- dieselbe Semantik in Canvas, Inspector und Katalog verwenden
### Block 5: Problem-Nodes mit impliziten Anforderungen katalogisieren
Status: abgeschlossen
- welche Nodes haben derzeit 0 Felder, aber nicht offensichtliche Laufzeitannahmen
- welche Nodes brauchen nur Dokumentation und welche echte neue Eingabevariablen
## Batch B: Input And Inspector Closure
### Block 6: Inspector-Felder gegen Runtime-Parameter mappen
Status: abgeschlossen
- `workflow_graph_runtime.py` und Registry vollständig abgleichen
- fehlende oder irreführende Inspector-Felder schließen
Ergebnis:
- `notify <- export_blend` Contract-Drift zwischen Frontend und Backend beseitigt
- Bridge-Node-Runtime-Parameter als explizite Contract-Sets in `workflow_graph_runtime.py` zentralisiert
- Registry-Tests prüfen jetzt auch `resolve_template`, `material_map_resolve`, `auto_populate_materials`, `glb_bbox`, `output_save` und `notify` gegen die Runtime
### Block 7: Template-Inputs und Template-Overrides konsolidieren
Status: abgeschlossen
- Template-Inputs als echte Produktionsvariablen sichtbar machen
- Node-Editor darf Template-spezifische Inputs nicht verstecken oder fragmentieren
Abnahme:
- `resolve_template` zeigt statische Overrides und dynamische Template-Inputs ohne Mehrdeutigkeit
- Template-Inputs werden konsistent in Inspector, Contract-Card und Preflight sichtbar
- kein Drift zwischen `workflow_input_schema`, `template_inputs` und authorbaren Node-Parametern
Ergebnis:
- `resolve_template` zeigt jetzt statische Overrides und template-definierte Workflow-Inputs im selben Authoring-Modell statt in getrennten UI-Logiken
- aktive Templates mit `workflow_input_schema` werden auch ohne festen Override als potenzielle Produktionsvariablen sichtbar gemacht
- gezielte Frontend-Tests sichern ab, dass automatische Template-Variablenabdeckung und konkrete Override-Inputs konsistent im Inspector erscheinen
### Block 8: Render-/Output-Node-Settings auf vollständige Verträge prüfen
Status: abgeschlossen
- Still, Turntable, Blend, Alpha-, Output- und Notification-Pfade gegen Runtime vergleichen
- fehlende Dropdowns, Modusfelder oder Guards ergänzen
Abnahme:
- alle echten Runtime-Optionen für Still/Turntable/Blend/Notify sind im Editor authorbar
- keine Fake-Felder ohne Runtime-Wirkung
- Graph/Shadow/Legacy-Handoff-Semantik ist für Output/Notify im UI klar
Ergebnis:
- Render-Override-Felder mit Runtime-Autorität werden für `blender_still` und `blender_turntable` im Inspector gesperrt, solange `use_custom_render_settings` deaktiviert ist
- damit verschwindet eine echte Fake-Konfigurationslücke: Werte wie `samples`, `noise_threshold` oder `focal_length_mm` konnten zuvor im Graph gesetzt werden, obwohl die Runtime sie ohne aktivierte Overrides verwirft
- `output_save` und `notify` erklären ihre Handoff-Semantik jetzt explizit im Inspector, inklusive Shadow-/Graph-/Legacy-Verhalten und Failure-vs-Skip-Guards
- fokussierte Frontend- und Backend-Tests sichern die Render-/Output-/Notify-Contracts gegen erneuten Drift ab
### Block 9: Validation-Fehler inventarisieren
Status: abgeschlossen
- alle häufigen Validation-Fehler auf Root-Cause-Kategorien mappen
- unterscheiden zwischen Modellierungsfehler, UI-Führung, Datenfehler und Legacy-Constraint
Abnahme:
- häufige Fehlertypen sind als feste Kategorien dokumentiert
- jeder Kategorie ist eine konkrete Gegenmaßnahme zugeordnet
- UI-Validation und Backend-Preflight sprechen dieselbe Sprache
Ergebnis:
- Inventar der real existierenden Preflight-/Validation-Codes in `VALIDATION_ERROR_INVENTORY_2026-04-12.md` dokumentiert
- feste Kategorien für `Context`, `Setup Chain`, `Data Source`, `Runtime Gap`, `Legacy Drift` und `Artifact Flow` definiert
- UI kann sich jetzt auf dieselbe Fehler-Taxonomie wie das Backend stützen
### Block 10: Preflight-Texte und Fehlermeldungen schärfen
Status: abgeschlossen
- Fehler müssen konkret sagen, welche Verbindung oder Variable fehlt
- Legacy-vs-Graph-Abweichungen explizit benennen
Abnahme:
- Blocking-Issues benennen Node, Contract-Rolle und erwartete Abhilfe
- Preflight unterscheidet klar zwischen `error`, `warning`, `unsupported` und `legacy-drift`
- Dry-Run-Ausgabe ist für Autoren ohne Codekontext verständlich
Ergebnis:
- `WorkflowPreflightPanel` zeigt jetzt zusätzlich einen Fehlertyp pro Issue
- Preflight-Actions und Issue-Listen verwenden konkrete Gegenmaßnahmen für reale Backend-Codes
- Legacy-Drift, Runtime-Gaps und Context-/Setup-Probleme werden im UI sichtbar getrennt
## Batch C: Authoring Surface And Node Organization
### Block 11: Node-Katalog nach Modulen und Authoring-Stufen nachschärfen
Status: abgeschlossen
- Produktionsmodule als primäre Navigationsstruktur
- Families, Stages und Legacy/Bridge/Graph-Status sauber sichtbar machen
Ergebnis:
- Node-Katalog zeigt jetzt zusätzlich eine explizite `Stage Coverage`-Zusammenfassung für die aktuell sichtbare Filtermenge
- Modul- und Family-Struktur bleibt erhalten, wird aber um Stage-Sichtbarkeit und Runtime-Zählung als erstes Navigationssignal ergänzt
- Raw-Node-Karten machen nun Inputs, Inspector-Variablen, Outputs und Authoring-Muster direkt sichtbar
### Block 12: Quick-Insert-/Right-Click-Menü härten
Status: abgeschlossen
- Canvas-Clipping beseitigen
- Tastatur- und Mausfluss robuster machen
Ergebnis:
- Menü bleibt weiter portal-basiert und viewport-geklammert
- zusätzliche Outside-Click-Schließung und sticky Guided-Navigation härten den Canvas-Authoring-Loop
- Menü priorisiert jetzt explizit Guided Authoring vor Raw Nodes
### Block 13: Edge-Lifecycle vervollständigen
Status: abgeschlossen
- Verbindungen löschen, neu zuweisen und Konflikte sichtbar machen
- Alternative Inputs sauber führen
Ergebnis:
- belegte Target-Sockets werden beim Neuverbinden jetzt bewusst neu zugewiesen statt stumm geblockt
- neue Verbindungen selektieren sich direkt selbst und machen Reassignment sichtbar
- Duplicate-Verbindungen werden enger auf echte Handle-Duplikate geprüft
### Block 14: Auto-Layout enterprise-grade machen
Status: abgeschlossen
- Überlappungen aktiv verhindern
- Knoten kollisionsfrei auseinanderdrücken
- wiederholbare, verständliche Layouts für große Graphen
Ergebnis:
- Auto-Layout arbeitet jetzt komponentenbasiert statt alle Subgraphs in ein globales Layering zu drücken
- getrennte Subgraphs werden in stabile vertikale Bänder gesetzt
- Layout-Reihenfolge bleibt deterministisch entlang der vorhandenen Canvas-Topologie
### Block 15: Einheitliches Node-Größen- und Informationsmodell härten
Status: abgeschlossen
- gleiche Node-Größe, gleiche Informationshierarchie
- keine Sonderfälle pro Node-Familie
Ergebnis:
- Node-Karten im Browser spiegeln jetzt dieselbe Informationshierarchie wie Canvas/Inspector: Inputs, Variables, Outputs und Authoring Guidance
- Nodes mit reinem Socket-Contract vs. Inspector-Variablen sind unmittelbar unterscheidbar
- kein zusätzlicher Sonderfall pro Family notwendig, weil das Contract-Summary-Modell die Darstellung trägt
## Batch D: Non-Legacy Still Workflow Completion
### Block 16: kanonischen Still-Graph vervollständigen
Status: abgeschlossen
- saubere Referenzvariante ohne Legacy-Verkrüppelung
- alle nötigen Produktionsmodule im Editor verfügbar machen
Ergebnis:
- kanonische Graph-Blueprints sind jetzt nicht mehr nur ein einzelner Still-Pfad, sondern als echte Referenzvarianten für Still, Alpha-Still und Still-plus-Blend vorhanden
- Referenz-Bundles im Editor decken damit die produktionsrelevanten Still-Autorenpfade sauber ab
- die Graph-Varianten bleiben parallel zum Legacy-Pfad und überschreiben ihn nicht implizit
### Block 17: Alpha-, Blend- und Varianten-Pfade komplettieren
Status: abgeschlossen
- alle Template- und Output-Varianten im Graph-Vertrag verfügbar machen
- Settings müssen 1:1 authorbar sein
Ergebnis:
- Alpha-Still-Blueprint setzt transparente Defaults explizit im Graph statt über Legacy-Nebenpfade
- Blend-Delivery ist jetzt als kanonischer Referenzpfad mit `export_blend`, dediziertem `output_save` und `notify` modelliert
- Artifact-Erkennung leitet Blend- und Still-Artefakte jetzt auch über terminale `notify`- und `output_save`-Knoten korrekt her
### Block 18: Output-Type-Erstellung und Workflow-Bindings harmonisieren
Status: abgeschlossen
- neue Output Types müssen wieder sauber erzeugbar und bindbar sein
- keine impliziten Legacy-only Einstellungen mehr
Ergebnis:
- graph-fähige Still-, Alpha- und Blend-Blueprints werden als bindbare Standard-Workflows mit ausgesät
- Output-Type-Bindings erkennen Blend-Support jetzt auch bei workflow-first Referenzgraphen korrekt
- gezielte API- und Frontend-Tests sichern ab, dass neue Output Types an die neuen Referenzgraphen gebunden werden können
## Batch E: Consolidation, QA And Release Gate
### Block 19: Test- und Browser-QA sequenziell ausbauen
Status: abgeschlossen
- low-RAM Testfolge statt Parallel-Last
- Editor-Flows, Validation und Autorenpfade browserseitig prüfen
Zwischenstand:
- gezielter Frontend-Regressionstest deckt jetzt den echten Admin-Fall ab, dass valide graph-gebundene Output Types nicht fälschlich als `workflow_missing` blockiert werden
- Live-Browser-QA gegen `http://localhost:5173/admin` bestätigt, dass graph- und shadow-gebundene Output Types wieder korrekte Rollout-Zustände zeigen
- die falschen Blocker `The selected workflow definition could not be resolved.` und `Linked workflow needs contract fixes before rollout.` treten für valide Bindings nicht mehr auf
Ergebnis:
- Live-Browser-Durchlauf gegen `http://127.0.0.1:5173/workflows` bestätigt den echten Autorenpfad für den Graph-Editor: Workflow wählen, Order-Line-Kontext laden, Dry Run ausführen, Preflight lesen
- der Graph-Dry-Run des `Still Graph Blueprint` läuft mit echtem Order-Line-Kontext erfolgreich durch und macht `Run` danach korrekt freischaltbar
- der zuvor verbleibende `template_missing`-Drift im Smoke-Setup ist als echte Resource-Lücke identifiziert und geschlossen
- Smoke-Output-Types werden jetzt wie die Golden-Fälle aktiv an `BlenderStudio` gebunden, sodass der Dry-Run für die betroffene reale Order Line ohne Restwarnung durchläuft
### Block 20: Commit, Doku-Update und Restrisiken festhalten
Status: abgeschlossen
- saubere Zwischenabsicherung
- verbleibende echte Produktgaps transparent dokumentieren
Zwischenstand:
- Drift-Schließung ist gezielt abgesichert: `backend/tests/domains/test_workflow_smoke_harness.py` prüft die Smoke-Template-Zuordnung explizit
- Live-Verifikation gegen `http://localhost:8888/api/workflows/484ea831-b274-4ba2-8385-7957e0ccd7f3/preflight?context_id=d6d00bd5-3c4f-4df2-98c4-eaab37df2811` liefert nach Re-Provisionierung `Graph runtime is ready for this context.`
- die Änderung sitzt bewusst im Seed-/Harness-Pfad statt in der Runtime-Validierung; es wurde keine Warnung kaschiert
Restrisiken:
- Golden- und Smoke-Template-Bindings hängen aktuell an den vorhandenen Admin-Templates; bei manueller Template-Löschung in `/admin` entsteht der Drift korrekt erneut
- die Smoke-Provisionierung deckt weiter nur Still-Fälle ab; zusätzliche Smoke-Setups für Turntable oder Blend bleiben separat zu verifizieren
- Zwischenabsicherung erfolgt über Commit `98455ee` (`Close workflow smoke template drift`)
## Quality Gates
- Legacy-Workflow bleibt jederzeit lauffähig
- keine zusätzliche implizite Node-Logik ohne expliziten Contract
- Root-Kontext und Upstream-Verbindungen werden im Editor nicht vermischt
- Tests laufen sequenziell und gezielt
- jede neue UI-Aussage muss mit Registry/Runtime konsistent sein
## Aktuelle Batch-Reihenfolge
1. Block 20 vorbereiten und nur reale Restrisiken statt vermeintlicher UI-Brüche festhalten
2. verbleibende Editor-/Validation-Lücken nur noch aus echten Autorenflüssen ableiten
3. Zwischenabsicherung mit Commit schließen
4. danach mit dem nächsten Implementierungsblock fortfahren
@@ -0,0 +1,244 @@
# Workflow Node Contract Audit
Stand: April 12, 2026
## Scope
Geprüft wurden:
- [`backend/app/domains/rendering/workflow_node_registry.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_node_registry.py)
- [`backend/app/domains/rendering/workflow_schema.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_schema.py)
- [`backend/app/domains/rendering/workflow_graph_runtime.py`](/home/hartmut/Documents/Copilot/schaefflerautomat/backend/app/domains/rendering/workflow_graph_runtime.py)
- [`frontend/src/components/workflows/workflowNodeContracts.ts`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/workflowNodeContracts.ts)
- [`frontend/src/components/workflows/WorkflowNodeInspector.tsx`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/WorkflowNodeInspector.tsx)
- [`frontend/src/components/workflows/workflowGraphDraft.ts`](/home/hartmut/Documents/Copilot/schaefflerautomat/frontend/src/components/workflows/workflowGraphDraft.ts)
## Findings
### 1. Root-context inputs were modeled like normal upstream sockets
Betroffen:
- `resolve_step_path` mit `cad_file_record`
- `order_line_setup` mit `order_line_record`
Root cause:
- Das Backend seedingt diese Eingänge implizit aus dem Workflow-Kontext.
- Der Editor behandelte sie trotzdem als normale Canvas-Ports.
Folge:
- Entry-Nodes sahen aus, als müssten sie zusätzlich verkabelt werden.
- Das erzeugte genau die Art von unklaren Input-Anforderungen, die im aktuellen Workflow-Editor stören.
Maßnahme:
- Root-context inputs werden im Frontend separat als Kontextanforderung modelliert.
- Sie werden nicht mehr als normale verkabelbare Upstream-Sockets dargestellt.
### 2. Node authoring semantics were implicit instead of explicit
Root cause:
- Der Editor wusste bislang zwar, welche Felder und Ports existieren, aber nicht, welches Authoring-Muster eine Node eigentlich hat.
Folge:
- `0 inspector vars` oder `1 input socket` war im UI technisch korrekt, aber semantisch oft unverständlich.
Maßnahme:
- Nodes werden jetzt explizit als `Context Entry`, `Connection-Driven`, `Inspector-Driven` oder `Hybrid` beschrieben.
### 3. Registry coverage is broad, but some nodes are intentionally connection-only
Nodes mit `0` Inspector-Feldern:
- `resolve_step_path`
- `occ_object_extract`
- `occ_glb_export`
- `thumbnail_save`
- `order_line_setup`
- `stl_cache_generate`
Bewertung:
- Das ist nicht automatisch ein Defekt.
- Diese Nodes brauchen vor allem klare Authoring-Erklärung und keine künstlichen Dummy-Einstellungen.
### 4. Render/runtime parameter alignment is already strong for core render nodes
Der bestehende Testpfad deckt insbesondere für `blender_still` und `blender_turntable` bereits ab, dass deklarierte Felder von der Runtime unterstützt werden.
Nächste Lücke:
- systematische Prüfung der restlichen Bridge-/Output-Nodes gegen Runtime-Parameter und Template-Inputs
## Current Batch Outcome
Batch A konzentriert sich zuerst auf:
1. sichtbaren 20-Block-Plan
2. Audit-Dokumentation
3. saubere Trennung von Root-Kontext und Upstream-Wiring
4. explizite Authoring-Semantik im Editor
## Block 5 Inventory: Implicit Requirement Nodes
### A. Korrekt feldlose Nodes, die nur bessere Authoring-Semantik brauchten
- `resolve_step_path`
- echter `Context Entry`
- braucht nur `cad_file_record` aus dem Workflow-Kontext
- keine zusätzlichen Inspector-Variablen sinnvoll
- `order_line_setup`
- echter `Context Entry`
- braucht nur `order_line_record` aus dem Workflow-Kontext
- liefert den Großteil des Order-Line-Arbeitskontexts
- `occ_object_extract`
- reine `Connection-Driven` Node
- braucht nur `step_path`
- keine zusätzlichen lokalen Einstellungen in der Runtime vorhanden
- `occ_glb_export`
- reine `Connection-Driven` Node
- braucht nur `step_path`
- Registry beschreibt bereits korrekt, dass per-Node-Tessellation-Overrides noch nicht existieren
- `thumbnail_save`
- reine `Connection-Driven` Node
- braucht nur `rendered_image`
- Verhalten kommt aus dem angeschlossenen Thumbnail-Request, nicht aus lokalen Feldern
- `stl_cache_generate`
- reine `Connection-Driven` Kompatibilitäts-Node
- kein echter Produktionsschritt im HartOMat-Graph
- Runtime ist bewusst ein `compatibility_noop`
### B. Nodes ohne große Feldoberfläche, aber mit wichtiger Laufzeitsemantik
- `output_save`
- hat nur wenige lokale Felder, aber relevante Handoff-Semantik
- Verhalten hängt von angeschlossenen Render-Artefakten, Shadow/Graph-Mode und Publish-Handoff ab
- UI muss klarer kommunizieren, wann diese Node `pending`, `completed` oder `failed` wird
- `notify`
- hat nur minimale lokale Konfiguration, ist aber stark vom bewaffneten Render-Handoff abhängig
- in `shadow` wird die Node bewusst unterdrückt
- braucht vor allem bessere Preflight-/Inspector-Erklärung, nicht mehr Freitextfelder
- `export_blend`
- aktuell nur ein bewusst schmaler Bridge-Export
- nur Dateinamensuffix ist pro Workflow authorbar
- größere Feldoberfläche wäre aktuell Fake-Konfiguration ohne Runtime-Nutzen
### C. Nodes mit dynamischen statt statischen Inputs
- `resolve_template`
- statische Inspector-Felder sind vorhanden
- zusätzliche Inputs entstehen dynamisch über `workflow_input_schema`
- das ist keine 0-Felder-Node, aber eine wichtige Ursache für Verwirrung, wenn Template-Inputs im Editor nicht klar sichtbar werden
### D. Bewertung
- Für Batch A/B ist die Hauptlücke nicht "mehr Felder um jeden Preis".
- Die Hauptlücke ist:
- Root-Kontext korrekt modellieren
- Connection-vs-Inspector-Semantik explizit machen
- Handoff-/Template-/Shadow-Semantik sichtbarer machen
- Echte neue Eingabevariablen werden erst dort ergänzt, wo Runtime und Template-System sie tatsächlich unterstützen.
## Finding 5: `notify` had a real frontend/backend contract drift
Betroffen:
- Frontend-Authoring erlaubte `export_blend -> notify`
- Backend-Schema ließ `notify` bislang nicht auf `blend_asset` reagieren
Root cause:
- Frontend ergänzte `blend_asset` als alternatives `requires_any`
- Backend-Registry führte für `notify` nur Render-Artefakte und `workflow_result`
Folge:
- derselbe Graph konnte im Editor plausibel aussehen, aber beim Backend-Schema scheitern
- besonders Blend-Export-Workflows waren dadurch inkonsistent authorbar
Maßnahme:
- `notify.input_contract.requires_any` enthält jetzt auch `blend_asset`
- Registry führt `blend_asset` auch als konsumiertes Artefakt
- Schema- und Executor-Tests decken `export_blend -> notify` jetzt explizit ab
## Finding 6: Bridge-node runtime params need explicit anti-drift guards
Betroffen:
- `resolve_template`
- `material_map_resolve`
- `auto_populate_materials`
- `glb_bbox`
- `output_save`
- `notify`
Root cause:
- Core-Render-Nodes waren bereits per Runtime-Key-Tests abgesichert.
- Bridge-Nodes hatten zwar Registry-Felder, aber keinen zentralen Runtime-Param-Contract gegen Drift.
Folge:
- zukünftige Änderungen in Runtime oder Registry könnten still auseinanderlaufen
- besonders gefährlich für Inspector-Felder, die klein wirken, aber produktionskritische Handoff-Semantik steuern
Maßnahme:
- Runtime-Key-Sets für die Bridge-Nodes wurden in `workflow_graph_runtime.py` zentralisiert
- Registry-Tests prüfen diese Nodes jetzt 1:1 gegen die Runtime
Ergebnis:
- Block 6 ist auf Contract-Ebene abgeschlossen
- weitere Batch-B-Arbeit kann sich jetzt auf Template-/Output-Semantik statt auf Grundsatzdrift konzentrieren
## Finding 7: Template workflow inputs were only fully visible after forcing a concrete template override
Betroffen:
- `resolve_template` im Workflow-Inspector
Root cause:
- Template-definierte Produktionsvariablen kamen technisch aus `workflow_input_schema`, wurden im Editor aber primär erst nach Auswahl eines festen Template-Overrides sichtbar.
- Damit blieb ein Teil des realen Authoring-Vertrags für automatische Template-Auflösung zu implizit.
Folge:
- Autoren konnten schwer erkennen, welche Workflow-Variablen aktive Templates grundsätzlich bereits verlangen oder anbieten.
- Das machte Template-First-Graphen unnötig intransparent, obwohl die Runtime die Inputs bereits unterstützt.
Maßnahme:
- der Inspector zeigt jetzt zusätzlich eine automatische Abdeckungsansicht über aktive Templates mit Workflow-Inputs
- potenzielle Template-Variablen werden vor Auswahl eines festen Overrides als reale Produktionsvariablen sichtbar
- gezielte Frontend-Tests prüfen sowohl explizite Override-Inputs als auch die automatische Coverage
## Finding 8: Render override fields could be edited although runtime discarded them
Betroffen:
- `blender_still`
- `blender_turntable`
Root cause:
- der Inspector behandelte mehrere renderautoritative Felder wie normale Node-Variablen
- die Runtime verwirft diese Werte jedoch bewusst, solange `use_custom_render_settings` deaktiviert bleibt und Output Type bzw. Template autoritativ sind
Folge:
- Autoren konnten Konfigurationen eingeben, die im Lauf keine Wirkung hatten
- das war eine echte Contract-Lücke zwischen UI und Runtime, nicht nur eine Darstellungsfrage
Maßnahme:
- renderautoritative Felder werden nun gesperrt, bis `use_custom_render_settings` aktiviert ist
- `output_save` und `notify` dokumentieren zusätzlich ihre Handoff-Semantik im Inspector explizit, um Shadow-/Graph-/Legacy-Verhalten klarer zu machen
- fokussierte Frontend- und Backend-Tests sichern diese Contract-Regeln gegen Regressions ab
@@ -0,0 +1,85 @@
# Workflow Validation Error Inventory
Stand: April 12, 2026
Dieses Inventar beschreibt die derzeit real existierenden Graph-Preflight- und Validation-Fehlerklassen im Workflow-System. Ziel ist, Backend-Preflight, Editor-Hinweise und Autoren-Debugging auf dieselbe Sprache zu bringen.
## Kategorien
### Context
Diese Fehler bedeuten, dass der Graph mit dem falschen Basiskontext oder mit einem ungültigen Kontext gestartet wird.
| Code | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `invalid_context_id` | error | Die angegebene Context-ID ist keine UUID. | Gültige UUID aus Order Line oder CAD File verwenden. |
| `context_not_found` | error | Die UUID zeigt auf keinen vorhandenen Datensatz. | Vorhandenen Datensatz wählen oder Seed-/Import-Daten prüfen. |
| `context_kind_mismatch` | error | Workflow-Familie und übergebener Kontext passen nicht zusammen. | Order-Line-Graph mit Order Line starten, CAD-Graph mit CAD File. |
| `invalid_context_kind` | error | Einzelne Node verlangt `order_line`, der Graph läuft aber nicht in diesem Kontext. | Kontext oder Node-Familie korrigieren. |
| `cad_file_only_node` | error | CAD-Entry-Node wurde in einem Order-Line-Graph platziert. | Node in CAD-Workflow verschieben oder order-line-taugliche Alternative nutzen. |
### Setup Chain
Diese Fehler zeigen, dass die notwendige Vorbereitungslogik für den Renderpfad fehlt oder nicht renderbar ist.
| Code | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `order_line_missing` | error | Order Line konnte nicht geladen werden. | Datensatz und FK-Kette prüfen. |
| `order_line_not_renderable` | error | Legacy-Setup erkennt harte Renderblocker. | Voraussetzungen der Order Line reparieren. |
| `order_line_skipped` | error | Legacy-Setup würde den Renderpfad bewusst überspringen. | Skip-Grund beseitigen. |
| `missing_order_line_setup` | error | Downstream-Node hat keinen vorgelagerten `order_line_setup`. | Setup-Node früher im Graph platzieren. |
| `setup_not_ready` | error | Setup ist vorhanden, aber nicht in einem lauffähigen Zustand. | Setup-Ursache beheben und erneut preflighten. |
### Data Source
Diese Fehler entstehen durch unvollständige oder nicht mehr erreichbare Eingabedaten.
| Code | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `cad_file_missing_path` | error | CAD File hat keinen gespeicherten STEP-Pfad. | STEP-Referenz reparieren oder neu importieren. |
| `cad_file_step_missing` | error | Gespeicherter STEP-Pfad existiert auf dem Dateisystem nicht. | Storage-/Mount-/Importpfad reparieren. |
| `bbox_unresolved` | warning | Bounding Box konnte nicht aus GLB oder STEP abgeleitet werden. | GLB-Upstream, Exportpfad oder STEP-Quelle prüfen. |
### Runtime Gap
Diese Fehler bedeuten, dass der Graph aktuell noch keine echte Runtime-Implementierung für den Schritt hat.
| Code | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `unsupported_node` | error | Node ist registriert, aber in der Graph-Runtime noch nicht ausführbar. | Legacy/Bridge behalten oder native Graph-Implementierung ergänzen. |
### Legacy Drift
Diese Warnungen markieren Stellen, an denen der Graph zwar laufen kann, aber vom bisherigen Legacy-Verhalten abweichen könnte.
| Code | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `missing_resolve_template` | warning | Render-/Export-Pfad läuft ohne vorgelagertes `resolve_template`. | `resolve_template` vor Render-/Export-Nodes ergänzen. |
| `template_missing` | warning | Für die Order Line wurde kein Template aufgelöst. | Template zuordnen oder Override setzen. |
### Artifact Flow
Diese Klasse deckt aktuell generische Vertrags- und Upstream-Probleme ab, die aus Message oder Code als Artefaktfluss erkennbar sind.
| Erkennung | Severity | Root Cause | Erwartete Abhilfe |
| --- | --- | --- | --- |
| `code` oder `message` enthält `artifact` | meist warning/error | Ein benötigtes Artefakt wurde upstream nicht produziert oder nicht verbunden. | Fehlende Node/Verbindung ergänzen und Contract im Editor prüfen. |
## Blocking-Regeln
- `error` blockiert Graph-Dispatch.
- `warning` blockiert nicht automatisch, muss aber vor Rollout-Parität bewertet werden.
- `unsupported_node` ist inhaltlich ein Runtime-Gap und wird als blockierend behandelt.
## UI-Sprachregelung
- Editor und Preflight sollen immer beide Ebenen zeigen:
- Schweregrad: `error`, `warning`, `info`
- Typ: `Context`, `Setup Chain`, `Data Source`, `Runtime Gap`, `Legacy Drift`, `Artifact Flow`
- Action-Hints müssen immer direkt sagen, welche Node, welcher Kontext oder welche Upstream-Voraussetzung fehlt.
## Nächste Folgeschritte
1. Node-Katalog und Inspector mit denselben Kategorien annotieren.
2. Validation bereits im Authoring vor Preflight so früh wie möglich sichtbar machen.
3. Für echte `Artifact Flow`-Fehler langfristig explizite Codes statt Message-Heuristik einführen.
@@ -250,4 +250,27 @@ describe('output type contract helpers', () => {
expect.objectContaining({ id: 'wf-1' }),
])
})
test('accepts graph workflows that advertise blend artifact support', () => {
expect(getCompatibleWorkflowsForOutputTypeContract(
[
{
id: 'wf-blend',
name: 'Still + Blend Graph',
family: 'order_line',
supported_artifact_kinds: ['still_image', 'blend_asset'],
},
{
id: 'wf-still',
name: 'Still Graph',
family: 'order_line',
supported_artifact_kinds: ['still_image'],
},
],
'order_line',
'blend_asset',
)).toEqual([
expect.objectContaining({ id: 'wf-blend' }),
])
})
})
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from 'vitest'
import {
buildWorkflowBlueprintConfig,
createPresetWorkflowConfig,
createStarterWorkflowConfig,
normalizeWorkflowConfig,
@@ -127,6 +128,59 @@ describe('workflow preset config builders', () => {
expect(config.nodes.map(node => node.step)).toEqual(['order_line_setup'])
})
test('builds alpha and blend graph blueprints for still-render authoring', () => {
const alphaConfig = buildWorkflowBlueprintConfig('still_graph_alpha_reference')
const blendConfig = buildWorkflowBlueprintConfig('still_graph_blend_reference')
expect(alphaConfig.ui?.execution_mode).toBe('graph')
expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
transparent_bg: true,
use_custom_render_settings: false,
})
expect(blendConfig.ui?.execution_mode).toBe('graph')
expect(blendConfig.nodes.map(node => node.step)).toEqual(
expect.arrayContaining(['blender_still', 'export_blend', 'output_save', 'notify']),
)
expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({
expected_artifact_role: 'blend_export',
})
})
test('rebuilds new still graph reference variants during normalization', () => {
const alphaConfig = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'graph',
blueprint: 'still_graph_alpha_reference',
},
nodes: [],
edges: [],
})
const blendConfig = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'graph',
blueprint: 'still_graph_blend_reference',
},
nodes: [],
edges: [],
})
expect(alphaConfig.ui?.blueprint).toBe('still_graph_alpha_reference')
expect(alphaConfig.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
transparent_bg: true,
})
expect(blendConfig.ui?.blueprint).toBe('still_graph_blend_reference')
expect(blendConfig.nodes.some(node => node.step === 'export_blend')).toBe(true)
expect(blendConfig.nodes.find(node => node.id === 'save_blend')?.params).toMatchObject({
expected_artifact_role: 'blend_export',
})
})
test('normalizes workflow rollout summary from the API payload', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: [
@@ -0,0 +1,228 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import type { Material } from '../../api/materials'
import type { OutputType, OutputTypeContractCatalog } from '../../api/outputTypes'
import type { PricingTier } from '../../api/pricing'
import type { WorkflowDefinition } from '../../api/workflows'
import OutputTypeTable from '../../components/admin/OutputTypeTable'
const listOutputTypesMock = vi.fn<() => Promise<OutputType[]>>()
const getOutputTypeContractCatalogMock = vi.fn<() => Promise<OutputTypeContractCatalog>>()
const listMaterialsMock = vi.fn<() => Promise<Material[]>>()
const listPricingTiersMock = vi.fn<() => Promise<PricingTier[]>>()
const getWorkflowsMock = vi.fn<() => Promise<WorkflowDefinition[]>>()
vi.mock('../../api/outputTypes', async () => {
const actual = await vi.importActual<typeof import('../../api/outputTypes')>('../../api/outputTypes')
return {
...actual,
listOutputTypes: () => listOutputTypesMock(),
getOutputTypeContractCatalog: () => getOutputTypeContractCatalogMock(),
getCachedOutputTypeContractCatalog: () => actual.getCachedOutputTypeContractCatalog(),
createOutputType: vi.fn(),
updateOutputType: vi.fn(),
deleteOutputType: vi.fn(),
}
})
vi.mock('../../api/materials', () => ({
listMaterials: () => listMaterialsMock(),
}))
vi.mock('../../api/pricing', () => ({
listPricingTiers: () => listPricingTiersMock(),
}))
vi.mock('../../api/workflows', async () => {
const actual = await vi.importActual<typeof import('../../api/workflows')>('../../api/workflows')
return {
...actual,
getWorkflows: () => getWorkflowsMock(),
}
})
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function renderTable() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<OutputTypeTable />
</QueryClientProvider>,
)
return { queryClient }
}
describe('OutputTypeTable', () => {
test('does not flag valid linked workflows as unresolved', async () => {
const workflowId = '9a4e65ff-ecb8-4840-9c52-c07b45236d2d'
const now = '2026-04-15T07:00:00Z'
listOutputTypesMock.mockResolvedValue([
{
id: '29af9864-a4b3-4fd5-a02b-53232ce81fa7',
name: '[Workflow Golden] Canonical Still Graph',
description: null,
renderer: 'blender',
render_settings: {},
invocation_overrides: {
width: 1024,
height: 1024,
samples: 64,
},
output_format: 'png',
sort_order: 0,
compatible_categories: [],
render_backend: 'celery',
is_animation: false,
transparent_bg: false,
workflow_family: 'order_line',
artifact_kind: 'still_image',
cycles_device: 'gpu',
pricing_tier_id: null,
pricing_tier_name: null,
price_per_item: null,
workflow_definition_id: workflowId,
workflow_rollout_mode: 'graph',
workflow_name: '[Workflow Golden] Canonical Still Graph',
material_override: null,
invocation_profile: {
renderer: 'blender',
render_backend: 'celery',
workflow_family: 'order_line',
artifact_kind: 'still_image',
output_format: 'png',
is_animation: false,
workflow_definition_id: workflowId,
workflow_rollout_mode: 'graph',
transparent_bg: false,
cycles_device: 'gpu',
material_override: null,
allowed_override_keys: ['width', 'height', 'samples', 'engine'],
invocation_overrides: {
width: 1024,
height: 1024,
samples: 64,
engine: 'cycles',
},
},
is_active: true,
created_at: now,
updated_at: now,
},
])
getOutputTypeContractCatalogMock.mockResolvedValue({
workflow_families: ['cad_file', 'order_line'],
workflow_rollout_modes: ['legacy_only', 'shadow', 'graph'],
artifact_kinds: ['still_image', 'turntable_video', 'model_export', 'thumbnail_image', 'blend_asset', 'package', 'custom'],
allowed_artifact_kinds_by_family: {
cad_file: ['thumbnail_image', 'model_export', 'package', 'custom'],
order_line: ['still_image', 'turntable_video', 'blend_asset', 'model_export', 'package', 'custom'],
},
allowed_output_formats_by_family: {
cad_file: ['png', 'jpg', 'webp', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'],
order_line: ['png', 'jpg', 'webp', 'mp4', 'webm', 'blend', 'glb', 'gltf', 'stl', 'obj', 'usd', 'usdz'],
},
allowed_invocation_override_keys_by_artifact_kind: {
still_image: ['width', 'height', 'engine', 'samples'],
turntable_video: ['width', 'height', 'engine', 'samples', 'frame_count', 'fps', 'turntable_axis'],
thumbnail_image: ['width', 'height'],
blend_asset: [],
model_export: [],
package: [],
custom: [],
},
default_output_format_by_artifact_kind: {
still_image: 'png',
turntable_video: 'mp4',
thumbnail_image: 'png',
blend_asset: 'blend',
model_export: 'glb',
package: 'zip',
custom: 'png',
},
parameter_ownership: {
output_type_profile_keys: [],
template_runtime_keys: [],
workflow_node_keys_by_step: {},
},
})
listMaterialsMock.mockResolvedValue([])
listPricingTiersMock.mockResolvedValue([])
getWorkflowsMock.mockResolvedValue([
{
id: workflowId,
name: '[Workflow Golden] Canonical Still Graph',
output_type_id: null,
config: {
version: 1,
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 0 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 200, y: 0 } } },
{ id: 'render', step: 'blender_still', params: {}, ui: { label: 'Still Render', position: { x: 400, y: 0 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { label: 'Save Output', position: { x: 600, y: 0 } } },
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
],
ui: {
execution_mode: 'graph',
family: 'order_line',
blueprint: 'still_graph_reference',
},
},
family: 'order_line',
supported_artifact_kinds: ['still_image'],
rollout_summary: {
linked_output_type_count: 1,
active_output_type_count: 1,
linked_output_type_names: ['[Workflow Golden] Canonical Still Graph'],
linked_output_types: [],
rollout_modes: ['graph'],
has_blocking_contracts: false,
blocking_reasons: [],
latest_run: null,
latest_shadow_run: null,
latest_rollout_gate_verdict: null,
latest_rollout_ready: null,
latest_rollout_status: null,
latest_rollout_reasons: [],
},
is_active: true,
created_at: now,
},
])
renderTable()
const [rowLabel] = await screen.findAllByText('[Workflow Golden] Canonical Still Graph')
const row = rowLabel.closest('tr')
expect(row).not.toBeNull()
const scoped = within(row as HTMLTableRowElement)
await waitFor(() => {
expect(scoped.getByText('Graph drives production with legacy fallback armed.')).toBeInTheDocument()
})
expect(scoped.getByText('Graph Authoritative')).toBeInTheDocument()
expect(scoped.queryByText(/The selected workflow definition could not be resolved\./)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { WorkflowListSidebar } from '../../components/workflows/WorkflowListSidebar'
describe('WorkflowListSidebar', () => {
test('separates workflow selection from destructive actions', async () => {
const user = userEvent.setup()
const onSelectWorkflow = vi.fn()
const onCreateWorkflow = vi.fn()
const onDeleteWorkflow = vi.fn()
render(
<WorkflowListSidebar
isLoading={false}
selectedId="wf-selected"
onSelectWorkflow={onSelectWorkflow}
onCreateWorkflow={onCreateWorkflow}
onDeleteWorkflow={onDeleteWorkflow}
sections={[
{
key: 'order_line',
label: 'Order Rendering',
className: 'bg-emerald-100 text-emerald-700',
items: [
{
id: 'wf-selected',
name: 'Still Graph Blueprint',
isActive: true,
presetLabel: 'Still (Graph)',
presetClassName: 'bg-emerald-100 text-emerald-700',
familyLabel: 'Order Rendering',
familyClassName: 'bg-emerald-100 text-emerald-700',
executionModeLabel: 'Graph',
executionModeClassName: 'bg-green-100 text-green-700',
rolloutBadgeLabel: 'Shadow',
rolloutBadgeClassName: 'bg-sky-100 text-sky-700',
rolloutStatusLabel: 'Legacy Authoritative',
rolloutStatusClassName: 'bg-sky-100 text-sky-700',
rolloutSummary: 'Shadow verdict: pass.',
linkedOutputTypeCount: 2,
blueprintLabel: 'Still Graph',
isReference: true,
},
{
id: 'wf-secondary',
name: 'Still Legacy',
isActive: false,
presetLabel: 'Still',
presetClassName: 'bg-orange-100 text-orange-700',
familyLabel: 'Order Rendering',
familyClassName: 'bg-emerald-100 text-emerald-700',
executionModeLabel: 'Legacy',
executionModeClassName: 'bg-slate-100 text-slate-700',
rolloutBadgeLabel: 'Legacy',
rolloutBadgeClassName: 'bg-slate-100 text-slate-700',
rolloutStatusLabel: 'Ready',
rolloutStatusClassName: 'bg-green-100 text-green-700',
rolloutSummary: 'Legacy remains authoritative.',
linkedOutputTypeCount: 1,
blueprintLabel: null,
isReference: false,
},
],
},
]}
/>,
)
const selectedWorkflowButton = screen
.getAllByRole('button', { name: /still graph blueprint/i })
.find(button => button.getAttribute('aria-pressed') === 'true')
const deleteButton = screen.getByRole('button', {
name: /delete workflow still graph blueprint/i,
})
expect(selectedWorkflowButton).toBeDefined()
expect(selectedWorkflowButton).toHaveAttribute('aria-pressed', 'true')
expect(deleteButton.closest('button')).toBe(deleteButton)
await user.click(deleteButton)
expect(onDeleteWorkflow).toHaveBeenCalledWith('wf-selected', 'Still Graph Blueprint')
expect(onSelectWorkflow).not.toHaveBeenCalled()
const secondaryWorkflowButton = screen
.getAllByRole('button', { name: /still legacy/i })
.find(button => button.getAttribute('aria-pressed') === 'false')
expect(secondaryWorkflowButton).toBeDefined()
await user.click(secondaryWorkflowButton!)
expect(onSelectWorkflow).toHaveBeenCalledWith('wf-secondary')
})
})
@@ -71,6 +71,331 @@ const notifyDefinition: WorkflowNodeDefinition = {
legacy_source: 'legacy.notify',
}
const orderLineSetupDefinition: WorkflowNodeDefinition = {
step: 'order_line_setup',
label: 'Order Line Setup',
family: 'order_line',
module_key: 'orders.setup',
category: 'input',
description: 'Resolve order-line context and setup metadata.',
node_type: 'inputNode',
icon: 'package-search',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_record'] },
output_contract: { context: 'order_line', provides: ['order_line_context'] },
artifact_roles_consumed: [],
artifact_roles_produced: ['order_line_context'],
legacy_source: 'legacy.order_line_setup',
}
const outputSaveDefinition: WorkflowNodeDefinition = {
step: 'output_save',
label: 'Save Output',
family: 'order_line',
module_key: 'media.save_output',
category: 'output',
description: 'Persist the selected render artifact.',
node_type: 'outputNode',
icon: 'download',
defaults: {
expected_artifact_role: '',
require_upstream_artifact: false,
},
fields: [
{
key: 'expected_artifact_role',
label: 'Expected Artifact Role',
type: 'select',
description: 'Restrict the accepted upstream artifact.',
section: 'Output',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: '', label: 'Any Connected Artifact' },
{ value: 'render_output', label: 'Still Output' },
{ value: 'turntable_output', label: 'Turntable Output' },
],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'require_upstream_artifact',
label: 'Require Upstream Artifact',
type: 'boolean',
description: 'Fail when no matching artifact is wired.',
section: 'Output',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context'], requires_any: ['rendered_image'] },
output_contract: { context: 'order_line', provides: ['workflow_result'] },
artifact_roles_consumed: ['rendered_image'],
artifact_roles_produced: ['workflow_result'],
legacy_source: 'legacy.output_save',
}
const exportBlendDefinition: WorkflowNodeDefinition = {
step: 'export_blend',
label: 'Export Blend',
family: 'order_line',
module_key: 'media.export_blend',
category: 'output',
description: 'Persist the generated .blend file.',
node_type: 'outputNode',
icon: 'download',
defaults: {
output_name_suffix: '',
},
fields: [
{
key: 'output_name_suffix',
label: 'Output Name Suffix',
type: 'text',
description: 'Optional suffix appended to the emitted filename.',
section: 'Output',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: 64,
text_format: 'safe_filename_suffix',
},
],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template'] },
output_contract: { context: 'order_line', provides: ['blend_asset'] },
artifact_roles_consumed: ['order_line_context', 'render_template'],
artifact_roles_produced: ['blend_asset'],
legacy_source: 'legacy.export_blend',
}
const blenderStillDefinition: WorkflowNodeDefinition = {
step: 'blender_still',
label: 'Render Still',
family: 'order_line',
module_key: 'render.production.still',
category: 'rendering',
description: 'Render a still image.',
node_type: 'renderNode',
icon: 'camera',
defaults: { use_custom_render_settings: false },
fields: [
{
key: 'use_custom_render_settings',
label: 'Custom Render Settings',
type: 'boolean',
description: 'Enable explicit render overrides.',
section: 'Render',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'samples',
label: 'Samples',
type: 'number',
description: 'Render samples.',
section: 'Render',
default: 256,
min: 1,
max: 4096,
step: 1,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'noise_threshold',
label: 'Noise Threshold',
type: 'text',
description: 'Adaptive sampling threshold.',
section: 'Denoising',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'focal_length_mm',
label: 'Focal Length',
type: 'number',
description: 'Lens override.',
section: 'Camera',
default: null,
min: 1,
max: 500,
step: 0.1,
unit: 'mm',
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'target_collection',
label: 'Target Collection',
type: 'text',
description: 'Collection name.',
section: 'Scene',
default: 'Product',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
output_contract: { context: 'order_line', provides: ['rendered_image'] },
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
artifact_roles_produced: ['rendered_image'],
legacy_source: 'legacy.blender_still',
}
const blenderTurntableDefinition: WorkflowNodeDefinition = {
step: 'blender_turntable',
label: 'Render Turntable',
family: 'order_line',
module_key: 'render.production.turntable',
category: 'rendering',
description: 'Render a turntable animation.',
node_type: 'renderNode',
icon: 'video',
defaults: { use_custom_render_settings: false },
fields: [
{
key: 'use_custom_render_settings',
label: 'Custom Render Settings',
type: 'boolean',
description: 'Enable explicit render overrides.',
section: 'Render',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'fps',
label: 'Frames Per Second',
type: 'number',
description: 'Playback speed.',
section: 'Animation',
default: 24,
min: 1,
max: 120,
step: 1,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'turntable_axis',
label: 'Turntable Axis',
type: 'select',
description: 'Rotation axis.',
section: 'Animation',
default: 'z',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'x', label: 'X' },
{ value: 'y', label: 'Y' },
{ value: 'z', label: 'Z' },
],
allow_blank: false,
max_length: null,
text_format: null,
},
{
key: 'bg_color',
label: 'Background Color',
type: 'text',
description: 'Background override.',
section: 'Render',
default: '#ffffff',
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
{
key: 'camera_orbit',
label: 'Camera Orbit',
type: 'boolean',
description: 'Orbit camera around the product.',
section: 'Scene',
default: true,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
execution_kind: 'native',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
output_contract: { context: 'order_line', provides: ['rendered_video'] },
artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'],
artifact_roles_produced: ['rendered_video'],
legacy_source: 'legacy.blender_turntable',
}
function createRenderTemplate(overrides: Partial<RenderTemplate> = {}): RenderTemplate {
return {
id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
@@ -256,14 +581,60 @@ describe('WorkflowNodeInspector', () => {
)
expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument()
expect(screen.getByText(/each required upstream input gets its own socket/i)).toBeInTheDocument()
expect(screen.getByText(/0 local variables by design/i)).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(
screen.getAllByText(
'This node accepts one upstream artifact from any of: rendered image / rendered frames / rendered video / workflow result / blend asset.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
expect(screen.getByText('Group 1')).toBeInTheDocument()
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
expect(screen.getByText('Watch Artifact Flow')).toBeInTheDocument()
expect(screen.getByText('Watch Runtime Gap')).toBeInTheDocument()
expect(
screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length,
).toBeGreaterThan(0)
})
test('explains context-entry nodes as context-supplied instead of misconfigured', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{}}
onChange={vi.fn()}
nodeDefinition={orderLineSetupDefinition}
step="order_line_setup"
nodeDefinitions={[orderLineSetupDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Context Entry')).toBeInTheDocument()
expect(
screen.getAllByText(
'Workflow context supplies Order Line Record. No additional upstream sockets are required.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText('Workflow context already provides Order Line Record.').length).toBeGreaterThan(0)
expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0)
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
expect(screen.getByText('Watch Context')).toBeInTheDocument()
expect(screen.queryByText('Socket 1')).not.toBeInTheDocument()
})
test('summarizes wired inputs and inspector variables separately', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
@@ -298,10 +669,289 @@ describe('WorkflowNodeInspector', () => {
})
await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2')
expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument()
expect(await screen.findByText(/1 required socket is exposed on the canvas/i)).toBeInTheDocument()
expect(screen.getAllByText('This node waits for Order Line Context from upstream.').length).toBeGreaterThan(0)
expect(screen.getByText('Required Sockets')).toBeInTheDocument()
expect(screen.getByText('Socket 1')).toBeInTheDocument()
expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument()
expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument()
expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument()
expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument()
})
test('shows automatic template variable coverage before a template override is selected', async () => {
listRenderTemplates.mockResolvedValue([
createRenderTemplate({
id: 'template-a',
name: 'Bearing Studio',
output_type_names: ['Still'],
workflow_input_schema: [
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'warm', label: 'Warm' },
],
allow_blank: false,
},
],
}),
createRenderTemplate({
id: 'template-b',
name: 'Shadow Studio',
output_type_names: ['Shadow Still'],
workflow_input_schema: [
{
key: 'shadow_density',
label: 'Shadow Density',
type: 'number',
section: 'Template Inputs',
description: 'Shadow catcher strength.',
default: 0.65,
min: 0,
max: 1,
step: 0.05,
unit: null,
options: [],
allow_blank: false,
},
{
key: 'studio_variant',
label: 'Studio Variant',
type: 'select',
section: 'Template Inputs',
description: 'Choose the blend lighting preset.',
default: 'default',
min: null,
max: null,
step: null,
unit: null,
options: [
{ value: 'default', label: 'Default' },
{ value: 'dramatic', label: 'Dramatic' },
],
allow_blank: false,
},
],
}),
])
renderInspector({})
expect(await screen.findByText('Automatic Resolution Coverage')).toBeInTheDocument()
expect(screen.getByText(/2 active templates expose 2 unique workflow variables/i)).toBeInTheDocument()
expect(screen.getByText('Potential Template Variables')).toBeInTheDocument()
expect(screen.getAllByText('Studio Variant').length).toBeGreaterThan(0)
expect(screen.getByText(/Available via Bearing Studio, Shadow Studio \(Still, Shadow Still\)\./i)).toBeInTheDocument()
expect(screen.getByText(/Available via Shadow Studio \(Shadow Still\)\./i)).toBeInTheDocument()
})
test('disables contract-owned still render fields until custom render settings are enabled', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ use_custom_render_settings: false }}
onChange={vi.fn()}
nodeDefinition={blenderStillDefinition}
step="blender_still"
nodeDefinitions={[blenderStillDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Render Override Scope')).toBeInTheDocument()
expect(screen.getByLabelText('Samples')).toBeDisabled()
expect(screen.getByLabelText('Noise Threshold')).toBeDisabled()
expect(screen.getByLabelText('Focal Length (mm)')).toBeDisabled()
expect(screen.getByLabelText('Target Collection')).toBeDisabled()
})
test('disables contract-owned turntable fields until custom render settings are enabled', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ use_custom_render_settings: false }}
onChange={vi.fn()}
nodeDefinition={blenderTurntableDefinition}
step="blender_turntable"
nodeDefinitions={[blenderTurntableDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByLabelText('Frames Per Second')).toBeDisabled()
expect(screen.getByLabelText('Turntable Axis')).toBeDisabled()
expect(screen.getByLabelText('Background Color')).toBeDisabled()
expect(screen.getByLabelText('Camera Orbit')).toBeDisabled()
})
test('explains output handoff semantics for output_save nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ expected_artifact_role: 'render_output', require_upstream_artifact: true }}
onChange={vi.fn()}
nodeDefinition={outputSaveDefinition}
step="output_save"
nodeDefinitions={[outputSaveDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByText('Output Handoff')).toBeInTheDocument()
expect(screen.getByText(/does not render files itself/i)).toBeInTheDocument()
expect(screen.getByText('render_output')).toBeInTheDocument()
expect(screen.getByText(/shadow runs stay observer-only/i)).toBeInTheDocument()
expect(
screen.getAllByText(
'This node requires Order Line Context and one additional upstream artifact from any of: rendered image.',
).length,
).toBeGreaterThan(0)
expect(screen.getByText(/1 required socket and 1 alternative group are exposed on the canvas/i)).toBeInTheDocument()
expect(screen.getByText('Required Sockets')).toBeInTheDocument()
expect(screen.getByText('Alternative Groups')).toBeInTheDocument()
})
test('explains blend delivery semantics for export_blend nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ output_name_suffix: 'studio' }}
onChange={vi.fn()}
nodeDefinition={exportBlendDefinition}
step="export_blend"
nodeDefinitions={[exportBlendDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getAllByText('Blend Delivery').length).toBeGreaterThan(0)
expect(screen.getByText(/does not render pixels itself/i)).toBeInTheDocument()
expect(screen.getByText('studio')).toBeInTheDocument()
expect(screen.getByText(/save output/i)).toBeInTheDocument()
})
test('explains notification handoff semantics for notify nodes', async () => {
listRenderTemplates.mockResolvedValue([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
<WorkflowNodeInspector
params={{ channel: 'audit_log', require_armed_render: true }}
onChange={vi.fn()}
nodeDefinition={{
...notifyDefinition,
fields: [
{
key: 'channel',
label: 'Channel',
type: 'select',
description: 'Notification channel.',
section: 'Notification',
default: 'audit_log',
min: null,
max: null,
step: null,
unit: null,
options: [{ value: 'audit_log', label: 'Audit Log' }],
allow_blank: false,
max_length: null,
text_format: null,
},
{
key: 'require_armed_render',
label: 'Require Armed Render',
type: 'boolean',
description: 'Fail when no render task hands off notifications.',
section: 'Notification',
default: false,
min: null,
max: null,
step: null,
unit: null,
options: [],
allow_blank: true,
max_length: null,
text_format: null,
},
],
}}
step="notify"
nodeDefinitions={[notifyDefinition]}
graphFamily="order_line"
onStepChange={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getAllByText('Notification Handoff').length).toBeGreaterThan(0)
expect(screen.getByText(/does not emit independently/i)).toBeInTheDocument()
expect(screen.getByText('audit_log')).toBeInTheDocument()
expect(screen.getByText(/shadow runs suppress user notifications entirely/i)).toBeInTheDocument()
})
})
@@ -166,13 +166,14 @@ describe('workflow authoring guidance', () => {
test('derives a single shared order-line authoring plan', () => {
const plan = getWorkflowAuthoringPlan(definitions, 'order_line', ['blender_still'])
expect(plan.referenceBundles).toHaveLength(1)
expect(plan.moduleBundles).toHaveLength(2)
expect(plan.referenceBundles).toHaveLength(2)
expect(plan.moduleBundles).toHaveLength(4)
expect(plan.referenceBundles[0]?.presentCount).toBe(1)
expect(plan.moduleBundles.find(bundle => bundle.id === 'still_render_core')?.presentCount).toBe(1)
expect(plan.stageProgress.map(stage => stage.id)).toEqual([
'still_render_reference',
'still_render_core',
'scene_prep_core',
'materials_core',
'output_publish_notify',
'order_line_setup',
])
@@ -209,6 +210,8 @@ describe('workflow authoring guidance', () => {
])
expect(surface.plan.referenceBundles[0]?.id).toBe('still_render_reference')
expect(surface.plan.moduleBundles.map(bundle => bundle.id)).toEqual([
'scene_prep_core',
'materials_core',
'still_render_core',
'output_publish_notify',
])
@@ -12,6 +12,7 @@ import { NodeCommandMenu } from '../../components/workflows/NodeCommandMenu'
import { NodeDefinitionsPanel } from '../../components/workflows/NodeDefinitionsPanel'
import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvasToolbar'
import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard'
import { WorkflowValidationBanner } from '../../components/workflows/WorkflowValidationBanner'
import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel'
import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel'
import {
@@ -392,13 +393,29 @@ describe('WorkflowNodeContractCard', () => {
runtimeClassName="bg-green-100 text-green-700"
legacyCompatible
legacySource="legacy.still_render"
inputContextLabel="Order Rendering"
outputContextLabel="Order Rendering"
requiredInputs={['order_line', 'render_template']}
requiredAnyInputs={[['rendered_image', 'rendered_frames']]}
consumedArtifacts={['cad_preview']}
providedOutputs={['render_image']}
producedArtifacts={['png_output']}
contract={{
inputContextLabel: 'Order Line',
outputContextLabel: 'Order Line',
contextInputs: [],
requiredInputs: ['order_line', 'render_template'],
requiredAnyInputs: [['rendered_image', 'rendered_frames']],
consumedArtifacts: ['cad_preview'],
providedOutputs: ['render_image'],
producedArtifacts: ['png_output'],
editableFieldCount: 0,
editableFieldLabels: [],
dynamicVariableHint: null,
authoringPatternLabel: 'Connection-Driven',
authoringPatternDescription:
'Configure this node by wiring upstream artifacts. It has no local inspector variables.',
}}
validationWatchpoints={[
{
kind: 'legacy-drift',
label: 'Legacy Drift',
reason: 'Template resolution must stay aligned with the legacy path.',
},
]}
/>,
)
@@ -410,8 +427,30 @@ describe('WorkflowNodeContractCard', () => {
expect(screen.getByText('Render Template')).toBeInTheDocument()
expect(screen.getByText('Any of: Rendered Image / Rendered Frames')).toBeInTheDocument()
expect(screen.getByText('CAD Preview')).toBeInTheDocument()
expect(screen.getByText('Render Image')).toBeInTheDocument()
expect(screen.getAllByText('Render Image').length).toBeGreaterThan(0)
expect(screen.getByText('Png Output')).toBeInTheDocument()
expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument()
expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument()
})
})
describe('WorkflowValidationBanner', () => {
test('surfaces validation categories before preflight', () => {
render(
<WorkflowValidationBanner
errors={[
'Node "Render Still" is missing upstream input "Render Template".',
'Node "Order Line Setup" expects Order Line context, but this workflow is CAD File based.',
]}
warnings={[
'Node "Render Still" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.',
]}
/>,
)
expect(screen.getAllByText('Artifact Flow: 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0)
})
})
@@ -473,8 +512,20 @@ describe('WorkflowCanvasToolbar', () => {
orderId: 'order-1',
orderLabel: 'ORD-1001',
options: [
{ value: 'line-1', label: 'Product A · Still', meta: 'ORD-1001 · pending' },
{ value: 'line-2', label: 'Product B · Still', meta: 'ORD-1001 · completed' },
{
value: 'line-1',
label: 'Product A · Still',
meta: 'ORD-1001 · pending',
isRenderable: true,
renderabilityReason: null,
},
{
value: 'line-2',
label: 'Product B · Still',
meta: 'ORD-1001 · completed',
isRenderable: false,
renderabilityReason: 'order_closed',
},
],
},
]}
@@ -498,7 +549,7 @@ describe('WorkflowCanvasToolbar', () => {
authoringEntryAction={{
label: 'Author',
title: 'Open guided workflow authoring browser',
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
helper: 'Open reference paths, stage modules, starter steps, and raw nodes.',
icon: () => null,
}}
onDispatchContextIdChange={onDispatchContextIdChange}
@@ -523,7 +574,7 @@ describe('WorkflowCanvasToolbar', () => {
expect(screen.getByText('Legacy Archive Output')).toBeInTheDocument()
expect(screen.getAllByText('Order Line').length).toBeGreaterThan(0)
expect(screen.getAllByText('Product A · Still').length).toBeGreaterThan(0)
expect(screen.getByText('Right-click to add')).toBeInTheDocument()
expect(screen.getByText('Add nodes')).toBeInTheDocument()
expect(screen.getByText('Preflight ready')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled()
const rollbackButtons = screen.getAllByRole('button', { name: 'Set Legacy' })
@@ -594,8 +645,8 @@ describe('WorkflowCanvasToolbar', () => {
authoringActions={{ openNodeMenu: vi.fn() }}
authoringEntryAction={{
label: 'Node',
title: 'Open raw node browser',
helper: 'Open the searchable node catalog directly on the canvas.',
title: 'Open raw node catalog',
helper: 'Open the searchable raw node catalog directly on the canvas.',
icon: () => null,
}}
onDispatchContextIdChange={vi.fn()}
@@ -634,12 +685,13 @@ describe('NodeCommandMenu', () => {
)
await user.click(screen.getByRole('button', { name: 'Graph' }))
await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}')
await user.type(screen.getByPlaceholderText('Search raw nodes'), 'blender{enter}')
expect(onSelectStep).toHaveBeenCalledWith('blender_still')
expect(screen.getByRole('button', { name: 'All Categories' })).toBeInTheDocument()
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
expect(screen.getByText('Graph Nodes')).toBeInTheDocument()
expect(screen.getByText('Guided First')).toBeInTheDocument()
})
test('supports module insertion directly from the canvas authoring menu', async () => {
@@ -664,27 +716,31 @@ describe('NodeCommandMenu', () => {
)
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Starter' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Starter Steps' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument()
expect(screen.getByText('Recommended Path')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Insert Still Reference' })).toBeInTheDocument()
expect(screen.getByText('Reference Baselines')).toBeInTheDocument()
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0)
await user.click(screen.getByRole('button', { name: 'Insert Still Reference' }))
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'Insert Still Render Reference' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Insert Still Render Reference' }))
expect(onInsertReferencePath).toHaveBeenNthCalledWith(2, 'still_render_reference')
await user.click(screen.getByRole('button', { name: 'Modules' }))
expect(screen.getByText('Production Modules')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Stage Modules' }))
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
@@ -697,33 +753,36 @@ describe('NodeDefinitionsPanel', () => {
const user = userEvent.setup()
render(<NodeDefinitionsPanel definitions={nodeDefinitions} graphFamily="mixed" />)
expect(screen.getByText('Node Library')).toBeInTheDocument()
expect(screen.getByText('Authoring Browser')).toBeInTheDocument()
expect(screen.getByText('Authoring Flow')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reference Paths' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Stage Modules' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Raw Nodes' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Modules' }))
expect(screen.getByText('Production Modules')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Stage Modules' }))
expect(screen.getAllByText('Stage Modules').length).toBeGreaterThan(0)
expect(screen.getByText('Still Render Core')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Nodes' }))
await user.click(screen.getByRole('button', { name: 'Raw Nodes' }))
expect(screen.getAllByText('Raw Node Catalog').length).toBeGreaterThan(0)
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
expect(screen.getByText('Runtime')).toBeInTheDocument()
expect(screen.getByText('Family')).toBeInTheDocument()
expect(screen.getByText('Category')).toBeInTheDocument()
expect(screen.getByText('Stage Coverage')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search modules')).toBeInTheDocument()
expect(screen.getAllByText('CAD Intake').length).toBeGreaterThan(0)
expect(screen.getAllByText('Order Rendering').length).toBeGreaterThan(0)
expect(screen.getByText('Legacy Nodes')).toBeInTheDocument()
expect(screen.getByText('Graph Nodes')).toBeInTheDocument()
expect(screen.getAllByText('Blender Still').length).toBeGreaterThan(0)
expect(screen.getAllByText('Wiring').length).toBeGreaterThan(0)
expect(screen.getAllByText('Variables').length).toBeGreaterThan(0)
expect(screen.getAllByText(/Watch Artifact Flow/).length).toBeGreaterThan(0)
expect(screen.getAllByText('Graph').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'All Modules' })).toBeInTheDocument()
expect(screen.getAllByText('Cad').length).toBeGreaterThan(0)
@@ -759,18 +818,22 @@ describe('NodeDefinitionsPanel', () => {
).toBeTruthy()
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reapply Still Reference' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Insert Scene Prep' }).length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Insert Materials' }).length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Insert Publish' }).length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Add Order Line Setup' }).length).toBeGreaterThan(0)
await user.click(screen.getByRole('button', { name: 'Reapply Still Reference' }))
await user.click(screen.getAllByRole('button', { name: 'Insert Scene Prep' })[0] as HTMLElement)
await user.click(screen.getAllByRole('button', { name: 'Insert Publish' })[0] as HTMLElement)
await user.click(screen.getAllByRole('button', { name: 'Add Order Line Setup' })[0] as HTMLElement)
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
expect(onInsertModule).toHaveBeenCalledWith('scene_prep_core')
expect(onInsertModule).toHaveBeenCalledWith('output_publish_notify')
expect(onSelectStep).toHaveBeenCalledWith('order_line_setup')
await user.click(screen.getByRole('button', { name: 'Starter' }))
await user.click(screen.getByRole('button', { name: 'Starter Steps' }))
expect(screen.getByText('Starter Path')).toBeInTheDocument()
expect(screen.getAllByText('Still-render assembly').length).toBeGreaterThan(0)
expect(screen.getAllByText('1/8 present').length).toBeGreaterThan(0)
@@ -810,11 +873,11 @@ describe('NodeDefinitionsPanel', () => {
expect(onInsertModule).not.toHaveBeenCalled()
expect(onSelectStep).toHaveBeenCalledWith('occ_object_extract')
await user.click(screen.getByRole('button', { name: 'Paths' }))
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reference Paths' }))
expect(screen.getAllByText('Reference Paths').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'Insert CAD Intake Reference' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Starter' }))
await user.click(screen.getByRole('button', { name: 'Starter Steps' }))
expect(screen.getByText('Starter Path')).toBeInTheDocument()
expect(screen.getAllByText('CAD intake assembly').length).toBeGreaterThan(0)
expect(screen.getAllByText('1/5 present').length).toBeGreaterThan(0)
@@ -833,7 +896,7 @@ describe('NodeDefinitionsPanel', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'Nodes' }))
await user.click(screen.getByRole('button', { name: 'Raw Nodes' }))
const blenderCard = screen.getAllByText('Blender Still')[0]?.closest('div.rounded-lg')
expect(blenderCard).not.toBeNull()
await user.click(within(blenderCard as HTMLElement).getByRole('button', { name: 'Insert Blender Still' }))
@@ -854,7 +917,7 @@ describe('NodeDefinitionsPanel', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'Modules' }))
await user.click(screen.getByRole('button', { name: 'Stage Modules' }))
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
@@ -909,7 +972,7 @@ describe('workflowAuthoringActions', () => {
expect(guidedEntry.label).toBe('Author')
expect(guidedEntry.title).toContain('guided workflow authoring')
expect(rawEntry.label).toBe('Node')
expect(rawEntry.title).toContain('raw node browser')
expect(rawEntry.title).toContain('raw node catalog')
})
})
@@ -953,14 +1016,109 @@ describe('WorkflowPreflightPanel', () => {
expect(screen.getByText('Graph Preflight')).toBeInTheDocument()
expect(screen.getByText('Graph requires one missing upstream artifact.')).toBeInTheDocument()
expect(screen.getByText('Missing cad_preview artifact.')).toBeInTheDocument()
expect(screen.getAllByText('Missing cad_preview artifact.').length).toBeGreaterThan(0)
expect(screen.getByText('Mode: graph')).toBeInTheDocument()
expect(screen.getByText('Blocking: 0')).toBeInTheDocument()
expect(screen.getByText('Warnings: 2')).toBeInTheDocument()
expect(screen.getByText('Next Actions')).toBeInTheDocument()
expect(screen.getByText('Warnings can be addressed incrementally')).toBeInTheDocument()
expect(
screen.getAllByText(
'Add the missing upstream node or connection so the required artifact is available before this step.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText('Artifact Flow: 2').length).toBeGreaterThan(0)
expect(screen.getAllByText('Type: Artifact Flow').length).toBeGreaterThan(0)
expect(screen.getByText('Unsupported Node IDs')).toBeInTheDocument()
expect(screen.getByText('node-legacy-1')).toBeInTheDocument()
expect(screen.getByText('Code: missing-artifact')).toBeInTheDocument()
expect(screen.getAllByText('Code: missing-artifact').length).toBeGreaterThan(0)
expect(screen.getByText('Runtime: native')).toBeInTheDocument()
expect(screen.getByText('Supported: yes')).toBeInTheDocument()
expect(screen.getByText('cad_preview must be produced upstream.')).toBeInTheDocument()
expect(screen.getByText('blocked')).toBeInTheDocument()
})
test('separates context, runtime-gap, and legacy-drift guidance', () => {
const richerPreflight: WorkflowPreflightResponse = {
workflow_id: 'wf-2',
context_id: 'bad-context',
context_kind: null,
expected_context_kind: 'order_line',
execution_mode: 'graph',
graph_dispatch_allowed: false,
summary: 'Preflight found blocking issues that would prevent a safe graph dispatch.',
resolved_order_line_id: null,
resolved_cad_file_id: null,
unsupported_node_ids: ['node-native-gap'],
issues: [
{
severity: 'error',
code: 'context_not_found',
message: 'Context ID did not match an existing order line or CAD file.',
node_id: null,
step: null,
},
],
nodes: [
{
node_id: 'node-native-gap',
step: 'notify',
label: 'Notify Result',
execution_kind: 'bridge',
supported: false,
status: 'unsupported',
issues: [
{
severity: 'error',
code: 'unsupported_node',
message: "Graph runtime has no executable implementation for step 'notify'.",
node_id: 'node-native-gap',
step: 'notify',
},
],
},
{
node_id: 'node-template',
step: 'blender_still',
label: 'Blender Still',
execution_kind: 'native',
supported: true,
status: 'warning',
issues: [
{
severity: 'warning',
code: 'missing_resolve_template',
message: 'No earlier resolve_template node found. Render defaults may drift from legacy behavior.',
node_id: 'node-template',
step: 'blender_still',
},
],
},
],
}
render(<WorkflowPreflightPanel preflight={richerPreflight} isLoading={false} />)
expect(screen.getAllByText('Context: 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Runtime Gap: 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Legacy Drift: 1').length).toBeGreaterThan(0)
expect(
screen.getAllByText(
'Pick an existing order line or CAD file. The supplied ID does not resolve to a stored workflow context.',
).length,
).toBeGreaterThan(0)
expect(
screen.getAllByText(
'Keep this path on legacy/bridge execution for now, or replace the node with a supported graph module.',
).length,
).toBeGreaterThan(0)
expect(
screen.getAllByText(
'Add a "Resolve Template" node before render or export nodes to keep graph behavior aligned with legacy.',
).length,
).toBeGreaterThan(0)
expect(screen.getAllByText('Type: Context').length).toBeGreaterThan(0)
expect(screen.getAllByText('Type: Runtime Gap').length).toBeGreaterThan(0)
expect(screen.getAllByText('Type: Legacy Drift').length).toBeGreaterThan(0)
})
})
@@ -3,12 +3,16 @@ import { describe, expect, test } from 'vitest'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
applyAutoLayout,
buildWorkflowCanvasNodeData,
findOpenNodePosition,
graphNeedsAutoLayout,
resolveParamsForStepChange,
resolveNodeCollisions,
validateWorkflowDraft,
WORKFLOW_NODE_HORIZONTAL_GAP,
WORKFLOW_NODE_MIN_HEIGHT,
WORKFLOW_NODE_WIDTH,
WORKFLOW_NODE_VERTICAL_GAP,
workflowToGraph,
} from '../../components/workflows/workflowGraphDraft'
@@ -472,6 +476,53 @@ describe('validateWorkflowDraft', () => {
})
})
describe('buildWorkflowCanvasNodeData', () => {
test('reuses normalized contract sockets for alternative and provided roles', () => {
const nodeData = buildWorkflowCanvasNodeData('output_save', {}, {
...definitions.output_save,
input_contract: {
context: 'order_line',
requires: ['order_line_context'],
},
output_contract: {
context: 'order_line',
provides: ['media_asset', 'workflow_result'],
},
artifact_roles_consumed: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
artifact_roles_produced: ['media_asset', 'workflow_result'],
})
expect(nodeData.inputPorts).toEqual([
{
id: 'input:order_line_context',
label: 'Order Line Context',
roles: ['order_line_context'],
kind: 'required',
},
{
id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset',
label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset',
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
kind: 'alternative',
},
])
expect(nodeData.outputPorts).toEqual([
{
id: 'output:media_asset',
label: 'Media Asset',
roles: ['media_asset'],
kind: 'provided',
},
{
id: 'output:workflow_result',
label: 'Workflow Result',
roles: ['workflow_result'],
kind: 'provided',
},
])
})
})
describe('resolveParamsForStepChange', () => {
test('keeps only parameters supported by the target step schema', () => {
const next = resolveParamsForStepChange(definitions.blender_still, {
@@ -503,6 +554,18 @@ describe('resolveParamsForStepChange', () => {
})
describe('resolveNodeCollisions', () => {
test('prefers structured placement to the right or below before searching left/up', () => {
const nodes = [
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
createPositionedNode('right', 'resolve_template', 56 + WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP, 48, 'Right'),
]
const nextPosition = findOpenNodePosition(nodes, { x: 56, y: 48 })
expect(nextPosition.x).toBe(56)
expect(nextPosition.y).toBeGreaterThanOrEqual(48 + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP)
})
test('pushes overlapping nodes away from a settled anchor without moving the anchor', () => {
const nodes = [
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
@@ -537,7 +600,53 @@ describe('resolveNodeCollisions', () => {
})
})
describe('applyAutoLayout', () => {
test('separates disconnected components into deterministic vertical bands', () => {
const nodes = [
createPositionedNode('a', 'resolve_step_path', 0, 0, 'Resolve STEP Path'),
createPositionedNode('b', 'glb_bbox', 0, 0, 'Compute Bounding Box'),
createPositionedNode('c', 'order_line_setup', 0, 0, 'Order Line Setup'),
createPositionedNode('d', 'resolve_template', 0, 0, 'Resolve Template'),
]
const edges = [createEdge('a', 'b'), createEdge('c', 'd')]
const laidOut = applyAutoLayout(nodes, edges)
const firstComponentBottom = Math.max(
...laidOut
.filter(node => node.id === 'a' || node.id === 'b')
.map(node => node.position.y + WORKFLOW_NODE_MIN_HEIGHT),
)
const secondComponentTop = Math.min(
...laidOut
.filter(node => node.id === 'c' || node.id === 'd')
.map(node => node.position.y),
)
expect(secondComponentTop - firstComponentBottom).toBeGreaterThanOrEqual(
WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP * 2,
)
expect(laidOut.find(node => node.id === 'a')?.position.x).toBe(laidOut.find(node => node.id === 'c')?.position.x)
expect(laidOut.find(node => node.id === 'b')?.position.x).toBeGreaterThan(
laidOut.find(node => node.id === 'a')?.position.x ?? 0,
)
expect(laidOut.find(node => node.id === 'd')?.position.x).toBeGreaterThan(
laidOut.find(node => node.id === 'c')?.position.x ?? 0,
)
})
})
describe('workflowToGraph', () => {
test('keeps workflow-root record requirements out of canvas input sockets', () => {
const data = buildWorkflowCanvasNodeData('order_line_setup', {}, definitions.order_line_setup)
expect(data.contextInputs).toEqual(['order_line_record'])
expect(data.inputPorts).toEqual([])
expect(data.authoringPatternLabel).toBe('Context Entry')
expect(data.variableSummaryText).toBe(
'This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.',
)
})
test('derives explicit input and output ports from the node contract', () => {
const data = buildWorkflowCanvasNodeData('blender_still', {}, definitions.blender_still)
@@ -549,6 +658,9 @@ describe('workflowToGraph', () => {
])
expect(data.outputPorts?.map(port => port.label)).toEqual(['Rendered Image'])
expect(data.editableFieldCount).toBe(2)
expect(data.socketRequirementDescription).toBe(
'Wire 4 required upstream sockets: Order Line Context, Render Template, Material Assignments, and Bounding Box.',
)
})
test('assigns semantic handle ids to edges based on matching contracts', () => {
@@ -160,6 +160,25 @@ const definitions: WorkflowNodeDefinition[] = [
artifact_roles_consumed: [],
legacy_source: 'legacy.notify',
},
{
step: 'export_blend',
label: 'Export Blend',
family: 'order_line',
module_key: 'media.export_blend',
category: 'output',
description: 'Export blend.',
node_type: 'outputNode',
icon: 'download',
defaults: {},
fields: [],
execution_kind: 'bridge',
legacy_compatible: true,
input_contract: { context: 'order_line', requires: ['render_template'] },
output_contract: { context: 'order_line', provides: ['blend_asset'] },
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: 'legacy.export_blend',
},
]
const cadDefinitions: WorkflowNodeDefinition[] = [
@@ -302,12 +321,18 @@ describe('workflowModuleBundles', () => {
test('exposes family-scoped bundles when required steps exist', () => {
const bundles = getWorkflowModuleBundles(definitions, 'order_line')
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_core', 'output_publish_notify'])
expect(bundles.map(bundle => bundle.id)).toEqual([
'scene_prep_core',
'materials_core',
'still_render_core',
'output_publish_notify',
'blend_export_publish',
])
})
test('creates a connected bundle insertion graph for still-render authoring', () => {
test('creates a connected bundle insertion graph for scene-prep authoring', () => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId: 'still_render_core',
bundleId: 'scene_prep_core',
graphFamily: 'order_line',
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
existingNodes: [],
@@ -317,22 +342,44 @@ describe('workflowModuleBundles', () => {
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(6)
expect(insertion.edges).toHaveLength(5)
expect(insertion.nodes).toHaveLength(3)
expect(insertion.edges).toHaveLength(2)
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 320 })
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 320 })
expect(insertion.nodes[0].data).toMatchObject({ step: 'order_line_setup', label: 'Order Line Setup' })
expect(insertion.nodes[5].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
expect(insertion.nodes[2].data).toMatchObject({ step: 'glb_bbox', label: 'Compute Bounding Box' })
expect(insertion.edges[0]).toMatchObject({
source: insertion.nodes[0].id,
target: insertion.nodes[1].id,
})
})
test('creates a single-node still-render module for stage-level insertion', () => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId: 'still_render_core',
graphFamily: 'order_line',
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
existingNodes: [],
preferredPosition: { x: 640, y: 180 },
})
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(1)
expect(insertion.edges).toHaveLength(0)
expect(insertion.nodes[0].position).toEqual({ x: 640, y: 180 })
expect(insertion.nodes[0].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
})
test('exposes full reference paths for complete non-legacy authoring flows', () => {
const bundles = getWorkflowReferenceBundles(definitions, 'order_line')
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_reference'])
expect(bundles.map(bundle => bundle.id)).toEqual([
'still_render_reference',
'still_render_alpha_reference',
'still_render_blend_reference',
])
})
test('creates the canonical still-render reference graph with branched edges', () => {
@@ -354,7 +401,13 @@ describe('workflowModuleBundles', () => {
expect(insertion.nodes[5].data).toMatchObject({
step: 'blender_still',
label: 'Still Render',
params: { use_custom_render_settings: true },
params: {
use_custom_render_settings: false,
render_engine: 'cycles',
samples: 256,
width: 1920,
height: 1080,
},
})
expect(insertion.edges).toEqual(
expect.arrayContaining([
@@ -386,12 +439,13 @@ describe('workflowModuleBundles', () => {
expect(insertion.ok).toBe(true)
if (!insertion.ok) return
expect(insertion.nodes).toHaveLength(8)
expect(insertion.edges).toHaveLength(7)
expect(insertion.nodes).toHaveLength(9)
expect(insertion.edges).toHaveLength(9)
expect(insertion.nodes.map(node => node.data.step)).toEqual([
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'glb_bbox',
'stl_cache_generate',
'blender_render',
'threejs_render',
@@ -402,11 +456,15 @@ describe('workflowModuleBundles', () => {
expect.arrayContaining([
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[4].id,
target: insertion.nodes[5].id,
}),
expect.objectContaining({
source: insertion.nodes[2].id,
target: insertion.nodes[5].id,
target: insertion.nodes[6].id,
}),
expect.objectContaining({
source: insertion.nodes[3].id,
target: insertion.nodes[6].id,
}),
]),
)
@@ -0,0 +1,378 @@
import { describe, expect, test } from 'vitest'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
getWorkflowNodeContractPresentation,
getWorkflowNodeContractSummary,
getWorkflowNodeDeclaredOutputCount,
getWorkflowNodeContractSignals,
getWorkflowNodeInputSocketDescriptors,
getWorkflowNodeInputMetric,
getWorkflowNodeNoSettingsDescription,
getWorkflowNodeOutputSocketDescriptors,
getWorkflowNodeOutputMetric,
getWorkflowNodeSocketRequirementDescription,
getWorkflowNodeTotalVariableCount,
getWorkflowNodeValidationWatchpoints,
getWorkflowNodeVariableSummaryText,
getWorkflowNodeVariableMetric,
} from '../../components/workflows/workflowNodeContracts'
function buildDefinition(overrides: Partial<WorkflowNodeDefinition>): WorkflowNodeDefinition {
return {
step: 'test_step',
label: 'Test Step',
family: 'order_line',
module_key: 'test.module',
category: 'processing',
description: 'Test definition',
node_type: 'processNode',
icon: 'box',
defaults: {},
fields: [],
execution_kind: 'native',
legacy_compatible: false,
input_contract: {},
output_contract: {},
artifact_roles_produced: [],
artifact_roles_consumed: [],
legacy_source: null,
...overrides,
}
}
describe('workflowNodeContracts', () => {
test('normalizes output_save fallback alternative inputs into one contract summary', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'output_save',
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] },
}),
)
expect(summary.requiredInputs).toEqual(['order_line_context'])
expect(summary.requiredAnyInputs).toEqual([
['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
])
expect(summary.inputContextLabel).toBe('Order Line')
expect(summary.outputContextLabel).toBe('Order Line')
})
test('normalizes notify fallback alternatives and avoids duplicate required roles', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'notify',
input_contract: { context: 'order_line', requires: ['order_line_context', 'workflow_result'] },
output_contract: { context: 'order_line', provides: ['notification_event'] },
}),
)
expect(summary.requiredInputs).toEqual(['order_line_context'])
expect(summary.requiredAnyInputs).toEqual([
['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset'],
])
})
test('surfaces editable fields and dynamic template hints from declared contracts', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'resolve_template',
fields: [
{
key: 'samples',
label: 'Samples',
type: 'number',
description: 'Render samples',
section: 'Render',
default: 64,
min: 1,
max: 1024,
step: 1,
unit: null,
options: [],
},
],
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
}),
)
expect(summary.editableFieldCount).toBe(1)
expect(summary.editableFieldLabels).toEqual(['Samples'])
expect(summary.dynamicVariableHint).toBe('Template-selected variables appear after choosing a template.')
expect(summary.authoringPatternLabel).toBe('Inspector-Driven')
})
test('treats workflow-root records as context inputs instead of canvas sockets', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'resolve_step_path',
family: 'cad_file',
input_contract: { context: 'cad_file', requires: ['cad_file_record'] },
output_contract: { context: 'cad_file', provides: ['step_path'] },
}),
)
expect(summary.contextInputs).toEqual(['cad_file_record'])
expect(summary.requiredInputs).toEqual([])
expect(summary.requiredAnyInputs).toEqual([])
expect(summary.authoringPatternLabel).toBe('Context Entry')
})
test('builds consistent catalog metrics for context-only nodes', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'order_line_setup',
input_contract: { context: 'order_line', requires: ['order_line_record'] },
output_contract: { context: 'order_line', provides: ['order_line_context'] },
}),
)
expect(getWorkflowNodeInputMetric(summary)).toEqual({
value: 'Context only (1)',
title: 'Order Line Record',
})
expect(getWorkflowNodeVariableMetric(summary)).toEqual({
value: '0 inspector',
title: 'No local inspector variables',
})
expect(getWorkflowNodeOutputMetric(summary)).toEqual({
value: '1 role',
title: 'Order Line Context',
})
})
test('builds consistent catalog metrics for hybrid nodes with dynamic variables', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'resolve_template',
fields: [
{
key: 'template_id_override',
label: 'Template Override',
type: 'text',
description: 'Template override',
section: 'General',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
},
],
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
}),
)
expect(getWorkflowNodeInputMetric(summary)).toEqual({
value: '1 socket',
title: 'Required: 1, alternative groups: 0',
})
expect(getWorkflowNodeVariableMetric(summary)).toEqual({
value: '2 inspector',
title: 'Template Override',
})
expect(getWorkflowNodeOutputMetric(summary)).toEqual({
value: '2 roles',
title: 'Render Template, Template Inputs',
})
expect(getWorkflowNodeDeclaredOutputCount(summary)).toBe(2)
expect(getWorkflowNodeTotalVariableCount(summary, ['Studio Variant'])).toBe(2)
})
test('builds one shared presentation model for contract metrics and authoring copy', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'resolve_template',
fields: [
{
key: 'template_id_override',
label: 'Template Override',
type: 'text',
description: 'Template override',
section: 'General',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
},
],
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
}),
)
expect(getWorkflowNodeContractPresentation(summary, ['Studio Variant'])).toEqual({
inputMetric: {
value: '1 socket',
title: 'Required: 1, alternative groups: 0',
},
variableMetric: {
value: '2 inspector',
title: 'Template Override',
},
outputMetric: {
value: '2 roles',
title: 'Render Template, Template Inputs',
},
socketRequirementDescription: 'This node waits for Order Line Context from upstream.',
variableSummaryText: '2 local variables are edited in the inspector.',
noSettingsDescription: 'This node waits for Order Line Context from upstream.',
socketCount: 1,
variableCount: 2,
declaredOutputCount: 2,
})
})
test('derives reusable input and output socket descriptors from the shared contract summary', () => {
const summary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'output_save',
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['media_asset', 'workflow_result'] },
artifact_roles_produced: ['media_asset', 'workflow_result'],
}),
)
expect(getWorkflowNodeInputSocketDescriptors(summary)).toEqual([
{
id: 'input:order_line_context',
label: 'Order Line Context',
roles: ['order_line_context'],
kind: 'required',
},
{
id: 'input-any:rendered_image|rendered_frames|rendered_video|blend_asset',
label: 'Any of: Rendered Image / Rendered Frames / Rendered Video / Blend Asset',
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset'],
kind: 'alternative',
},
])
expect(getWorkflowNodeOutputSocketDescriptors(summary)).toEqual([
{
id: 'output:media_asset',
label: 'Media Asset',
roles: ['media_asset'],
kind: 'provided',
},
{
id: 'output:workflow_result',
label: 'Workflow Result',
roles: ['workflow_result'],
kind: 'provided',
},
])
})
test('describes context-only and single-socket authoring expectations clearly', () => {
const contextSummary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'order_line_setup',
input_contract: { context: 'order_line', requires: ['order_line_record'] },
output_contract: { context: 'order_line', provides: ['order_line_context'] },
}),
)
const singleSocketSummary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'occ_object_extract',
family: 'cad_file',
input_contract: { context: 'cad_file', requires: ['step_path'] },
output_contract: { context: 'cad_file', provides: ['occ_object'] },
}),
)
expect(getWorkflowNodeSocketRequirementDescription(contextSummary)).toBe(
'Workflow context supplies Order Line Record. No additional upstream sockets are required.',
)
expect(getWorkflowNodeNoSettingsDescription(contextSummary)).toBe(
'Workflow context already provides Order Line Record.',
)
expect(getWorkflowNodeSocketRequirementDescription(singleSocketSummary)).toBe(
'This node waits for STEP Path from upstream.',
)
})
test('describes mixed required and alternative socket requirements clearly', () => {
const mixedSummary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'output_save',
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['workflow_result'] },
}),
)
expect(getWorkflowNodeSocketRequirementDescription(mixedSummary)).toBe(
'This node requires Order Line Context and one additional upstream artifact from any of: rendered image / rendered frames / rendered video / blend asset.',
)
})
test('describes multi-socket render prerequisites without collapsing them into generic text', () => {
const renderSummary = getWorkflowNodeContractSummary(
buildDefinition({
step: 'blender_still',
input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] },
output_contract: { context: 'order_line', provides: ['rendered_image'] },
}),
)
expect(getWorkflowNodeSocketRequirementDescription(renderSummary)).toBe(
'Wire 3 required upstream sockets: Order Line Context, Render Template, and Bounding Box.',
)
})
test('builds variable summaries and validation watchpoints from the shared contract model', () => {
const definition = buildDefinition({
step: 'resolve_template',
label: 'Resolve Template',
execution_kind: 'bridge',
input_contract: { context: 'order_line', requires: ['order_line_context'] },
output_contract: { context: 'order_line', provides: ['render_template', 'template_inputs'] },
artifact_roles_consumed: ['order_line_context'],
artifact_roles_produced: ['render_template'],
fields: [
{
key: 'template_id_override',
label: 'Template Override',
type: 'text',
description: 'Template override',
section: 'General',
default: '',
min: null,
max: null,
step: null,
unit: null,
options: [],
},
],
})
const summary = getWorkflowNodeContractSummary(definition)
const watchpoints = getWorkflowNodeValidationWatchpoints(definition, summary)
expect(getWorkflowNodeVariableSummaryText(summary, ['Studio Variant'])).toBe(
'2 local variables are edited in the inspector.',
)
expect(watchpoints.map(watchpoint => watchpoint.label)).toEqual([
'Context',
'Runtime Gap',
'Legacy Drift',
'Artifact Flow',
])
expect(getWorkflowNodeContractSignals(definition, summary).map(signal => signal.label)).toEqual([
'Hybrid',
'Template Inputs',
'In Order Line',
'Out Order Line',
'Requires Order Line Context',
'Provides Render Template',
'Provides Template Inputs',
'Consumes Order Line Context',
'Produces Render Template',
'Watch Context',
'Watch Runtime Gap',
])
})
})
+11
View File
@@ -0,0 +1,11 @@
import api from './client'
export interface BrandingSettings {
app_name: string
app_subtitle: string
}
export async function fetchBranding(): Promise<BrandingSettings> {
const { data } = await api.get<BrandingSettings>('/admin/branding')
return data
}
+80 -12
View File
@@ -4,7 +4,12 @@ import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './ou
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export type WorkflowBlueprintType = 'cad_intake' | 'order_rendering' | 'still_graph_reference'
export type WorkflowBlueprintType =
| 'cad_intake'
| 'order_rendering'
| 'still_graph_reference'
| 'still_graph_alpha_reference'
| 'still_graph_blend_reference'
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
export interface WorkflowRolloutLatestRun {
@@ -49,6 +54,7 @@ export interface WorkflowDefinition {
rollout_summary: WorkflowRolloutSummary
is_active: boolean
created_at: string
updated_at: string
}
export interface WorkflowConfig {
@@ -173,6 +179,8 @@ export interface WorkflowOrderLineContextOption {
value: string
label: string
meta: string
is_renderable: boolean
renderability_reason: string | null
}
export interface WorkflowOrderLineContextGroup {
@@ -232,7 +240,11 @@ export const getWorkflow = (id: string): Promise<WorkflowDefinition> =>
export const createWorkflow = (data: WorkflowCreate): Promise<WorkflowDefinition> =>
api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data))
export const updateWorkflow = (id: string, data: Partial<WorkflowCreate>): Promise<WorkflowDefinition> =>
export interface WorkflowUpdatePayload extends Partial<WorkflowCreate> {
updated_at?: string
}
export const updateWorkflow = (id: string, data: WorkflowUpdatePayload): Promise<WorkflowDefinition> =>
api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data))
export const deleteWorkflow = (id: string): Promise<void> =>
@@ -379,9 +391,20 @@ function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): Work
return normalizeRenderParams(match?.params ?? {})
}
function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return {
nodes: [
function buildOrderLineStillGraphNodes(
renderParams: WorkflowParams,
options: {
transparentBg?: boolean
includeBlendExport?: boolean
} = {},
): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
const resolvedRenderParams = {
use_custom_render_settings: false,
...renderParams,
...(options.transparentBg !== undefined ? { transparent_bg: options.transparentBg } : {}),
}
const nodes: WorkflowNode[] = [
buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }),
buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }),
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, {
@@ -399,7 +422,7 @@ function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: W
buildWorkflowNode('render', 'blender_still', 680, 160, {
label: 'Still Render',
type: 'renderNode',
params: { use_custom_render_settings: false, ...renderParams },
params: resolvedRenderParams,
}),
buildWorkflowNode('output', 'output_save', 920, 120, {
label: 'Save Output',
@@ -409,8 +432,9 @@ function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: W
label: 'Notify Result',
type: 'outputNode',
}),
],
edges: [
]
const edges: WorkflowEdge[] = [
{ from: 'setup', to: 'template' },
{ from: 'setup', to: 'populate_materials' },
{ from: 'setup', to: 'bbox' },
@@ -421,7 +445,35 @@ function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: W
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
]
if (options.includeBlendExport) {
nodes.push(
buildWorkflowNode('blend_export', 'export_blend', 920, 360, {
label: 'Export Blend',
type: 'outputNode',
}),
buildWorkflowNode('save_blend', 'output_save', 1160, 320, {
label: 'Save Blend Output',
type: 'outputNode',
params: { expected_artifact_role: 'blend_export' },
}),
buildWorkflowNode('notify_blend', 'notify', 1160, 420, {
label: 'Notify Blend Export',
type: 'outputNode',
}),
)
edges.push(
{ from: 'setup', to: 'blend_export' },
{ from: 'template', to: 'blend_export' },
{ from: 'blend_export', to: 'save_blend' },
{ from: 'blend_export', to: 'notify_blend' },
)
}
return {
nodes,
edges,
}
}
@@ -681,12 +733,22 @@ export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType):
}
}
const { nodes, edges } = buildOrderLineStillGraphNodes({
const stillReferenceParams: WorkflowParams = {
render_engine: 'cycles',
samples: 256,
width: 1920,
height: 1080,
})
}
const buildStillReference = () => {
if (blueprint === 'still_graph_alpha_reference') {
return buildOrderLineStillGraphNodes(stillReferenceParams, { transparentBg: true })
}
if (blueprint === 'still_graph_blend_reference') {
return buildOrderLineStillGraphNodes(stillReferenceParams, { includeBlendExport: true })
}
return buildOrderLineStillGraphNodes(stillReferenceParams)
}
const { nodes, edges } = buildStillReference()
return {
version: 1,
@@ -819,7 +881,13 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
}
}
if (rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference') {
if (
rawUi.blueprint === 'cad_intake' ||
rawUi.blueprint === 'order_rendering' ||
rawUi.blueprint === 'still_graph_reference' ||
rawUi.blueprint === 'still_graph_alpha_reference' ||
rawUi.blueprint === 'still_graph_blend_reference'
) {
const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint)
return {
...canonical,
@@ -395,21 +395,6 @@ export default function InlineCadViewer({
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
if (!import.meta.env.DEV || !modelReady || meshRegistryRef.current.length === 0) return
const names = new Set<string>(meshRegistryRef.current.map(e => e.partKey))
const keys = Object.keys(partMaterials)
const matched = keys.filter(k => names.has(k))
const unmatched = keys.filter(k => !names.has(k))
console.debug('[CAD] Match status:', {
totalGlbMeshes: names.size,
totalStoredKeys: keys.length,
matched: matched.length,
unmatched: unmatched.length,
unmatchedKeys: unmatched,
glbNames: [...names].sort(),
})
}, [modelReady, partMaterials])
const generateMut = useMutation({
mutationFn: () => generateGltfGeometry(cadFileId),
+3 -3
View File
@@ -55,7 +55,7 @@ export interface ThreeDViewerProps {
cadFileId: string
onClose: () => void
/** URL for the geometry-only GLB (from OCC export) */
geometryGltfUrl?: string
glbUrl?: string
hasGeometryGlb?: boolean
onGenerateGeometry?: () => void
isGeneratingGeometry?: boolean
@@ -366,7 +366,7 @@ function TBtn({ active, onClick, title, children, disabled }: {
export default function ThreeDViewer({
cadFileId,
onClose,
geometryGltfUrl,
glbUrl,
hasGeometryGlb,
onGenerateGeometry,
isGeneratingGeometry,
@@ -501,7 +501,7 @@ export default function ThreeDViewer({
)
// Raw URL (used as stable key before blob fetch)
const rawActiveUrl = geometryGltfUrl
const rawActiveUrl = glbUrl
// Resolved blob URL used in useGLTF (requires auth header)
const activeUrl = blobUrl
+5 -3
View File
@@ -8,6 +8,7 @@ import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter'
import ChatPanel from '../chat/ChatPanel'
import { useBranding } from '../../hooks/useBranding'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
@@ -39,6 +40,7 @@ export default function Layout() {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [chatOpen, setChatOpen] = useState(false)
const { appName, appSubtitle } = useBranding()
// Extract page context from URL for the chat agent
const chatContext = (() => {
@@ -81,7 +83,7 @@ export default function Layout() {
>
<Menu size={20} />
</button>
<span className="flex-1 text-sm font-semibold text-content">Hart.O.Mat</span>
<span className="flex-1 text-sm font-semibold text-content">{appName}</span>
<NotificationCenter />
</header>
@@ -108,8 +110,8 @@ export default function Layout() {
<span className="text-accent-text text-sm font-bold">H</span>
</div>
<div className="flex-1">
<p className="font-semibold text-content text-sm">Hart.O.Mat</p>
<p className="text-xs text-content-muted">Hartomatisierung</p>
<p className="font-semibold text-content text-sm">{appName}</p>
<p className="text-xs text-content-muted">{appSubtitle}</p>
</div>
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
<span className="hidden md:block">
@@ -1,16 +1,17 @@
import { useEffect, type ReactNode } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { Boxes, Milestone, X } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector'
import {
type WorkflowAuthoringActions,
type WorkflowAuthoringPosition,
} from './workflowAuthoringActions'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
export const NODE_COMMAND_MENU_WIDTH = 360
export const NODE_COMMAND_MENU_WIDTH = 380
interface NodeCommandMenuProps {
definitions: WorkflowNodeDefinition[]
@@ -31,7 +32,8 @@ export function NodeCommandMenu({
onClose,
renderIcon,
}: NodeCommandMenuProps) {
const { activeSection, insertBindings, plan, sections, setActiveSection } =
const containerRef = useRef<HTMLDivElement | null>(null)
const { activeSection, activeSectionDetail, chrome, insertBindings, plan, sections, setActiveSection } =
useWorkflowAuthoringSurface({
definitions,
graphFamily,
@@ -52,19 +54,28 @@ export function NodeCommandMenu({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!containerRef.current) return
if (containerRef.current.contains(event.target as Node)) return
onClose()
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [onClose])
return (
<div
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
ref={containerRef}
className="flex max-h-[calc(100vh-1.5rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
style={{ width: NODE_COMMAND_MENU_WIDTH }}
>
<div className="border-b border-border-default px-4 py-3">
<div className="border-b border-border-default px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-content">Workflow Authoring</p>
<p className="text-xs text-content-muted">
Use complete reference paths, modules, starter steps, or the raw node library directly on the canvas.
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-semibold text-content">{chrome.menuTitle}</p>
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
{activeSteps.length} on canvas
</span>
@@ -80,7 +91,13 @@ export function NodeCommandMenu({
{plan.moduleBundles.length} modules
</span>
)}
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
Esc closes
</span>
</div>
<p className="mt-1 text-xs text-content-muted">
{chrome.menuHelper}
</p>
</div>
<button
type="button"
@@ -93,29 +110,28 @@ export function NodeCommandMenu({
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={section.helper}
>
<Icon size={12} />
{section.label}
</button>
)
})}
<div className="flex-1 overflow-y-auto px-3 py-2.5">
<div className="space-y-2.5">
<div className="sticky top-0 z-10 -mx-1 rounded-xl bg-surface/95 px-1 pb-2 pt-1 backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface-hover/30 px-3 py-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{chrome.guideEyebrow}
</p>
<p className="text-xs text-content-muted">
{activeSectionDetail?.helper ?? chrome.guideHelper}
</p>
</div>
<span className="shrink-0 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeSectionDetail?.label ?? activeSectionDetail?.tone ?? 'Guided'}
</span>
</div>
<WorkflowAuthoringSectionSelector
sections={sections}
activeSection={activeSection}
onSelectSection={setActiveSection}
variant="pill"
/>
</div>
<WorkflowAuthoringSectionContent
@@ -126,7 +142,7 @@ export function NodeCommandMenu({
actions={insertBindings}
renderIcon={renderIcon}
nodesVariant="menu"
searchPlaceholder="Search nodes"
searchPlaceholder="Search raw nodes"
autoFocusSearch
/>
</div>
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { ArrowRight } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import { WorkflowAuthoringSectionSelector } from './WorkflowAuthoringSectionSelector'
import { type WorkflowAuthoringActions } from './workflowAuthoringActions'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
@@ -30,7 +30,8 @@ export function NodeDefinitionsPanel({
}: NodeDefinitionsPanelProps) {
const {
activeSection,
activeSectionMeta,
activeSectionDetail,
chrome,
defaultSection,
insertBindings,
plan: authoringPlan,
@@ -45,17 +46,20 @@ export function NodeDefinitionsPanel({
const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow
const presentStepCount = activeSteps.length
const isOverviewSection = activeSection === defaultSection || activeSection === 'overview'
const isRawNodeSection = activeSectionDetail?.isRawNodeSection ?? activeSection === 'nodes'
const activeSectionTone = activeSectionDetail?.tone ?? 'Guided'
const activeSectionDescription = activeSectionDetail?.helper
const browserStatusLabel = insertBindings.onSelectStep ? 'Insert mode' : 'Browse mode'
const activeSectionLabel = activeSectionDetail?.summaryLabel ?? activeSection
return (
<div className="space-y-3">
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/25 p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Library
</p>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">Authoring Browser</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{chrome.browserTitle}</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{definitions.length} definitions
</span>
@@ -63,66 +67,38 @@ export function NodeDefinitionsPanel({
{presentStepCount} on canvas
</span>
</div>
<p className="mt-1 text-xs text-content-muted">
Start from reference paths or production modules, then drop to starter steps and raw nodes only when needed.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{insertBindings.onSelectStep ? 'Insert enabled' : 'Browse only'}
<div className="mt-1 flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
<span>{browserStatusLabel}</span>
<span></span>
<span className="font-medium text-content">{activeSectionLabel}</span>
<span></span>
<span>{activeSectionTone}</span>
{activeSectionDescription && (
<>
<span className="hidden sm:inline"></span>
<span className="hidden max-w-[18rem] truncate sm:inline" title={activeSectionDescription}>
{activeSectionDescription}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
aria-label={section.label}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isActive
? 'border-accent/40 bg-accent-light'
: 'border-border-default bg-surface hover:bg-surface-hover'
}`}
title={section.helper}
>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
<Icon size={14} />
{section.label}
</span>
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
</div>
<p className="mt-2 text-xs text-content-muted">{section.helper}</p>
</button>
)
})}
</div>
</div>
{activeSectionMeta && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Current Section
</p>
<p className="mt-1 text-xs text-content-muted">
{activeSectionMeta.label}: {activeSectionMeta.helper}
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Active
</span>
</div>
</div>
</>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeSectionTone}
</span>
</div>
</div>
{activeSection === defaultSection && (
<WorkflowAuthoringSectionSelector
sections={sections}
activeSection={activeSection}
onSelectSection={setActiveSection}
variant="card"
/>
</div>
{isOverviewSection && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
@@ -130,7 +106,7 @@ export function NodeDefinitionsPanel({
Authoring Flow
</p>
<p className="mt-1 text-xs text-content-muted">
Keep the legacy workflow safe by starting from graph-safe assemblies, then drilling down only when the module-level path is in place.
Start high-level, then descend only when the path is already stable.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
@@ -156,21 +132,8 @@ export function NodeDefinitionsPanel({
</div>
)}
{activeSection === 'nodes' ? (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Raw Node Catalog
</p>
<p className="mt-1 text-xs text-content-muted">
Advanced mode for inserting individual legacy, bridge, or graph nodes after the higher-level path is established.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Escape Hatch
</span>
</div>
{isRawNodeSection ? (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
@@ -1,7 +1,13 @@
import { ArrowRight, Boxes, CheckCircle2, CircleDashed, Library, Milestone, Sparkles } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import {
AUTHORING_STAGE_DESCRIPTIONS,
AUTHORING_STAGE_ORDER,
AUTHORING_STAGE_STYLES,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
@@ -15,6 +21,10 @@ type WorkflowAuthoringOverviewProps = {
onSelectStep?: (step: string) => void
}
function compareStages(left: WorkflowAuthoringStage, right: WorkflowAuthoringStage) {
return AUTHORING_STAGE_ORDER.indexOf(left) - AUTHORING_STAGE_ORDER.indexOf(right)
}
export function WorkflowAuthoringOverview({
definitions,
graphFamily,
@@ -24,6 +34,7 @@ export function WorkflowAuthoringOverview({
onSelectStep,
}: WorkflowAuthoringOverviewProps) {
const {
authoringFlow,
description,
gapFillDefinitions,
moduleBundles,
@@ -33,24 +44,42 @@ export function WorkflowAuthoringOverview({
title,
} = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
const priorityIcons = [Milestone, Boxes, Library] as const
const orderedModuleBundles = [...moduleBundles].sort((left, right) => {
const stageDelta = compareStages(left.stageId, right.stageId)
if (stageDelta !== 0) return stageDelta
return left.label.localeCompare(right.label)
})
const hasIncompleteStage = stageProgress.some(stage => stage.present < stage.total)
const shouldExpandQuickStart = activeSteps.length === 0
const shouldExpandRecommendedPath = activeSteps.length < 2
const flowSummary = authoringFlow.map(item => item.title).join(' -> ')
const incompleteStages = stageProgress.filter(stage => stage.present < stage.total).length
return (
<div className="space-y-3">
{graphFamily !== 'mixed' && stageProgress.length > 0 && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<details
className="rounded-2xl border border-border-default bg-surface-hover/20 p-3"
open={hasIncompleteStage}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Stage Status
</p>
<p className="mt-1 text-xs text-content-muted">
Track the canonical authoring path stage by stage and fill only the missing parts.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Operational
{incompleteStages === 0
? 'All stages covered'
: `${incompleteStages} stage${incompleteStages === 1 ? '' : 's'} open`}
</span>
</div>
<p className="mt-1 truncate text-xs text-content-muted">{flowSummary}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{hasIncompleteStage ? 'Needs attention' : 'Operational'}
</span>
</summary>
<div className="mt-3 grid gap-2">
{stageProgress.map(stage => {
@@ -61,12 +90,14 @@ export function WorkflowAuthoringOverview({
return (
<div
key={stage.id}
className="rounded-xl border border-border-default bg-surface px-3 py-3"
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isComplete ? 'text-emerald-700 dark:text-emerald-300' : 'text-content'}`}>
<span
className={`inline-flex items-center gap-1 text-sm font-medium ${isComplete ? 'text-emerald-700 dark:text-emerald-300' : 'text-content'}`}
>
<Icon size={14} />
{stage.title}
</span>
@@ -74,7 +105,7 @@ export function WorkflowAuthoringOverview({
{progressLabel}
</span>
</div>
<p className="mt-1 text-xs text-content-muted">{stage.description}</p>
<p className="mt-1 text-[11px] text-content-muted">{stage.description}</p>
</div>
{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && (
@@ -114,33 +145,40 @@ export function WorkflowAuthoringOverview({
)
})}
</div>
</div>
</details>
)}
<div className="rounded-2xl border border-accent/20 bg-accent-light p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<details
className="rounded-2xl border border-accent/20 bg-accent-light p-3"
open={shouldExpandRecommendedPath}
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Sparkles size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Recommended Path
</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{priorities.length} priorities
</span>
</div>
<p className="mt-1 text-sm font-semibold text-content">{title}</p>
<p className="mt-1 text-xs text-content-muted">{description}</p>
<p className="mt-2 truncate text-[11px] text-content-secondary">{flowSummary}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeSteps.length} on canvas
</span>
</div>
</summary>
<div className="mt-3 space-y-1.5">
<div className="mt-3 grid gap-2">
{priorities.map((priority, index) => {
const Icon = priorityIcons[index] ?? Library
return (
<div
key={priority.title}
className="rounded-xl border border-border-default bg-surface px-3 py-2"
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
>
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
@@ -151,32 +189,59 @@ export function WorkflowAuthoringOverview({
<Icon size={13} />
{priority.title}
</span>
<p className="mt-0.5 text-xs text-content-muted">{priority.description}</p>
<p className="mt-0.5 text-[11px] text-content-muted">{priority.description}</p>
</div>
</div>
</div>
)
})}
</div>
</div>
</details>
{(referenceBundles.length > 0 || moduleBundles.length > 0) && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<details
className="rounded-2xl border border-border-default bg-surface-hover/20 p-3"
open={shouldExpandQuickStart}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Start
</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{referenceBundles.length + orderedModuleBundles.length} insert options
</span>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert the recommended baseline first, then add stage bundles only where the path should diverge.
Start from a reference path, then add only the stage bundles you need.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</summary>
<div className="mt-3 space-y-2.5">
{referenceBundles.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Reference Baselines
</p>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
{referenceBundles.length} path{referenceBundles.length === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1 text-[11px] text-content-muted">
Insert a known-good full path before swapping single stages.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="mt-2 flex flex-wrap gap-2">
{referenceBundles.map(bundle => (
<button
key={bundle.id}
@@ -189,32 +254,85 @@ export function WorkflowAuthoringOverview({
Insert {bundle.shortLabel}
</button>
))}
{moduleBundles.slice(0, 2).map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertModule?.(bundle.id)}
disabled={!onInsertModule}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Boxes size={12} />
Insert {bundle.shortLabel}
</button>
))}
</div>
</div>
)}
{graphFamily !== 'mixed' && onSelectStep && (
{orderedModuleBundles.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Stage Modules
</p>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
{orderedModuleBundles.length} module{orderedModuleBundles.length === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1 text-[11px] text-content-muted">
Replace one production stage at a time without rewiring the full path.
</p>
</div>
</div>
<div className="mt-2 grid gap-2 md:grid-cols-2">
{orderedModuleBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${AUTHORING_STAGE_STYLES[bundle.stageId]}`}>
{bundle.stage}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<div className="mt-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<p className="mt-0.5 text-[11px] text-content-muted">
{bundle.description || AUTHORING_STAGE_DESCRIPTIONS[bundle.stageId]}
</p>
<p className="mt-1 truncate text-[11px] text-content-secondary">
{bundle.stepIds.join(' -> ')}
</p>
</div>
<button
type="button"
onClick={() => onInsertModule?.(bundle.id)}
disabled={!onInsertModule}
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Boxes size={12} />
Insert {bundle.shortLabel}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</details>
)}
{graphFamily !== 'mixed' && onSelectStep && gapFillDefinitions.length > 0 && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Gap Fill
</p>
<p className="mt-1 text-xs text-content-muted">
Use starter-safe inserts only for missing links in the recommended chain.
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{gapFillDefinitions.length} starter-safe step{gapFillDefinitions.length === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1 text-[11px] text-content-muted">
Insert only the missing links for the canonical chain.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
@@ -222,7 +340,7 @@ export function WorkflowAuthoringOverview({
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="mt-2 flex flex-wrap gap-2">
{gapFillDefinitions.map(definition => (
<button
key={definition.step}
@@ -33,54 +33,30 @@ export function WorkflowAuthoringSectionContent({
searchPlaceholder,
autoFocusSearch = false,
}: WorkflowAuthoringSectionContentProps) {
if (activeSection === 'overview') {
return (
const sharedProps = {
definitions,
graphFamily,
activeSteps,
}
const contentBySection: Record<WorkflowAuthoringSection, ReactNode> = {
overview: (
<WorkflowAuthoringOverview
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
{...sharedProps}
onInsertModule={actions?.onInsertModule}
onInsertReferencePath={actions?.onInsertReferencePath}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'paths') {
return (
),
paths: (
<WorkflowReferenceBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
{...sharedProps}
onInsertReferencePath={actions?.onInsertReferencePath}
/>
)
}
if (activeSection === 'modules') {
return (
<WorkflowModuleBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertModule={actions?.onInsertModule}
/>
)
}
if (activeSection === 'starter') {
return (
<WorkflowStarterPathPanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'nodes') {
return (
),
modules: <WorkflowModuleBundlePanel {...sharedProps} onInsertModule={actions?.onInsertModule} />,
starter: <WorkflowStarterPathPanel {...sharedProps} onSelectStep={actions?.onSelectStep} />,
nodes: (
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
@@ -90,8 +66,8 @@ export function WorkflowAuthoringSectionContent({
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
/>
)
),
}
return null
return contentBySection[activeSection] ?? null
}
@@ -0,0 +1,103 @@
import { ArrowRight } from 'lucide-react'
import type {
WorkflowAuthoringSection,
WorkflowAuthoringSectionConfig,
} from './workflowAuthoringSections'
type WorkflowAuthoringSectionSelectorProps = {
sections: WorkflowAuthoringSectionConfig[]
activeSection: WorkflowAuthoringSection
onSelectSection: (section: WorkflowAuthoringSection) => void
variant?: 'pill' | 'card'
}
export function WorkflowAuthoringSectionSelector({
sections,
activeSection,
onSelectSection,
variant = 'pill',
}: WorkflowAuthoringSectionSelectorProps) {
if (variant === 'card') {
return (
<div className="grid gap-2 sm:grid-cols-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
const toneLabel = section.tone === 'Escape Hatch' ? 'Direct' : section.tone
return (
<button
key={section.key}
type="button"
onClick={() => onSelectSection(section.key)}
aria-label={section.label}
className={`min-h-[76px] rounded-2xl border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-accent/40 bg-accent-light'
: 'border-border-default bg-surface hover:bg-surface-hover'
}`}
title={section.helper}
>
<div className="flex h-full items-start justify-between gap-2">
<div className="min-w-0">
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
<Icon size={14} />
{section.label}
</span>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<span
className={`rounded-full px-2 py-0.5 font-medium ${
section.tone === 'Escape Hatch'
? 'border border-border-default bg-surface text-content-muted'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
}`}
>
{toneLabel}
</span>
{section.summaryLabel && (
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{section.summaryLabel}
</span>
)}
</div>
<p className="mt-1 line-clamp-1 text-[11px] text-content-muted">
{section.helper}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 self-center">
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
</div>
</div>
</button>
)
})}
</div>
)
}
return (
<div className="flex flex-wrap gap-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => onSelectSection(section.key)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={section.helper}
>
<Icon size={12} />
{section.label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,93 @@
import type { LucideIcon } from 'lucide-react'
import { Wand2 } from 'lucide-react'
type WorkflowBundleCatalogItem = {
id: string
label: string
stage: string
description: string
stepIds: string[]
presentCount: number
totalCount: number
}
type WorkflowBundleCatalogProps<TBundle extends WorkflowBundleCatalogItem> = {
bundles: TBundle[]
emptyLabel: string
icon: LucideIcon
title: string
description: string
countLabel: string
insertLabel?: string
onInsert?: (bundleId: TBundle['id']) => void
}
export function WorkflowBundleCatalog<TBundle extends WorkflowBundleCatalogItem>({
bundles,
emptyLabel,
icon: Icon,
title,
description,
countLabel,
insertLabel = 'Insert',
onInsert,
}: WorkflowBundleCatalogProps<TBundle>) {
if (bundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Icon size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
{title}
</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{bundles.length} {countLabel}
</span>
</div>
<p className="mt-1 text-[11px] text-content-muted">{description}</p>
</div>
</div>
<div className="grid gap-2">
{bundles.map(bundle => (
<div
key={bundle.id}
className="rounded-xl border border-border-default bg-surface px-3 py-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-[11px] text-content-muted">{bundle.description}</p>
<p className="truncate text-[11px] text-content-secondary">
{bundle.stepIds.length > 0 ? bundle.stepIds.join(' -> ') : emptyLabel}
</p>
</div>
{onInsert ? (
<button
type="button"
onClick={() => onInsert(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex shrink-0 items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
{insertLabel}
</button>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import {
Background,
Controls,
MiniMap,
ReactFlow,
type Edge,
type Node,
} from '@xyflow/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { updateOutputType } from '../../api/outputTypes'
import type {
WorkflowConfig,
WorkflowDefinition,
WorkflowExecutionMode,
} from '../../api/workflows'
import { useThemeStore, resolveTheme } from '../../store/theme'
import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from './NodeCommandMenu'
import { renderWorkflowIcon, workflowCanvasNodeTypes } from './WorkflowCanvasNodes'
import { WorkflowCanvasToolbar } from './WorkflowCanvasToolbar'
import { WorkflowCanvasUtilitySidebar } from './WorkflowCanvasUtilitySidebar'
import { WorkflowValidationBanner } from './WorkflowValidationBanner'
import {
BLUEPRINT_DESCRIPTION,
BLUEPRINT_LABELS,
getWorkflowBlueprint,
} from './workflowBlueprints'
import {
getWorkflowAuthoringEntryAction,
type WorkflowAuthoringActions,
} from './workflowAuthoringActions'
import { type WorkflowCanvasNodeData } from './workflowGraphDraft'
import {
GRAPH_FAMILY_LABELS,
GRAPH_FAMILY_STYLES,
} from './workflowNodeLibrary'
import {
EXECUTION_MODE_BADGE_STYLES,
EXECUTION_MODE_LABELS,
} from './workflowRunPresentation'
import { getWorkflowRolloutPresentation } from './workflowRolloutPresentation'
import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
import { summarizeWorkflowDraftValidationBySeverity } from './workflowValidationPresentation'
import { useWorkflowCanvasController } from './useWorkflowCanvasController'
const EXECUTION_MODE_HINTS: Record<WorkflowExecutionMode, string> = {
legacy: 'Preset dispatcher remains authoritative for production runs.',
graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.',
shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.',
}
type WorkflowCanvasProps = {
workflow: WorkflowDefinition
onSave: (config: WorkflowConfig) => void
isSaving: boolean
canSave?: boolean
}
export function WorkflowCanvas({ workflow, onSave, isSaving, canSave = true }: WorkflowCanvasProps) {
const queryClient = useQueryClient()
const {
reactFlowWrapper,
nodeDefinitions,
nodeDefinitionsByStep,
nodes,
edges,
onNodesChange,
onEdgesChange,
selectedEdgeIds,
selectedNode,
workflowRuns,
selectedRun,
setSelectedRunId,
selectedRunComparison,
isComparisonLoading,
dispatchMutation,
preflightMutation,
dispatchContextId,
setDispatchContextId,
isOrderLineGraph,
isOrderLineContextsLoading,
orderLineContextGroups,
dispatchContextLabel,
dispatchContextSummary,
dispatchContextMeta,
preflightResult,
preflightState,
hasFreshSuccessfulPreflight,
executionMode,
setExecutionMode,
nodeMenuAnchor,
setNodeMenuAnchor,
activeUtilityTab,
setActiveUtilityTab,
validation,
authoringFamily,
graphFamily,
onConnect,
onNodeClick,
onEdgeClick,
onPaneClick,
handleSelectionChange,
handleParamsChange,
handlePipelineStepChange,
handlePaneContextMenu,
handleNodeContextMenu,
insertNode,
insertModuleBundle,
insertReferenceBundle,
handleOpenToolbarNodeMenu,
handleAutoLayout,
handleDeleteSelectedEdges,
onEdgeContextMenu,
onEdgeDoubleClick,
handleSave,
handleDispatch,
handlePreflight,
setReactFlowInstance,
} = useWorkflowCanvasController({ workflow, onSave })
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [nodeMenuElement, setNodeMenuElement] = useState<HTMLDivElement | null>(null)
const [nodeMenuSize, setNodeMenuSize] = useState({ width: NODE_COMMAND_MENU_WIDTH, height: 420 })
useEffect(() => {
const wrapper = reactFlowWrapper.current
if (!wrapper) return
const updateCanvasReadiness = () => {
const bounds = wrapper.getBoundingClientRect()
setIsCanvasReady(bounds.width > 0 && bounds.height > 0)
}
updateCanvasReadiness()
const resizeObserver = new ResizeObserver(() => {
updateCanvasReadiness()
})
resizeObserver.observe(wrapper)
return () => {
resizeObserver.disconnect()
}
}, [reactFlowWrapper, workflow.id])
useLayoutEffect(() => {
if (!nodeMenuElement) return
const measure = () => {
const bounds = nodeMenuElement.getBoundingClientRect()
setNodeMenuSize({
width: Math.max(Math.ceil(bounds.width), NODE_COMMAND_MENU_WIDTH),
height: Math.max(Math.ceil(bounds.height), 320),
})
}
measure()
const resizeObserver = new ResizeObserver(() => {
measure()
})
resizeObserver.observe(nodeMenuElement)
return () => {
resizeObserver.disconnect()
}
}, [nodeMenuElement, nodeMenuAnchor])
const nodeMenuRef = useCallback((element: HTMLDivElement | null) => {
setNodeMenuElement(element)
}, [])
const nodeMenuStyle = useMemo(() => {
if (!nodeMenuAnchor || typeof window === 'undefined') return null
const horizontalMargin = 16
const verticalMargin = 16
const width = nodeMenuSize.width
const height = Math.min(nodeMenuSize.height, window.innerHeight - verticalMargin * 2)
const left = Math.min(
Math.max(nodeMenuAnchor.clientX, horizontalMargin),
Math.max(window.innerWidth - width - horizontalMargin, horizontalMargin),
)
const top = Math.min(
Math.max(nodeMenuAnchor.clientY, verticalMargin),
Math.max(window.innerHeight - height - verticalMargin, verticalMargin),
)
return { left, top }
}, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width])
const { mode } = useThemeStore()
const isDark = resolveTheme(mode) === 'dark'
const canvasEdges = useMemo(
() =>
edges.map(edge => ({
...edge,
selectable: true,
focusable: true,
interactionWidth: (edge as Edge & { interactionWidth?: number }).interactionWidth ?? 44,
style: {
...(edge.style ?? {}),
strokeWidth: edge.selected ? 3.25 : ((edge.style?.strokeWidth as number | undefined) ?? 2.25),
stroke: edge.selected ? (isDark ? '#f59e0b' : '#d97706') : edge.style?.stroke,
opacity: edge.selected ? 1 : 0.95,
},
zIndex: edge.selected ? 20 : edge.zIndex,
})),
[edges, isDark],
)
const activeSteps = useMemo(
() =>
nodes
.map(node => (node.data as WorkflowCanvasNodeData | undefined)?.step)
.filter((step): step is string => Boolean(step)),
[nodes],
)
const authoringActions = useMemo<WorkflowAuthoringActions>(
() => ({
openNodeMenu: handleOpenToolbarNodeMenu,
insertNode,
insertModule: insertModuleBundle,
insertReferencePath: insertReferenceBundle,
}),
[handleOpenToolbarNodeMenu, insertModuleBundle, insertNode, insertReferenceBundle],
)
const authoringSurfaceModel = useMemo(
() =>
getWorkflowAuthoringSurfaceModel({
definitions: nodeDefinitions,
graphFamily: authoringFamily,
activeSteps,
}),
[activeSteps, authoringFamily, nodeDefinitions],
)
const authoringEntryAction = useMemo(
() => getWorkflowAuthoringEntryAction(authoringSurfaceModel),
[authoringSurfaceModel],
)
const rolloutPresentation = useMemo(
() => getWorkflowRolloutPresentation(workflow.rollout_summary),
[workflow.rollout_summary],
)
const validationSummary = useMemo(
() =>
summarizeWorkflowDraftValidationBySeverity({
errors: validation.errors,
warnings: validation.warnings,
}),
[validation.errors, validation.warnings],
)
const rollbackOutputTypeMutation = useMutation({
mutationFn: ({ outputTypeId }: { outputTypeId: string }) =>
updateOutputType(outputTypeId, { workflow_rollout_mode: 'legacy_only' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workflows'] })
queryClient.invalidateQueries({ queryKey: ['output-types'] })
toast.success('Output type rollout reverted to legacy')
},
onError: () => {
toast.error('Failed to revert output type rollout')
},
})
return (
<div className="flex min-h-0 flex-1 flex-col">
<WorkflowCanvasToolbar
workflowName={workflow.name}
blueprintLabel={getWorkflowBlueprint(workflow.config) ? BLUEPRINT_LABELS[getWorkflowBlueprint(workflow.config)!] ?? 'Blueprint' : null}
blueprintDescription={
getWorkflowBlueprint(workflow.config)
? BLUEPRINT_DESCRIPTION[getWorkflowBlueprint(workflow.config)!] ?? 'Reference workflow graph.'
: null
}
authoringFamilyLabel={GRAPH_FAMILY_LABELS[authoringFamily]}
authoringFamilyClassName={GRAPH_FAMILY_STYLES[authoringFamily]}
graphFamilyLabel={GRAPH_FAMILY_LABELS[graphFamily]}
graphFamilyClassName={GRAPH_FAMILY_STYLES[graphFamily]}
executionMode={executionMode}
executionModeLabel={EXECUTION_MODE_LABELS[executionMode]}
executionModeClassName={EXECUTION_MODE_BADGE_STYLES[executionMode]}
executionModeHint={EXECUTION_MODE_HINTS[executionMode]}
rolloutBadgeLabel={rolloutPresentation.badgeLabel}
rolloutBadgeClassName={rolloutPresentation.badgeClassName}
rolloutStatusLabel={rolloutPresentation.statusLabel}
rolloutStatusClassName={rolloutPresentation.statusClassName}
rolloutSummary={rolloutPresentation.summary}
linkedOutputTypeCount={workflow.rollout_summary.linked_output_type_count}
linkedOutputTypes={workflow.rollout_summary.linked_output_types}
dispatchContextKind={isOrderLineGraph ? 'order_line' : graphFamily === 'cad_file' ? 'cad_file' : null}
dispatchContextLabel={dispatchContextLabel}
dispatchContextId={dispatchContextId}
dispatchContextSummary={dispatchContextSummary}
dispatchContextMeta={dispatchContextMeta}
orderLineContextGroups={orderLineContextGroups}
executionModes={(['legacy', 'graph', 'shadow'] as WorkflowExecutionMode[]).map(mode => ({
value: mode,
label: EXECUTION_MODE_LABELS[mode],
}))}
selectedEdgeCount={selectedEdgeIds.length}
canAutoLayout={nodes.length > 0}
canPreflight={dispatchContextId.trim().length > 0}
canDispatch={dispatchContextId.trim().length > 0 && hasFreshSuccessfulPreflight}
hasValidationErrors={validation.errors.length > 0}
validationSummary={validationSummary}
isPreflightPending={preflightMutation.isPending}
isDispatchPending={dispatchMutation.isPending}
isContextOptionsLoading={isOrderLineContextsLoading}
isSaving={isSaving}
canSave={canSave}
rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null}
preflightState={preflightState}
authoringActions={authoringActions}
authoringEntryAction={authoringEntryAction}
onDispatchContextIdChange={setDispatchContextId}
onExecutionModeChange={value => setExecutionMode(value as WorkflowExecutionMode)}
onAutoLayout={handleAutoLayout}
onDeleteSelectedEdges={handleDeleteSelectedEdges}
onPreflight={handlePreflight}
onDispatch={handleDispatch}
onSave={handleSave}
onRollbackOutputType={outputTypeId => rollbackOutputTypeMutation.mutate({ outputTypeId })}
/>
<WorkflowValidationBanner errors={validation.errors} warnings={validation.warnings} />
<div className="flex h-full min-h-0 flex-1 flex-col xl:flex-row">
<div
ref={reactFlowWrapper}
className="relative flex min-h-[680px] min-w-0 flex-1 overflow-hidden xl:h-full"
onContextMenu={event => event.preventDefault()}
>
{isCanvasReady ? (
<ReactFlow
nodes={nodes}
edges={canvasEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={onPaneClick}
onPaneContextMenu={handlePaneContextMenu}
onSelectionChange={handleSelectionChange}
onInit={setReactFlowInstance}
nodeTypes={workflowCanvasNodeTypes}
colorMode={isDark ? 'dark' : 'light'}
defaultEdgeOptions={{
interactionWidth: 44,
selectable: true,
focusable: true,
}}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={{ padding: 0.2 }}
className="h-full w-full"
>
<Background gap={16} />
<Controls />
<MiniMap nodeStrokeWidth={3} zoomable pannable />
</ReactFlow>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-content-muted">
Preparing workflow canvas
</div>
)}
</div>
<WorkflowCanvasUtilitySidebar
activeTab={activeUtilityTab}
onTabChange={setActiveUtilityTab}
selectedNode={selectedNode ? { data: selectedNode.data as WorkflowCanvasNodeData | undefined } : null}
onNodeParamsChange={handleParamsChange}
onNodeStepChange={handlePipelineStepChange}
nodeDefinitions={nodeDefinitions}
nodeDefinitionsByStep={nodeDefinitionsByStep}
graphFamily={authoringFamily}
activeSteps={activeSteps}
authoringActions={authoringActions}
renderNodeIcon={renderWorkflowIcon}
workflowRuns={workflowRuns}
selectedRunId={selectedRun?.id ?? null}
onSelectRun={setSelectedRunId}
comparison={selectedRunComparison}
isComparisonLoading={isComparisonLoading}
preflightResult={preflightResult}
isPreflightPending={preflightMutation.isPending}
/>
</div>
{nodeMenuAnchor && nodeMenuStyle &&
createPortal(
<div
ref={nodeMenuRef}
className="fixed z-[100]"
style={{
left: nodeMenuStyle.left,
top: nodeMenuStyle.top,
}}
>
<NodeCommandMenu
definitions={nodeDefinitions}
graphFamily={authoringFamily}
activeSteps={activeSteps}
actions={authoringActions}
preferredPosition={nodeMenuAnchor.flowPosition}
onClose={() => setNodeMenuAnchor(null)}
renderIcon={renderWorkflowIcon}
/>
</div>,
document.body,
)}
</div>
)
}
@@ -0,0 +1,357 @@
import type { ReactNode } from 'react'
import { Handle, Position, type NodeTypes } from '@xyflow/react'
import {
Bell,
Camera,
Download,
FileUp,
Film,
Layers,
RefreshCw,
} from 'lucide-react'
import {
WORKFLOW_NODE_MIN_HEIGHT,
WORKFLOW_NODE_WIDTH,
type WorkflowCanvasNodeData,
} from './workflowGraphDraft'
import {
getWorkflowNodePortBadgeLabel,
getWorkflowNodePortTitle,
} from './workflowNodePresentation'
import { formatContractValue } from './workflowNodeContracts'
export function renderWorkflowIcon(iconName?: string, size = 14) {
switch (iconName) {
case 'file-up':
return <FileUp size={size} />
case 'film':
return <Film size={size} />
case 'layers':
return <Layers size={size} />
case 'download':
return <Download size={size} />
case 'bell':
return <Bell size={size} />
case 'camera':
return <Camera size={size} />
case 'refresh-cw':
default:
return <RefreshCw size={size} />
}
}
interface BaseNodeProps {
data: WorkflowCanvasNodeData
icon: ReactNode
accentClass: string
selected?: boolean
}
function getHandleOffset(index: number, total: number) {
if (total <= 1) return '50%'
const topPadding = 22
const bottomPadding = 22
const usableHeight = WORKFLOW_NODE_MIN_HEIGHT - topPadding - bottomPadding
const step = usableHeight / (total - 1)
return `${topPadding + step * index}px`
}
type NodeBadge = {
id: string
label: string
title?: string
tone?: 'default' | 'muted'
}
function BadgeList({
badges,
emptyLabel,
badgeClassName,
maxVisibleBadges = 3,
}: {
badges: NodeBadge[]
emptyLabel: string
badgeClassName: string
maxVisibleBadges?: number
}) {
if (badges.length === 0) {
return <p className="text-[10px] text-content-muted">{emptyLabel}</p>
}
const visibleBadges = badges.slice(0, maxVisibleBadges)
const hiddenCount = badges.length - visibleBadges.length
return (
<div className="flex flex-wrap gap-1 overflow-hidden">
{visibleBadges.map(badge => (
<span
key={badge.id}
className={`rounded-full border px-1.5 py-0.5 text-[9px] font-medium leading-4 ${
badge.tone === 'muted'
? 'border-border-default bg-surface text-content-muted'
: badgeClassName
}`}
title={badge.title ?? badge.label}
>
{badge.label}
</span>
))}
{hiddenCount > 0 && (
<span
className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 text-[9px] font-medium leading-4 text-content-muted"
title={badges.slice(maxVisibleBadges).map(badge => badge.title ?? badge.label).join(', ')}
>
+{hiddenCount}
</span>
)}
</div>
)
}
function NodeSummaryRow({
label,
badges,
emptyLabel,
badgeClassName,
maxVisibleBadges,
}: {
label: string
badges: NodeBadge[]
emptyLabel: string
badgeClassName: string
maxVisibleBadges?: number
}) {
return (
<div className="flex items-start gap-2 rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1">
<p className="min-w-[4.2rem] pt-[1px] text-[9px] font-semibold uppercase tracking-wide text-content-muted">
{label}
</p>
<div className="min-w-0 flex-1">
<BadgeList
badges={badges}
emptyLabel={emptyLabel}
badgeClassName={badgeClassName}
maxVisibleBadges={maxVisibleBadges}
/>
</div>
</div>
)
}
function BaseNode({ data, icon, accentClass, selected }: BaseNodeProps) {
const inputPorts = data.inputPorts ?? []
const outputPorts = data.outputPorts ?? []
const contextInputs = data.contextInputs ?? []
const requiredInputCount = inputPorts.filter(port => port.kind === 'required').length
const alternativeInputCount = inputPorts.filter(port => port.kind === 'alternative').length
const hasExplicitInspectorVariables = (data.editableFieldLabels?.length ?? 0) > 0
const hasDynamicVariables = Boolean(data.dynamicVariableHint)
const contextBadges: NodeBadge[] = contextInputs.map(input => ({
id: `context:${input}`,
label: formatContractValue(input),
title: formatContractValue(input),
}))
const variableBadges: NodeBadge[] = [
...(data.editableFieldLabels ?? []).map(label => ({
id: `variable:${label}`,
label,
title: label,
})),
...(data.dynamicVariableHint
? [
{
id: 'variable:dynamic-hint',
label: 'Template Inputs',
title: data.dynamicVariableHint,
tone: 'muted' as const,
},
]
: []),
]
const inputBadges: NodeBadge[] = inputPorts.map(port => ({
id: port.id,
label: getWorkflowNodePortBadgeLabel(port),
title: getWorkflowNodePortTitle(port),
}))
const outputBadges: NodeBadge[] = outputPorts.map(port => ({
id: port.id,
label: getWorkflowNodePortBadgeLabel(port),
title: getWorkflowNodePortTitle(port),
}))
return (
<div
className={`relative flex h-full flex-col rounded-xl border-2 bg-surface px-3 py-3 shadow-sm transition-colors ${
selected ? 'border-accent' : 'border-border-default'
}`}
style={{
width: WORKFLOW_NODE_WIDTH,
minHeight: WORKFLOW_NODE_MIN_HEIGHT,
height: WORKFLOW_NODE_MIN_HEIGHT,
}}
>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
title={port.label}
className={`h-3 w-3 border-2 border-surface ${
port.kind === 'alternative' ? 'bg-sky-400' : 'bg-content-muted'
}`}
style={{ top: getHandleOffset(index, inputPorts.length) }}
/>
))}
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
title={port.label}
className="h-3 w-3 border-2 border-surface bg-content-muted"
style={{ top: getHandleOffset(index, outputPorts.length) }}
/>
))}
<div className={`mb-1 flex min-h-[1.25rem] items-center gap-2 ${accentClass}`}>
{icon}
<span className="text-sm font-medium">{data.label}</span>
</div>
<div className="h-8 overflow-hidden">
{data.description && <p className="line-clamp-2 text-xs text-content-muted">{data.description}</p>}
</div>
<div className="mt-2 space-y-1.5 text-[10px] leading-4 text-content-secondary">
<NodeSummaryRow
label={`Context${contextInputs.length > 0 ? ` · ${contextInputs.length}` : ''}`}
badges={contextBadges}
emptyLabel="No workflow context"
badgeClassName="border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/40 dark:text-amber-300"
maxVisibleBadges={3}
/>
<NodeSummaryRow
label={
inputPorts.length > 0
? `Inputs · ${requiredInputCount}${alternativeInputCount > 0 ? ` + ${alternativeInputCount} alt` : ''}`
: 'Inputs'
}
badges={inputBadges}
emptyLabel="No upstream sockets"
badgeClassName="border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-900/40 dark:text-sky-300"
maxVisibleBadges={5}
/>
<NodeSummaryRow
label={`Variables${hasExplicitInspectorVariables ? ` · ${data.editableFieldLabels?.length ?? 0}` : hasDynamicVariables ? ' · dynamic' : ''}`}
badges={variableBadges}
emptyLabel={data.variableSummaryText ?? 'Connection-driven'}
badgeClassName="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-900/40 dark:text-violet-300"
maxVisibleBadges={2}
/>
<NodeSummaryRow
label={`Outputs${outputPorts.length > 0 ? ` · ${outputPorts.length}` : ''}`}
badges={outputBadges}
emptyLabel="Terminal node"
badgeClassName="border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/40 dark:text-emerald-300"
maxVisibleBadges={4}
/>
</div>
<div className="mt-auto pt-2">
<p className="line-clamp-2 text-[10px] text-content-muted">
{(inputPorts.length > 0 || contextInputs.length > 0
? data.socketRequirementDescription
: data.authoringPatternDescription) ??
(inputPorts.length > 0
? `Canvas requires ${inputPorts.length} upstream ${inputPorts.length === 1 ? 'connection' : 'connections'}.`
: contextInputs.length > 0
? 'Workflow context supplies the record for this node.'
: 'This node can start without upstream graph inputs.')}
</p>
</div>
</div>
)
}
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-green-600"
selected={selected}
/>
)
}
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-blue-600"
selected={selected}
/>
)
}
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-sky-600"
selected={selected}
/>
)
}
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
const params = data.params ?? {}
return (
<BaseNode
data={{
...data,
description: params.render_engine
? `${params.render_engine} · ${params.samples ?? 256} samples`
: data.description,
}}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-orange-600"
selected={selected}
/>
)
}
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
const params = data.params ?? {}
return (
<BaseNode
data={{
...data,
description: params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description,
}}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-orange-600"
selected={selected}
/>
)
}
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
data={data}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-slate-600"
selected={selected}
/>
)
}
export const workflowCanvasNodeTypes: NodeTypes = {
inputNode: InputNode as never,
convertNode: ConvertNode as never,
processNode: ProcessNode as never,
renderNode: RenderNode as never,
renderFramesNode: RenderFramesNode as never,
outputNode: OutputNode as never,
}
@@ -3,6 +3,10 @@ import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, T
import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows'
import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation'
import {
getWorkflowValidationSummaryToneClassName,
type WorkflowValidationSummaryItem,
} from './workflowValidationPresentation'
import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController'
import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions'
@@ -11,6 +15,16 @@ type WorkflowExecutionModeOption = {
label: string
}
function getValidationCalloutLabel(
hasValidationErrors: boolean,
validationSummary: WorkflowValidationSummaryItem[],
) {
if (validationSummary.length === 0) return null
return hasValidationErrors
? 'Validation blocks save and runtime actions until the draft issues are cleared.'
: 'Validation passed with watch items. Preflight before dispatching to confirm runtime readiness.'
}
function ToolbarBadge({
children,
className = '',
@@ -22,7 +36,7 @@ function ToolbarBadge({
}) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] font-medium ${className}`}
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0 text-[11px] font-medium leading-5 ${className}`}
title={title}
>
{children}
@@ -38,7 +52,7 @@ function ToolbarField({
children: ReactNode
}) {
return (
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary">
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-secondary">
<span className="whitespace-nowrap font-medium">{label}</span>
{children}
</label>
@@ -64,7 +78,7 @@ function ToolbarActionButton({
onClick={onClick}
disabled={disabled}
title={title}
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium disabled:opacity-50 ${
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-medium disabled:opacity-50 ${
tone === 'primary'
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-border-default text-content hover:bg-surface-hover'
@@ -106,10 +120,12 @@ interface WorkflowCanvasToolbarProps {
canPreflight: boolean
canDispatch: boolean
hasValidationErrors: boolean
validationSummary?: WorkflowValidationSummaryItem[]
isPreflightPending: boolean
isDispatchPending: boolean
isContextOptionsLoading: boolean
isSaving: boolean
canSave?: boolean
rollbackPendingOutputTypeId?: string | null
preflightState: 'ready' | 'required' | 'stale' | 'blocked'
authoringActions: WorkflowAuthoringActions
@@ -155,10 +171,12 @@ export function WorkflowCanvasToolbar({
canPreflight,
canDispatch,
hasValidationErrors,
validationSummary = [],
isPreflightPending,
isDispatchPending,
isContextOptionsLoading,
isSaving,
canSave = true,
rollbackPendingOutputTypeId,
preflightState,
authoringActions,
@@ -188,13 +206,18 @@ export function WorkflowCanvasToolbar({
const showSplitFamilyBadges = authoringFamilyLabel !== graphFamilyLabel || authoringFamilyClassName !== graphFamilyClassName
const selectedEdgeLabel = selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'
const hasRolloutControls = linkedOutputTypes.length > 0
const visibleValidationSummary = validationSummary.slice(0, 3)
const hiddenValidationSummaryCount = Math.max(validationSummary.length - visibleValidationSummary.length, 0)
const validationCalloutLabel = getValidationCalloutLabel(hasValidationErrors, validationSummary)
const linkedOutputTypeLabel = `${linkedOutputTypeCount} linked output type${linkedOutputTypeCount === 1 ? '' : 's'}`
const rolloutStatusTitle = `${rolloutSummary}${validationCalloutLabel ? ` ${validationCalloutLabel}` : ''}`
return (
<div className="border-b border-border-default bg-surface px-3 py-2">
<div className="border-b border-border-default bg-surface px-3 py-1.5">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-start justify-between gap-1.5">
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ToolbarBadge className="bg-surface-hover/60 text-content-secondary">
<GitBranch size={13} />
Workflow Canvas
@@ -222,27 +245,63 @@ export function WorkflowCanvasToolbar({
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}
title={rolloutSummary}
>
{rolloutBadgeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}
title={rolloutStatusTitle}
>
{rolloutStatusLabel}
</span>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}
title={executionModeHint}
>
{preflightBadgeLabel}
</span>
<ToolbarBadge className="text-content-secondary" title={rolloutSummary}>
{linkedOutputTypeLabel}
</ToolbarBadge>
{visibleValidationSummary.map(item => (
<span
key={`${item.severity}:${item.kind}`}
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${getWorkflowValidationSummaryToneClassName(item.severity)}`}
title={`${item.label}: ${item.count}`}
>
{item.label}: {item.count}
</span>
))}
{hiddenValidationSummaryCount > 0 && (
<ToolbarBadge
className="text-content-muted"
title={`${hiddenValidationSummaryCount} additional validation category${hiddenValidationSummaryCount === 1 ? '' : 'ies'}`}
>
+{hiddenValidationSummaryCount} more
</ToolbarBadge>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-content-muted">
<p className="text-xs text-content-muted">
{linkedOutputTypeCount} linked output type{linkedOutputTypeCount === 1 ? '' : 's'} · {rolloutSummary}
</p>
<span className="hidden text-content-muted lg:inline">|</span>
<span className="text-xs text-content-muted">{executionModeHint}</span>
{(validationCalloutLabel || blueprintDescription) && (
<div className="flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
{validationCalloutLabel && (
<ToolbarBadge className="text-content-muted" title={validationCalloutLabel}>
{hasValidationErrors ? 'Validation blocked' : 'Validation watch'}
</ToolbarBadge>
)}
{blueprintDescription && (
<ToolbarBadge className="max-w-[22rem] text-content-muted" title={blueprintDescription}>
<span className="truncate">{blueprintDescription}</span>
</ToolbarBadge>
)}
</div>
{blueprintDescription && <p className="text-[11px] text-content-muted">{blueprintDescription}</p>}
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 self-start">
<div className="flex flex-wrap items-center justify-end gap-1 self-start">
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-default bg-surface-hover/35 p-0.5">
<ToolbarActionButton
onClick={authoringActions.openNodeMenu}
disabled={!authoringActions.openNodeMenu}
@@ -267,6 +326,9 @@ export function WorkflowCanvasToolbar({
<Trash2 size={14} />
{selectedEdgeLabel}
</ToolbarActionButton>
</div>
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-default bg-surface-hover/35 p-0.5">
<ToolbarActionButton
onClick={onPreflight}
disabled={!canPreflight || isPreflightPending || hasValidationErrors}
@@ -285,7 +347,8 @@ export function WorkflowCanvasToolbar({
</ToolbarActionButton>
<ToolbarActionButton
onClick={onSave}
disabled={isSaving || hasValidationErrors}
disabled={isSaving || hasValidationErrors || !canSave}
title={!canSave ? 'Admin access required to save workflows' : undefined}
tone="primary"
>
<Save size={14} />
@@ -293,9 +356,10 @@ export function WorkflowCanvasToolbar({
</ToolbarActionButton>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-1.5 rounded-xl border border-border-default bg-surface-hover/20 px-2.5 py-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{dispatchContextKind === 'order_line' ? (
<ToolbarField label={dispatchContextLabel}>
<select
@@ -314,7 +378,7 @@ export function WorkflowCanvasToolbar({
<optgroup key={group.orderId} label={group.orderLabel}>
{group.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
{option.isRenderable ? option.label : `${option.label} (blocked)`}
</option>
))}
</optgroup>
@@ -349,7 +413,7 @@ export function WorkflowCanvasToolbar({
</ToolbarField>
{dispatchContextSummary && (
<ToolbarBadge
className="max-w-[28rem] bg-surface-hover/60 text-content-secondary"
className="max-w-[28rem] bg-surface text-content-secondary"
title={dispatchContextMeta ? `${dispatchContextSummary} · ${dispatchContextMeta}` : dispatchContextSummary}
>
<span className="font-medium text-content">{dispatchContextLabel}</span>
@@ -359,27 +423,27 @@ export function WorkflowCanvasToolbar({
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<div className="flex flex-wrap items-center gap-1 text-[11px] text-content-muted">
<ToolbarBadge
className="text-content-muted"
title={blueprintDescription ?? 'Right-click anywhere on the canvas to open the searchable node picker.'}
title="Right-click anywhere on the canvas to open the searchable node picker."
>
<MousePointer2 size={11} />
Right-click to add
Add nodes
</ToolbarBadge>
<ToolbarBadge
className="text-content-muted"
title="Select an edge and press Delete, or use right-click / double-click to remove it."
>
<Trash2 size={11} />
Delete removes connections
Delete edges
</ToolbarBadge>
</div>
</div>
{hasRolloutControls && (
<details className="rounded-xl border border-border-default bg-surface-hover/40">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-2">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-1.5">
<div className="flex min-w-0 flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-content-muted">
Rollout Controls
@@ -46,8 +46,11 @@ export function WorkflowListSidebar({
}: WorkflowListSidebarProps) {
const workflowCount = sections.reduce((count, section) => count + section.items.length, 0)
const formatLinkedOutputTypes = (count: number) =>
`${count} output type${count === 1 ? '' : 's'}`
return (
<aside className="flex w-56 flex-shrink-0 flex-col border-r border-border-default bg-surface">
<aside className="flex w-52 flex-shrink-0 flex-col border-r border-border-default bg-surface xl:w-56">
<div className="flex items-center justify-between border-b border-border-default p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
<GitBranch size={16} />
@@ -91,73 +94,76 @@ export function WorkflowListSidebar({
{section.items.map(item => (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => onSelectWorkflow(item.id)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectWorkflow(item.id)
}
}}
className={`group w-full rounded-lg border px-3 py-2.5 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-accent ${
className={`group relative w-full rounded-xl border transition-colors ${
selectedId === item.id
? 'border-accent/30 bg-accent-light'
: 'border-transparent hover:bg-surface-hover'
}`}
>
<div className="flex items-start justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
{item.isActive && (
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-green-500" title="Active" />
)}
<p className="truncate text-sm font-medium text-content">{item.name}</p>
</div>
<button
type="button"
onClick={event => {
event.stopPropagation()
onDeleteWorkflow(item.id, item.name)
}}
className="flex-shrink-0 rounded p-0.5 text-content-muted opacity-0 hover:bg-red-100 hover:text-red-600 group-hover:opacity-100"
title="Delete"
onClick={() => onSelectWorkflow(item.id)}
aria-pressed={selectedId === item.id}
className="w-full rounded-xl px-3 py-2.5 pr-9 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Trash2 size={12} />
</button>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span
className={`mt-0.5 h-2 w-2 flex-shrink-0 rounded-full ${
item.isActive ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'
}`}
title={item.isActive ? 'Active' : 'Inactive'}
/>
<p className="truncate text-sm font-medium text-content">{item.name}</p>
</div>
<span className={`mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.presetClassName}`}>
<p className="mt-1 truncate text-[11px] text-content-muted">
{formatLinkedOutputTypes(item.linkedOutputTypeCount)}
{' • '}
{item.rolloutStatusLabel}
{!item.isActive ? ' • inactive' : ''}
</p>
</div>
<span className={`inline-flex flex-shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.rolloutBadgeClassName}`}>
{item.rolloutBadgeLabel}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.presetClassName}`}>
{item.presetLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.familyClassName}`}>
{item.familyLabel}
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${item.executionModeClassName}`}>
{item.executionModeLabel}
</span>
{item.blueprintLabel && (
<span className="ml-1 mt-1 inline-block rounded-full bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
<span className="inline-flex items-center rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{item.blueprintLabel}
</span>
)}
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.executionModeClassName}`}>
{item.executionModeLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutBadgeClassName}`}>
{item.rolloutBadgeLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutStatusClassName}`}>
{item.rolloutStatusLabel}
</span>
<p className="mt-1 text-xs text-content-muted">
{item.linkedOutputTypeCount} linked output type{item.linkedOutputTypeCount === 1 ? '' : 's'}.
</p>
<p className="mt-1 text-xs text-content-muted">
{item.rolloutSummary}
</p>
</div>
{selectedId === item.id && (
<div className="mt-2 space-y-1">
<p className="text-xs text-content-muted">{item.rolloutSummary}</p>
{item.isReference && (
<p className="mt-1 text-xs text-content-muted">
<p className="text-xs text-content-muted">
Canonical reference workflow for parity work.
</p>
)}
{!item.isActive && (
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
</div>
)}
</button>
{selectedId === item.id && (
<button
type="button"
onClick={() => onDeleteWorkflow(item.id, item.name)}
className="absolute right-2 top-2 flex-shrink-0 rounded p-0.5 text-content-muted opacity-0 transition-opacity hover:bg-red-100 hover:text-red-600 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500 group-hover:opacity-100"
title={`Delete workflow ${item.name}`}
aria-label={`Delete workflow ${item.name}`}
>
<Trash2 size={12} />
</button>
)}
</div>
))}
@@ -1,8 +1,9 @@
import { Boxes, Wand2 } from 'lucide-react'
import { Boxes } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import { WorkflowBundleCatalog } from './WorkflowBundleCatalog'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
type WorkflowModuleBundlePanelProps = {
@@ -20,64 +21,16 @@ export function WorkflowModuleBundlePanel({
}: WorkflowModuleBundlePanelProps) {
const { moduleBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (moduleBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Boxes size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Production Modules
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert reusable subgraphs for core production stages instead of assembling every node from scratch.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{moduleBundles.length} bundles
</span>
</div>
<div className="space-y-2">
{moduleBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertModule ? (
<button
type="button"
onClick={() => onInsertModule(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
<WorkflowBundleCatalog
bundles={moduleBundles}
emptyLabel="No workflow steps"
icon={Boxes}
title="Stage Modules"
description="Insert reusable stage bundles without rebuilding the whole path."
countLabel="bundles"
insertLabel="Insert module"
onInsert={onInsertModule}
/>
)
}
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, Plus, Search } from 'lucide-react'
import { ArrowRight, Plus } from 'lucide-react'
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
AUTHORING_STAGE_DESCRIPTIONS,
AUTHORING_STAGE_LABELS,
@@ -18,6 +18,7 @@ import {
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
@@ -29,6 +30,94 @@ import {
getAvailableFamilyFilters,
} from './workflowNodeCatalog'
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
import {
CATEGORY_FILTER_LABELS,
CATEGORY_FILTERS,
type FilterPillOption,
type WorkflowNodeCategoryFilter,
WorkflowNodeCatalogFilterControls,
} from './WorkflowNodeCatalogFilterControls'
import { WorkflowNodeCatalogEmptyState } from './WorkflowNodeCatalogEmptyState'
import { WorkflowNodeCatalogQuickInsert } from './WorkflowNodeCatalogQuickInsert'
import {
getWorkflowNodeContractPresentation,
getWorkflowNodeContractSignals,
getWorkflowNodeContractSummary,
} from './workflowNodeContracts'
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
function ContractSummaryMetric({
label,
value,
title,
}: {
label: string
value: string
title?: string
}) {
return (
<div
className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1"
title={title}
>
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
<p className="mt-0.5 text-[11px] font-medium text-content">{value}</p>
</div>
)
}
function RuntimeCoverageBadges({
runtimeCounts,
size = 'sm',
}: {
runtimeCounts: Partial<Record<WorkflowNodeLibraryGroup, number>>
size?: 'sm' | 'xs'
}) {
return (
<>
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = runtimeCounts[group] ?? 0
if (count === 0) return null
const label = size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : NODE_KIND_FILTER_LABELS[group]
return (
<span
key={group}
className={
size === 'xs'
? `rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
}
title={size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : undefined}
>
<span>{label}</span>
<span>{count}</span>
</span>
)
})}
</>
)
}
function StageCoverageBadges({
stages,
}: {
stages: WorkflowAuthoringStage[]
}) {
return (
<>
{stages.map(stage => (
<span
key={stage}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
>
{AUTHORING_STAGE_LABELS[stage]}
</span>
))}
</>
)
}
type WorkflowNodeCatalogBrowserProps = {
definitions: WorkflowNodeDefinition[]
@@ -42,76 +131,6 @@ type WorkflowNodeCatalogBrowserProps = {
autoFocusSearch?: boolean
}
type WorkflowNodeCategoryFilter = 'all' | StepCategory
const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = ['all', 'input', 'processing', 'rendering', 'output']
const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
all: 'All Categories',
input: 'Input',
processing: 'Processing',
rendering: 'Rendering',
output: 'Output',
}
type FilterPillOption<T extends string> = {
value: T
label: string
}
function readContractList(contract: Record<string, unknown>, key: string) {
const value = contract[key]
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : []
}
function readContractContext(contract: Record<string, unknown>) {
return typeof contract.context === 'string' ? contract.context : null
}
function formatContractLabel(value: string) {
return value
.split(/[_\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function FilterPillGroup<T extends string>({
title,
options,
activeValue,
onChange,
}: {
title: string
options: FilterPillOption<T>[]
activeValue: T
onChange: (value: T) => void
}) {
return (
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{title}
</p>
<div className="flex flex-wrap gap-2">
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
activeValue === option.value
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
}
export function WorkflowNodeCatalogBrowser({
definitions,
graphFamily,
@@ -179,6 +198,7 @@ export function WorkflowNodeCatalogBrowser({
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
const moduleFilters = catalogModel.moduleFilters
const stageSections = catalogModel.stageSections
useEffect(() => {
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
@@ -189,6 +209,7 @@ export function WorkflowNodeCatalogBrowser({
const familySections = catalogModel.familySections
const firstVisibleDefinition = visibleDefinitions[0]
const totalModuleCount = moduleFilters.length
const shouldExpandStageCoverage = variant === 'panel' && query.trim().length === 0 && moduleFilter === 'all'
const quickInsertDefinitions = useMemo(() => {
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
@@ -219,190 +240,98 @@ export function WorkflowNodeCatalogBrowser({
return (
<div className="space-y-3">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{visibleDefinitions.length} nodes
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{totalModuleCount} modules
</span>
{graphFamily !== 'mixed' && (
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
{FAMILY_FILTER_LABELS[graphFamily]}
</span>
)}
</div>
{(moduleFilter !== 'all' || moduleQuery) && (
<button
type="button"
onClick={() => {
setModuleFilter('all')
setModuleQuery('')
}}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
</button>
)}
</div>
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={query}
autoFocus={autoFocusSearch}
onChange={event => setQuery(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && firstVisibleDefinition && onSelectStep) {
event.preventDefault()
<WorkflowNodeCatalogFilterControls
visibleDefinitionCount={visibleDefinitions.length}
totalModuleCount={totalModuleCount}
graphFamily={graphFamily}
familyFilterOptions={familyFilterOptions}
runtimeFilterOptions={runtimeFilterOptions}
categoryFilterOptions={categoryFilterOptions}
familyFilter={familyFilter}
onFamilyFilterChange={setFamilyFilter}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
categoryFilter={categoryFilter}
onCategoryFilterChange={setCategoryFilter}
query={query}
onQueryChange={setQuery}
onQueryEnter={() => {
if (firstVisibleDefinition && onSelectStep) {
onSelectStep(firstVisibleDefinition.step)
}
}}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
moduleFilters={moduleFilters}
moduleFilter={moduleFilter}
onModuleFilterChange={setModuleFilter}
moduleQuery={moduleQuery}
onModuleQueryChange={setModuleQuery}
onClearModuleScope={() => {
setModuleFilter('all')
setModuleQuery('')
}}
/>
</div>
<div className="space-y-2">
<FilterPillGroup
title="Runtime"
options={runtimeFilterOptions}
activeValue={kindFilter}
onChange={setKindFilter}
/>
<FilterPillGroup
title="Family"
options={familyFilterOptions}
activeValue={familyFilter}
onChange={setFamilyFilter}
/>
<FilterPillGroup
title="Category"
options={categoryFilterOptions}
activeValue={categoryFilter}
onChange={setCategoryFilter}
/>
{moduleFilters.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Modules
</p>
<span className="text-[11px] text-content-muted">
family + runtime scoped
</span>
</div>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={moduleQuery}
onChange={event => setModuleQuery(event.target.value)}
placeholder="Search modules"
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setModuleFilter('all')}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === 'all'
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
All Modules
</button>
{moduleFilters.map(module => (
<button
key={module.namespace}
type="button"
onClick={() => setModuleFilter(module.namespace)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === module.namespace
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
>
{module.label}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = catalogModel.runtimeCounts[group] ?? 0
if (count === 0) return null
return (
<span
key={group}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
title={NODE_LIBRARY_GROUP_LABELS[group]}
>
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
<span>{count}</span>
</span>
)
})}
<RuntimeCoverageBadges runtimeCounts={catalogModel.runtimeCounts} />
</div>
{quickInsertDefinitions.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
<div className="flex items-center justify-between gap-2">
{stageSections.length > 0 && (
<details
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
open={shouldExpandStageCoverage}
>
<summary className="flex cursor-pointer list-none flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Insert
Stage Coverage
</p>
<p className="mt-1 text-xs text-content-muted">
Use Quick Insert for the first canonical steps, then use stage coverage to inspect which modules can fill the rest of the production path.
</p>
<p className="mt-1 text-xs text-content-muted">{quickInsertTitle}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{quickInsertDefinitions.length} picks
{stageSections.length} active stages
</span>
</summary>
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{stageSections.map(section => (
<div
key={`stage-summary-${section.stage}`}
className="rounded-lg border border-border-default bg-surface px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[section.stage]}`}>
{AUTHORING_STAGE_LABELS[section.stage]}
</span>
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
{section.modules.length} modules
</span>
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
{section.definitions.length} nodes
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{quickInsertDefinitions.map(definition => (
<button
key={`quick-${definition.step}`}
type="button"
onClick={() => onSelectStep?.(definition.step)}
disabled={!onSelectStep}
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
title={definition.description}
>
{definition.label}
</button>
<p className="mt-2 text-xs text-content-muted">
{AUTHORING_STAGE_DESCRIPTIONS[section.stage]}
</p>
</div>
))}
</div>
</div>
</details>
)}
<WorkflowNodeCatalogQuickInsert
title={quickInsertTitle}
definitions={quickInsertDefinitions}
onSelectStep={onSelectStep}
/>
{visibleDefinitions.length === 0 && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No matching nodes</p>
<p className="mt-1 text-xs text-content-muted">
Adjust search, runtime, family, or module filters to bring nodes back into view.
</p>
{onEmptyAction && (
<button
type="button"
onClick={onEmptyAction}
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
>
{emptyActionLabel}
</button>
)}
</div>
<WorkflowNodeCatalogEmptyState
onResetFilters={onEmptyAction}
resetLabel={emptyActionLabel}
/>
)}
<div className="space-y-3">
@@ -433,18 +362,7 @@ export function WorkflowNodeCatalogBrowser({
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = familySection.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${familySection.family}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
<RuntimeCoverageBadges runtimeCounts={familySection.runtimeCounts} size="xs" />
</div>
</div>
@@ -463,29 +381,10 @@ export function WorkflowNodeCatalogBrowser({
<span className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
{moduleGroup.namespace}
</span>
{moduleGroup.stages.map(stage => (
<span
key={`${moduleGroup.namespace}-${stage}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
>
{AUTHORING_STAGE_LABELS[stage]}
</span>
))}
<StageCoverageBadges stages={moduleGroup.stages} />
</div>
<div className="flex flex-wrap gap-1.5">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = moduleGroup.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${moduleGroup.namespace}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
<RuntimeCoverageBadges runtimeCounts={moduleGroup.runtimeCounts} size="xs" />
</div>
</div>
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
@@ -529,10 +428,20 @@ export function WorkflowNodeCatalogBrowser({
<div className="space-y-1">
{categoryDefinitions.map(definition => {
const family = getDefinitionFamily(definition)
const requiredInputs = readContractList(definition.input_contract, 'requires')
const providedOutputs = readContractList(definition.output_contract, 'provides')
const inputContext = readContractContext(definition.input_contract)
const outputContext = readContractContext(definition.output_contract)
const contractSummary = getWorkflowNodeContractSummary(definition)
const contractPresentation =
getWorkflowNodeContractPresentation(contractSummary)
const {
inputMetric,
variableMetric,
outputMetric,
socketRequirementDescription,
variableSummaryText,
} = contractPresentation
const contractSignals = getWorkflowNodeContractSignals(
definition,
contractSummary,
)
const isActionable = Boolean(onSelectStep)
return (
@@ -609,49 +518,46 @@ export function WorkflowNodeCatalogBrowser({
{definition.description}
</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{inputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
In {formatContractLabel(inputContext)}
</span>
)}
{outputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
Out {formatContractLabel(outputContext)}
</span>
)}
{requiredInputs.slice(0, 2).map(input => (
<span
key={`${definition.step}-requires-${input}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Requires {formatContractLabel(input)}
</span>
))}
{providedOutputs.slice(0, 2).map(output => (
<span
key={`${definition.step}-provides-${output}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Provides {formatContractLabel(output)}
</span>
))}
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-consumes-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Consumes {formatContractLabel(artifact)}
</span>
))}
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-produces-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Produces {formatContractLabel(artifact)}
</span>
))}
<WorkflowNodeContractSignalList
signals={contractSignals}
className="mt-2 flex flex-wrap gap-1.5 text-[10px]"
/>
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
<ContractSummaryMetric
label="Inputs"
value={inputMetric.value}
title={inputMetric.title}
/>
<ContractSummaryMetric
label="Variables"
value={variableMetric.value}
title={variableMetric.title}
/>
<ContractSummaryMetric
label="Outputs"
value={outputMetric.value}
title={outputMetric.title}
/>
</div>
<div className="mt-2 grid gap-1.5 lg:grid-cols-2">
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
Wiring
</p>
<p className="mt-1 text-[11px] text-content-secondary">
{socketRequirementDescription}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
Variables
</p>
<p className="mt-1 text-[11px] text-content-muted">
{variableSummaryText}
</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,27 @@
type WorkflowNodeCatalogEmptyStateProps = {
onResetFilters?: () => void
resetLabel?: string
}
export function WorkflowNodeCatalogEmptyState({
onResetFilters,
resetLabel = 'Clear Filters',
}: WorkflowNodeCatalogEmptyStateProps) {
return (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No matching nodes</p>
<p className="mt-1 text-xs text-content-muted">
Adjust search or filter scope, or step back to reference paths and stage modules for guided assembly.
</p>
{onResetFilters && (
<button
type="button"
onClick={onResetFilters}
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
>
{resetLabel}
</button>
)}
</div>
)
}
@@ -0,0 +1,263 @@
import { Search } from 'lucide-react'
import type { StepCategory } from '../../api/workflows'
import {
FAMILY_FILTER_DESCRIPTIONS,
FAMILY_FILTER_LABELS,
FAMILY_FILTER_STYLES,
NODE_KIND_FILTER_LABELS,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
} from './workflowNodeLibrary'
export type WorkflowNodeCategoryFilter = 'all' | StepCategory
export const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = [
'all',
'input',
'processing',
'rendering',
'output',
]
export const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
all: 'All Categories',
input: 'Input',
processing: 'Processing',
rendering: 'Rendering',
output: 'Output',
}
export type FilterPillOption<T extends string> = {
value: T
label: string
}
function FilterPillGroup<T extends string>({
title,
options,
activeValue,
onChange,
helper,
}: {
title: string
options: FilterPillOption<T>[]
activeValue: T
onChange: (value: T) => void
helper?: string
}) {
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{title}
</p>
{helper && <p className="text-[11px] text-content-muted">{helper}</p>}
</div>
<div className="flex flex-wrap gap-2">
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
activeValue === option.value
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
}
type ModuleFilter = {
namespace: string
label: string
count: number
stages: string[]
}
type WorkflowNodeCatalogFilterControlsProps = {
visibleDefinitionCount: number
totalModuleCount: number
graphFamily: WorkflowGraphFamily
familyFilterOptions: FilterPillOption<WorkflowNodeFamilyFilter>[]
runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[]
categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[]
familyFilter: WorkflowNodeFamilyFilter
onFamilyFilterChange: (value: WorkflowNodeFamilyFilter) => void
kindFilter: WorkflowNodeKindFilter
onKindFilterChange: (value: WorkflowNodeKindFilter) => void
categoryFilter: WorkflowNodeCategoryFilter
onCategoryFilterChange: (value: WorkflowNodeCategoryFilter) => void
query: string
onQueryChange: (value: string) => void
onQueryEnter: () => void
searchPlaceholder: string
autoFocusSearch: boolean
moduleFilters: ModuleFilter[]
moduleFilter: string
onModuleFilterChange: (value: string) => void
moduleQuery: string
onModuleQueryChange: (value: string) => void
onClearModuleScope: () => void
}
export function WorkflowNodeCatalogFilterControls({
visibleDefinitionCount,
totalModuleCount,
graphFamily,
familyFilterOptions,
runtimeFilterOptions,
categoryFilterOptions,
familyFilter,
onFamilyFilterChange,
kindFilter,
onKindFilterChange,
categoryFilter,
onCategoryFilterChange,
query,
onQueryChange,
onQueryEnter,
searchPlaceholder,
autoFocusSearch,
moduleFilters,
moduleFilter,
onModuleFilterChange,
moduleQuery,
onModuleQueryChange,
onClearModuleScope,
}: WorkflowNodeCatalogFilterControlsProps) {
const familyDescription =
familyFilter === 'all'
? 'Show all workflow families in one catalog view.'
: familyFilter === 'cad_file'
? FAMILY_FILTER_DESCRIPTIONS.cad_file
: FAMILY_FILTER_DESCRIPTIONS.order_line
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{visibleDefinitionCount} nodes
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{totalModuleCount} modules
</span>
{graphFamily !== 'mixed' && (
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
{FAMILY_FILTER_LABELS[graphFamily]}
</span>
)}
</div>
{(moduleFilter !== 'all' || moduleQuery) && (
<button
type="button"
onClick={onClearModuleScope}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
</button>
)}
</div>
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={query}
autoFocus={autoFocusSearch}
onChange={event => onQueryChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
onQueryEnter()
}
}}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<div className="space-y-2">
<FilterPillGroup
title="Runtime"
options={runtimeFilterOptions}
activeValue={kindFilter}
onChange={onKindFilterChange}
/>
<FilterPillGroup
title="Family"
options={familyFilterOptions}
activeValue={familyFilter}
onChange={onFamilyFilterChange}
helper={familyDescription}
/>
<FilterPillGroup
title="Category"
options={categoryFilterOptions}
activeValue={categoryFilter}
onChange={onCategoryFilterChange}
/>
</div>
{moduleFilters.length > 0 && (
<div className="space-y-2 rounded-xl border border-border-default bg-surface-hover/25 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Module Scope
</p>
<span className="text-[11px] text-content-muted">runtime + family scoped</span>
</div>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={moduleQuery}
onChange={event => onModuleQueryChange(event.target.value)}
placeholder="Search modules"
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onModuleFilterChange('all')}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === 'all'
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
All Modules
</button>
{moduleFilters.map(module => (
<button
key={module.namespace}
type="button"
onClick={() => onModuleFilterChange(module.namespace)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === module.namespace
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={`${module.label} · ${module.stages.join(' / ')}`}
>
{module.label}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,47 @@
import type { WorkflowNodeDefinition } from '../../api/workflows'
type WorkflowNodeCatalogQuickInsertProps = {
title: string
definitions: WorkflowNodeDefinition[]
onSelectStep?: (step: string) => void
}
export function WorkflowNodeCatalogQuickInsert({
title,
definitions,
onSelectStep,
}: WorkflowNodeCatalogQuickInsertProps) {
if (definitions.length === 0) return null
return (
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Insert
</p>
<p className="mt-1 text-xs text-content-muted">
Start from <span className="font-medium text-content">{title}</span> before drilling into individual nodes.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{definitions.length} starters
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{definitions.map(definition => (
<button
key={`quick-${definition.step}`}
type="button"
onClick={() => onSelectStep?.(definition.step)}
disabled={!onSelectStep}
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
title={definition.description}
>
{definition.label}
</button>
))}
</div>
</div>
)
}
@@ -1,6 +1,16 @@
import { formatContractValue } from './workflowGraphDraft'
import {
formatContractAlternativeGroup,
formatContractValue,
getWorkflowNodeContractPresentation,
getWorkflowNodeContractSignals,
type WorkflowNodeContractSummary,
type WorkflowNodeValidationWatchpoint,
} from './workflowNodeContracts'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
interface WorkflowNodeContractCardProps {
nodeDefinition?: WorkflowNodeDefinition
moduleLabel: string
moduleKey: string
familyLabel: string
@@ -9,13 +19,8 @@ interface WorkflowNodeContractCardProps {
runtimeClassName: string
legacyCompatible: boolean
legacySource?: string | null
inputContextLabel?: string | null
outputContextLabel?: string | null
requiredInputs: string[]
requiredAnyInputs: string[][]
consumedArtifacts: string[]
providedOutputs: string[]
producedArtifacts: string[]
contract: WorkflowNodeContractSummary
validationWatchpoints?: WorkflowNodeValidationWatchpoint[]
}
function formatContractRole(role: string): string {
@@ -57,14 +62,33 @@ function ContractAlternativeGroups({
key={group.join('|')}
className="rounded-lg border border-dashed border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary"
>
Any of: {group.map(formatContractRole).join(' / ')}
{formatContractAlternativeGroup(group)}
</div>
))}
</div>
)
}
function ContractMetricCard({
label,
value,
helper,
}: {
label: string
value: string
helper: string
}) {
return (
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-content">{value}</p>
<p className="mt-1 text-xs text-content-muted">{helper}</p>
</div>
)
}
export function WorkflowNodeContractCard({
nodeDefinition,
moduleLabel,
moduleKey,
familyLabel,
@@ -73,14 +97,52 @@ export function WorkflowNodeContractCard({
runtimeClassName,
legacyCompatible,
legacySource,
contract,
validationWatchpoints = [],
}: WorkflowNodeContractCardProps) {
const {
inputContextLabel,
outputContextLabel,
requiredInputs,
requiredAnyInputs,
consumedArtifacts,
providedOutputs,
producedArtifacts,
}: WorkflowNodeContractCardProps) {
contextInputs = [],
requiredInputs = [],
requiredAnyInputs = [],
consumedArtifacts = [],
providedOutputs = [],
producedArtifacts = [],
authoringPatternLabel = 'Hybrid',
authoringPatternDescription = 'Wire required upstream artifacts on the canvas and configure local variables in the inspector.',
} = contract
const contractPresentation = getWorkflowNodeContractPresentation(contract)
const {
inputMetric,
variableMetric,
outputMetric,
socketRequirementDescription,
variableSummaryText,
noSettingsDescription,
socketCount,
variableCount: inspectorVariableCount,
declaredOutputCount: providedRoleCount,
} = contractPresentation
const operationalSignals = getWorkflowNodeContractSignals(
nodeDefinition,
contract,
{
contextInputs: 1,
requiredInputs: 1,
providedOutputs: 1,
consumedArtifacts: 1,
producedArtifacts: 1,
watchpoints: 0,
},
).filter(signal => signal.id !== 'authoring-pattern')
const watchpointSignals = validationWatchpoints.map(watchpoint => ({
id: `watch:${watchpoint.kind}`,
label: `Watch ${watchpoint.label}`,
title: watchpoint.reason,
tone: 'watch' as const,
}))
return (
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
<div className="flex items-start justify-between gap-3">
@@ -100,6 +162,9 @@ export function WorkflowNodeContractCard({
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${runtimeClassName}`}>
{runtimeLabel}
</span>
<span className="inline-flex items-center rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-secondary">
{authoringPatternLabel}
</span>
{legacyCompatible && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
Legacy Safe
@@ -112,15 +177,58 @@ export function WorkflowNodeContractCard({
)}
</div>
<p className="text-xs text-content-muted">{authoringPatternDescription}</p>
<p className="text-xs text-content-muted">{variableSummaryText}</p>
<div className="grid gap-2 sm:grid-cols-3">
<ContractMetricCard
label="Sockets"
value={socketCount === 0 ? 'Context / none' : inputMetric.value}
helper={socketRequirementDescription}
/>
<ContractMetricCard
label="Variables"
value={inspectorVariableCount === 0 ? 'No local variables' : variableMetric.value}
helper={variableSummaryText}
/>
<ContractMetricCard
label="Outputs"
value={providedRoleCount === 0 ? 'No declared outputs' : outputMetric.value}
helper={
providedOutputs.length > 0
? providedOutputs.map(formatContractRole).join(', ')
: producedArtifacts.length > 0
? producedArtifacts.map(formatContractRole).join(', ')
: 'No declared downstream roles yet.'
}
/>
</div>
{operationalSignals.length > 0 && (
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Operational Signals
</p>
<WorkflowNodeContractSignalList signals={operationalSignals} />
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">Inputs</p>
{inputContextLabel && <p className="text-xs text-content-muted">Context: {inputContextLabel}</p>}
{contextInputs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Context Provided</p>
<ContractRolePills roles={contextInputs} />
</div>
)}
<p className="text-xs text-content-muted">{socketRequirementDescription}</p>
{requiredInputs.length > 0 ? (
<ContractRolePills roles={requiredInputs} />
) : (
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
)}
) : requiredAnyInputs.length === 0 ? (
<p className="text-xs text-content-muted">{noSettingsDescription}</p>
) : null}
{requiredAnyInputs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Alternative Inputs</p>
@@ -133,6 +241,17 @@ export function WorkflowNodeContractCard({
<ContractRolePills roles={consumedArtifacts} />
</div>
)}
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Inspector Variables</p>
{contract.editableFieldLabels.length > 0 ? (
<ContractRolePills roles={contract.editableFieldLabels} />
) : (
<p className="text-xs text-content-muted">{variableSummaryText}</p>
)}
{contract.dynamicVariableHint && (
<p className="text-xs text-content-muted">{contract.dynamicVariableHint}</p>
)}
</div>
</div>
<div className="space-y-2 rounded-lg border border-border-default bg-surface px-3 py-2">
@@ -151,6 +270,18 @@ export function WorkflowNodeContractCard({
)}
</div>
</div>
{watchpointSignals.length > 0 && (
<div className="space-y-2 rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Validation Watchpoints
</p>
<p className="text-xs text-content-muted">
The most likely preflight categories this node can influence while authoring or debugging graph runs.
</p>
<WorkflowNodeContractSignalList signals={watchpointSignals} />
</div>
)}
</div>
)
}
@@ -0,0 +1,32 @@
import type { WorkflowNodeContractSignal } from './workflowNodeContracts'
const SIGNAL_TONE_STYLES: Record<WorkflowNodeContractSignal['tone'], string> = {
default: 'border-border-default bg-surface-hover/80 text-content-secondary',
watch: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300',
}
type WorkflowNodeContractSignalListProps = {
signals: WorkflowNodeContractSignal[]
className?: string
}
export function WorkflowNodeContractSignalList({
signals,
className = 'flex flex-wrap gap-1.5 text-[10px]',
}: WorkflowNodeContractSignalListProps) {
if (signals.length === 0) return null
return (
<div className={className}>
{signals.map(signal => (
<span
key={signal.id}
className={`rounded-full border px-1.5 py-0.5 ${SIGNAL_TONE_STYLES[signal.tone]}`}
title={signal.title}
>
{signal.label}
</span>
))}
</div>
)
}
@@ -1,6 +1,7 @@
import { useMemo, type ChangeEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getCachedOutputTypeContractCatalog } from '../../api/outputTypes'
import { listRenderTemplates } from '../../api/renderTemplates'
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
import {
@@ -12,12 +13,16 @@ import {
isDefinitionAllowedForGraphFamily,
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
import {
TEMPLATE_INPUT_PARAM_PREFIX,
getWorkflowNodeContractPresentation,
formatContractValue,
getWorkflowNodeContractSummary,
getWorkflowNodeInputSocketDescriptors,
getWorkflowNodeValidationWatchpoints,
getWorkflowVariableLabels,
} from './workflowNodeContracts'
import { WorkflowNodeContractCard } from './WorkflowNodeContractCard'
import { formatContractValue } from './workflowGraphDraft'
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
@@ -27,49 +32,6 @@ function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
}, {})
}
function getContractValues(contract: Record<string, unknown> | undefined, key: string): string[] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
}
function getContractAlternativeGroups(contract: Record<string, unknown> | undefined, key: string): string[][] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
return [value as string[]]
}
return value
.filter((entry): entry is string[] => Array.isArray(entry))
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
.filter(group => group.length > 0)
}
function getContractContextLabel(contract: Record<string, unknown> | undefined): string | null {
const value = contract?.context
if (value !== 'cad_file' && value !== 'order_line') return null
return value === 'cad_file' ? 'CAD File' : 'Order Line'
}
function getAlternativeInputGroupsForNode(step: string | undefined, contract: Record<string, unknown> | undefined): string[][] {
const groups = getContractAlternativeGroups(contract, 'requires_any')
if (step === 'output_save' && groups.length === 0) {
return [OUTPUT_SAVE_ALTERNATIVE_INPUTS]
}
if (step === 'notify') {
if (groups.length === 0) {
return [NOTIFY_ALTERNATIVE_INPUTS]
}
return [Array.from(new Set([...groups[0], 'blend_asset'])), ...groups.slice(1)]
}
return groups
}
function parseSelectValue(field: WorkflowNodeFieldDefinition, rawValue: string): unknown {
if (rawValue === '') return ''
const matchedOption = field.options.find(option => String(option.value) === rawValue)
@@ -82,55 +44,32 @@ function clearDynamicTemplateInputParams(params: WorkflowParams): WorkflowParams
)
}
function getVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
return Array.from(
new Set(
fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function formatContractRole(value: string): string {
return formatContractValue(value)
}
function describeRequiredInputs(requiredInputs: string[], requiredAnyInputs: string[][]): string[] {
return [
...requiredInputs.map(role => formatContractRole(role)),
...requiredAnyInputs.map(group => `Any of: ${group.map(formatContractRole).join(' / ')}`),
]
}
type InputSocketDescriptor = {
id: string
label: string
tone: 'required' | 'alternative'
}
function buildInputSocketDescriptors(
requiredInputs: string[],
requiredAnyInputs: string[][],
): InputSocketDescriptor[] {
return [
...requiredInputs.map(role => ({
id: `required:${role}`,
label: formatContractRole(role),
tone: 'required' as const,
})),
...requiredAnyInputs.map(group => ({
id: `alternative:${group.join('|')}`,
label: `Any of: ${group.map(formatContractRole).join(' / ')}`,
tone: 'alternative' as const,
})),
]
}
function formatCountNoun(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`
}
function describeCanvasSocketBreakdown(requiredCount: number, alternativeCount: number): string {
if (requiredCount > 0 && alternativeCount > 0) {
return `${formatCountNoun(requiredCount, 'required socket', 'required sockets')} and ${formatCountNoun(alternativeCount, 'alternative group', 'alternative groups')} are exposed on the canvas.`
}
if (requiredCount > 0) {
return `${formatCountNoun(requiredCount, 'required socket', 'required sockets')} ${requiredCount === 1 ? 'is' : 'are'} exposed on the canvas.`
}
return `${formatCountNoun(alternativeCount, 'alternative group', 'alternative groups')} ${alternativeCount === 1 ? 'is' : 'are'} exposed on the canvas.`
}
function describeTemplateCoverage(
templateNames: string[],
outputTypeNames: string[],
): string {
if (outputTypeNames.length > 0) {
return `${templateNames.join(', ')} (${outputTypeNames.join(', ')})`
}
return templateNames.join(', ')
}
const RENDER_OVERRIDE_CONTROL_STEPS = new Set(['blender_still', 'blender_turntable'])
type WorkflowNodeInspectorProps = {
params: WorkflowParams
onChange: (params: WorkflowParams) => void
@@ -151,6 +90,10 @@ export function WorkflowNodeInspector({
graphFamily,
}: WorkflowNodeInspectorProps) {
const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings)
const isRenderOverrideControlledNode = RENDER_OVERRIDE_CONTROL_STEPS.has(step ?? '')
const isOutputSaveNode = step === 'output_save'
const isNotifyNode = step === 'notify'
const isExportBlendNode = step === 'export_blend'
const selectableNodeDefinitions = useMemo(
() =>
nodeDefinitions.filter(definition =>
@@ -173,6 +116,45 @@ export function WorkflowNodeInspector({
() => renderTemplates.find(template => template.id === selectedTemplateId) ?? null,
[renderTemplates, selectedTemplateId],
)
const activeRenderTemplates = useMemo(
() => renderTemplates.filter(template => template.is_active),
[renderTemplates],
)
const templatesWithWorkflowInputs = useMemo(
() => activeRenderTemplates.filter(template => (template.workflow_input_schema?.length ?? 0) > 0),
[activeRenderTemplates],
)
const automaticTemplateVariableLabels = useMemo(
() =>
Array.from(
new Set(
templatesWithWorkflowInputs.flatMap(template =>
getWorkflowVariableLabels(template.workflow_input_schema ?? []),
),
),
),
[templatesWithWorkflowInputs],
)
const automaticTemplateCoverage = useMemo(() => {
const coverage = new Map<string, { templateNames: string[]; outputTypeNames: string[] }>()
templatesWithWorkflowInputs.forEach(template => {
getWorkflowVariableLabels(template.workflow_input_schema ?? []).forEach(label => {
const entry = coverage.get(label) ?? { templateNames: [], outputTypeNames: [] }
if (!entry.templateNames.includes(template.name)) {
entry.templateNames.push(template.name)
}
;(template.output_type_names ?? []).forEach(outputTypeName => {
if (!entry.outputTypeNames.includes(outputTypeName)) {
entry.outputTypeNames.push(outputTypeName)
}
})
coverage.set(label, entry)
})
})
return Array.from(coverage.entries())
.map(([label, sources]) => ({ label, ...sources }))
.sort((left, right) => left.label.localeCompare(right.label))
}, [templatesWithWorkflowInputs])
const effectiveFields = useMemo(() => {
const baseFields = [...(nodeDefinition?.fields ?? [])]
@@ -251,23 +233,40 @@ export function WorkflowNodeInspector({
}
const fieldsBySection = groupFieldsBySection(effectiveFields)
const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record<string, unknown> | undefined)
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
const requiredAnyInputs = getAlternativeInputGroupsForNode(
step,
nodeDefinition?.input_contract as Record<string, unknown> | undefined,
const contractSummary = getWorkflowNodeContractSummary(nodeDefinition)
const contractCatalog = getCachedOutputTypeContractCatalog()
const renderOverrideFieldKeys = useMemo(() => {
if (!isRenderOverrideControlledNode || !step) {
return new Set<string>()
}
return new Set(
(contractCatalog.parameter_ownership.workflow_node_keys_by_step[step] ?? []).filter(
fieldKey => fieldKey !== 'use_custom_render_settings',
),
)
const alternativeRoleSet = new Set(requiredAnyInputs.flat())
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
.filter(role => !alternativeRoleSet.has(role))
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
const staticVariableLabels = getVariableLabels(nodeDefinition?.fields ?? [])
const dynamicTemplateVariableLabels = getVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
const inputDescriptions = describeRequiredInputs(requiredInputs, requiredAnyInputs)
const inputSocketDescriptors = buildInputSocketDescriptors(requiredInputs, requiredAnyInputs)
const totalVariableCount = staticVariableLabels.length + dynamicTemplateVariableLabels.length
}, [contractCatalog.parameter_ownership.workflow_node_keys_by_step, isRenderOverrideControlledNode, step])
const contextInputs = contractSummary.contextInputs
const staticVariableLabels = contractSummary.editableFieldLabels
const dynamicTemplateVariableLabels = getWorkflowVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
const inputSocketDescriptors = getWorkflowNodeInputSocketDescriptors(contractSummary)
const requiredSocketDescriptors = inputSocketDescriptors.filter(
descriptor => descriptor.kind === 'required',
)
const alternativeSocketDescriptors = inputSocketDescriptors.filter(
descriptor => descriptor.kind === 'alternative',
)
const validationWatchpoints = getWorkflowNodeValidationWatchpoints(nodeDefinition, contractSummary)
const contractPresentation = getWorkflowNodeContractPresentation(
contractSummary,
dynamicTemplateVariableLabels,
)
const {
socketRequirementDescription,
variableSummaryText,
noSettingsDescription,
variableCount: totalVariableCount,
} = contractPresentation
return (
<div className="space-y-5">
@@ -315,6 +314,7 @@ export function WorkflowNodeInspector({
{nodeDefinition && (
<WorkflowNodeContractCard
nodeDefinition={nodeDefinition}
moduleLabel={getDefinitionModuleLabel(nodeDefinition)}
moduleKey={nodeDefinition.module_key}
familyLabel={FAMILY_FILTER_LABELS[getDefinitionFamily(nodeDefinition)]}
@@ -327,13 +327,8 @@ export function WorkflowNodeInspector({
}
legacyCompatible={nodeDefinition.legacy_compatible}
legacySource={nodeDefinition.legacy_source}
inputContextLabel={inputContextLabel}
outputContextLabel={outputContextLabel}
requiredInputs={requiredInputs}
requiredAnyInputs={requiredAnyInputs}
consumedArtifacts={consumedArtifacts}
providedOutputs={providedOutputs}
producedArtifacts={producedArtifacts}
contract={contractSummary}
validationWatchpoints={validationWatchpoints}
/>
)}
@@ -344,7 +339,7 @@ export function WorkflowNodeInspector({
Authoring Model
</p>
<p className="mt-1 text-sm text-content">
Canvas connections define upstream artifacts, inspector fields define local node variables.
{contractSummary.authoringPatternDescription}
</p>
</div>
@@ -353,34 +348,62 @@ export function WorkflowNodeInspector({
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Wired Inputs
</p>
{inputDescriptions.length > 0 ? (
{inputSocketDescriptors.length > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(inputDescriptions.length, 'canvas socket', 'canvas sockets')} {inputDescriptions.length === 1 ? 'is' : 'are'} required. Each entry below maps to one input handle on the node.
{describeCanvasSocketBreakdown(
requiredSocketDescriptors.length,
alternativeSocketDescriptors.length,
)} Each entry below maps to one input handle on the node.
</p>
<p className="mt-2 text-xs text-content">
{socketRequirementDescription}
</p>
{contextInputs.length > 0 && (
<p className="mt-2 text-xs text-content-muted">
Workflow context already supplies: {contextInputs.map(formatContractValue).join(', ')}.
</p>
)}
{requiredSocketDescriptors.length > 0 && (
<div className="mt-2 space-y-1.5">
{inputSocketDescriptors.map((descriptor, index) => (
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Required Sockets
</p>
{requiredSocketDescriptors.map((descriptor, index) => (
<div
key={descriptor.id}
className="flex items-start gap-2 rounded-md border border-border-default bg-surface-hover/50 px-2 py-1"
>
<span
className={`inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
descriptor.tone === 'alternative'
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
}`}
>
<span className="inline-flex rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
Socket {index + 1}
</span>
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
</div>
))}
</div>
)}
{alternativeSocketDescriptors.length > 0 && (
<div className="mt-2 space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Alternative Groups
</p>
{alternativeSocketDescriptors.map((descriptor, index) => (
<div
key={descriptor.id}
className="flex items-start gap-2 rounded-md border border-border-default bg-surface-hover/50 px-2 py-1"
>
<span className="inline-flex rounded-full bg-sky-100 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
Group {index + 1}
</span>
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
</div>
))}
</div>
)}
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This entry node does not declare additional upstream sockets.
{socketRequirementDescription}
</p>
)}
</div>
@@ -391,7 +414,7 @@ export function WorkflowNodeInspector({
{totalVariableCount > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(totalVariableCount, 'local variable', 'local variables')} {totalVariableCount === 1 ? 'is' : 'are'} edited in the inspector.
{variableSummaryText}
</p>
{staticVariableLabels.length > 0 && (
<p className="mt-2 text-xs text-content">
@@ -406,7 +429,7 @@ export function WorkflowNodeInspector({
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.
{variableSummaryText}
</p>
)}
</div>
@@ -434,6 +457,105 @@ export function WorkflowNodeInspector({
)}
</div>
)}
{isRenderOverrideControlledNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Render Override Scope
</p>
<p className="mt-1 text-xs text-content-muted">
Render, scene, camera, and animation overrides are inherited from Output Type and
Template until
{' '}
<span className="font-medium text-content">Custom Render Settings</span>
{' '}
is enabled.
</p>
</div>
)}
{isOutputSaveNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Output Handoff
</p>
<p className="mt-1 text-xs text-content-muted">
This node does not render files itself. A connected render or export node hands off
the matching artifact and this node turns that handoff into the authoritative saved
workflow result.
</p>
<p className="mt-2 text-xs text-content-muted">
Filter:{' '}
<span className="text-content">
{String(params.expected_artifact_role || '').trim() || 'Any connected artifact'}
</span>
</p>
<p className="mt-1 text-xs text-content-muted">
Require Upstream Artifact decides whether a missing matching handoff becomes a hard
failure or just an informational no-op. Shadow runs stay observer-only and do not
publish user-visible assets.
</p>
</div>
)}
{isExportBlendNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Blend Delivery
</p>
<p className="mt-1 text-xs text-content-muted">
This node publishes the resolved order-line scene as a downloadable
{' '}
<span className="font-medium text-content">Blend Asset</span>
. It does not render pixels itself.
</p>
<p className="mt-2 text-xs text-content-muted">
Scene content still comes from upstream order-line context and the selected render
template. The only local override on this node is the optional filename suffix.
</p>
<p className="mt-2 text-xs text-content-muted">
Filename suffix:{' '}
<span className="text-content">
{String(params.output_name_suffix || '').trim() || 'None'}
</span>
</p>
<p className="mt-1 text-xs text-content-muted">
Downstream
{' '}
<span className="font-medium text-content">Save Output</span>
{' '}
or
{' '}
<span className="font-medium text-content">Notify</span>
{' '}
nodes can subscribe to the emitted blend artifact.
</p>
</div>
)}
{isNotifyNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Notification Handoff
</p>
<p className="mt-1 text-xs text-content-muted">
This node completes only when a connected render or export task arms notification
handoff. It does not emit independently.
</p>
<p className="mt-2 text-xs text-content-muted">
Channel:{' '}
<span className="text-content">
{String(params.channel || 'audit_log')}
</span>
{' '}
(current runtime delivery target)
</p>
<p className="mt-1 text-xs text-content-muted">
Require Armed Render turns a missing handoff into a failure instead of a skipped
notification. Shadow runs suppress user notifications entirely.
</p>
</div>
)}
</div>
)}
@@ -443,6 +565,26 @@ export function WorkflowNodeInspector({
<p className="mt-1 text-xs text-content-muted">
Leave the field empty to keep legacy category/output-type resolution.
</p>
{templatesWithWorkflowInputs.length > 0 && (
<>
<p className="mt-3 text-xs font-medium uppercase tracking-wide text-content-secondary">
Automatic Resolution Coverage
</p>
<p className="mt-1 text-xs text-content-muted">
Automatic resolution can still select templates with workflow-defined inputs. Use the list below to see
which variables exist before forcing a specific template.
</p>
<p className="mt-2 text-xs text-content">
{templatesWithWorkflowInputs.length} active template
{templatesWithWorkflowInputs.length === 1 ? '' : 's'} expose
{' '}
{automaticTemplateVariableLabels.length}
{' '}
unique workflow variable
{automaticTemplateVariableLabels.length === 1 ? '' : 's'}.
</p>
</>
)}
</div>
)}
@@ -456,11 +598,37 @@ export function WorkflowNodeInspector({
</div>
)}
{isResolveTemplateNode && !selectedTemplate && automaticTemplateCoverage.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm font-medium text-content">Potential Template Variables</p>
<p className="mt-1 text-xs text-content-muted">
These variables exist on active templates today. They only become editable on this node after choosing the
matching template override.
</p>
<div className="mt-3 space-y-2">
{automaticTemplateCoverage.map(variable => (
<div
key={variable.label}
className="rounded-lg border border-border-default bg-surface px-3 py-2"
>
<p className="text-sm text-content">{variable.label}</p>
<p className="mt-1 text-xs text-content-muted">
Available via {describeTemplateCoverage(variable.templateNames, variable.outputTypeNames)}.
</p>
</div>
))}
</div>
</div>
)}
{Object.keys(fieldsBySection).length === 0 && (
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm text-content">This node has no editor settings.</p>
<p className="mt-1 text-xs text-content-muted">
Configure it by wiring its declared inputs on the canvas. Each required upstream input gets its own socket on the node itself.
{noSettingsDescription}
</p>
<p className="mt-2 text-xs text-content-muted">
{socketRequirementDescription}
</p>
</div>
)}
@@ -476,10 +644,10 @@ export function WorkflowNodeInspector({
const fieldId = `workflow-node-field-${field.key}`
const fieldOptions = field.options ?? []
const disableRenderOverrideField =
(step === 'blender_still' || step === 'blender_turntable') &&
isRenderOverrideControlledNode &&
!customRenderSettingsEnabled &&
field.key !== 'use_custom_render_settings' &&
(field.section === 'Render' || field.section === 'Output')
renderOverrideFieldKeys.has(field.key)
return (
<div key={field.key}>
@@ -2,12 +2,60 @@ import { Loader2 } from 'lucide-react'
import type { WorkflowPreflightResponse } from '../../api/workflows'
import { getPreflightStatusClassName } from './workflowRunPresentation'
import {
getWorkflowPreflightActionHint,
getWorkflowValidationSummaryToneClassName,
getWorkflowValidationKind,
getWorkflowValidationKindLabel,
summarizeWorkflowPreflightIssues,
} from './workflowValidationPresentation'
interface WorkflowPreflightPanelProps {
preflight: WorkflowPreflightResponse | null
isLoading: boolean
}
type PreflightActionItem = {
id: string
severity: 'error' | 'warning' | 'info'
kind: string
kindLabel: string
title: string
hint: string
}
function collectActionItems(preflight: WorkflowPreflightResponse): PreflightActionItem[] {
const items: PreflightActionItem[] = []
for (const issue of preflight.issues) {
const kind = getWorkflowValidationKind(issue)
items.push({
id: `global-${issue.code}-${issue.message}`,
severity: issue.severity,
kind,
kindLabel: getWorkflowValidationKindLabel(kind),
title: issue.message,
hint: getWorkflowPreflightActionHint(issue),
})
}
for (const node of preflight.nodes) {
for (const issue of node.issues) {
const kind = getWorkflowValidationKind(issue)
items.push({
id: `${node.node_id}-${issue.code}-${issue.message}`,
severity: issue.severity,
kind,
kindLabel: getWorkflowValidationKindLabel(kind),
title: `${node.label ?? node.node_id}: ${issue.message}`,
hint: getWorkflowPreflightActionHint(issue),
})
}
}
return items
}
export function WorkflowPreflightPanel({
preflight,
isLoading,
@@ -16,6 +64,20 @@ export function WorkflowPreflightPanel({
return null
}
const actionItems = preflight ? collectActionItems(preflight) : []
const blockingItems = actionItems.filter(item => item.severity === 'error')
const warningItems = actionItems.filter(item => item.severity === 'warning')
const issueTypeCounts = actionItems.reduce<Record<string, number>>((counts, item) => {
counts[item.kind] = (counts[item.kind] ?? 0) + 1
return counts
}, {})
const validationSummary = preflight
? summarizeWorkflowPreflightIssues([
...preflight.issues,
...preflight.nodes.flatMap(node => node.issues),
])
: []
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
@@ -41,12 +103,26 @@ export function WorkflowPreflightPanel({
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Mode: {preflight.execution_mode}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Blocking: {blockingItems.length}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Warnings: {warningItems.length}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Global issues: {preflight.issues.length}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Node checks: {preflight.nodes.length}
</span>
{Object.entries(issueTypeCounts).map(([kind, count]) => (
<span
key={kind}
className="rounded-full border border-border-default bg-surface px-2 py-0.5"
>
{getWorkflowValidationKindLabel(kind as ReturnType<typeof getWorkflowValidationKind>)}: {count}
</span>
))}
{preflight.unsupported_node_ids.length > 0 && (
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Unsupported nodes: {preflight.unsupported_node_ids.length}
@@ -54,6 +130,19 @@ export function WorkflowPreflightPanel({
)}
</div>
{validationSummary.length > 0 && (
<div className="flex flex-wrap gap-2 text-[11px]">
{validationSummary.map(item => (
<span
key={`${item.severity}:${item.kind}`}
className={`rounded-full border px-2 py-0.5 font-medium ${getWorkflowValidationSummaryToneClassName(item.severity)}`}
>
{item.label}: {item.count}
</span>
))}
</div>
)}
{(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && (
<div className="space-y-1 text-xs text-content-muted">
{preflight.resolved_order_line_id && <p>Order Line: {preflight.resolved_order_line_id}</p>}
@@ -61,6 +150,35 @@ export function WorkflowPreflightPanel({
</div>
)}
{actionItems.length > 0 && (
<div className="space-y-2 rounded-md border border-border-default bg-surface px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-content">Next Actions</p>
<span className="text-[11px] text-content-muted">
{blockingItems.length > 0 ? 'Resolve blocking items first' : 'Warnings can be addressed incrementally'}
</span>
</div>
<div className="space-y-2">
{(blockingItems.length > 0 ? blockingItems : warningItems).slice(0, 3).map(item => (
<div key={item.id} className="rounded-md border border-border-default bg-surface-hover/60 px-2 py-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-content">{item.title}</p>
<div className="flex items-center gap-1">
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 text-[11px] font-medium text-content-muted">
{item.kindLabel}
</span>
<span className={`rounded-full px-1.5 py-0.5 text-[11px] font-medium ${getPreflightStatusClassName(item.severity)}`}>
{item.severity}
</span>
</div>
</div>
<p className="mt-1 text-xs text-content-muted">{item.hint}</p>
</div>
))}
</div>
</div>
)}
{preflight.unsupported_node_ids.length > 0 && (
<div className="space-y-1 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
<p className="font-medium text-content">Unsupported Node IDs</p>
@@ -83,9 +201,11 @@ export function WorkflowPreflightPanel({
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
<span>Code: {issue.code}</span>
<span>Type: {getWorkflowValidationKindLabel(getWorkflowValidationKind(issue))}</span>
{issue.step && <span>Step: {issue.step}</span>}
{issue.node_id && <span>Node: {issue.node_id}</span>}
</div>
<p className="mt-1 text-xs text-content-muted">{getWorkflowPreflightActionHint(issue)}</p>
</div>
))}
</div>
@@ -117,9 +237,14 @@ export function WorkflowPreflightPanel({
{node.issues.length > 0 && (
<div className="mt-2 space-y-1">
{node.issues.map(issue => (
<p key={`${node.node_id}-${issue.code}-${issue.message}`} className="text-xs text-content-muted">
{issue.message}
</p>
<div key={`${node.node_id}-${issue.code}-${issue.message}`} className="rounded-md border border-border-default bg-surface-hover/40 px-2 py-1.5">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
<span className="font-medium text-content">{issue.message}</span>
<span>Code: {issue.code}</span>
<span>Type: {getWorkflowValidationKindLabel(getWorkflowValidationKind(issue))}</span>
</div>
<p className="mt-1 text-xs text-content-muted">{getWorkflowPreflightActionHint(issue)}</p>
</div>
))}
</div>
)}
@@ -1,8 +1,9 @@
import { Milestone, Wand2 } from 'lucide-react'
import { Milestone } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import { WorkflowBundleCatalog } from './WorkflowBundleCatalog'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
type WorkflowReferenceBundlePanelProps = {
@@ -20,64 +21,16 @@ export function WorkflowReferenceBundlePanel({
}: WorkflowReferenceBundlePanelProps) {
const { referenceBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (referenceBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Milestone size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Reference Paths
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert complete canonical production routes when you want a full non-legacy baseline instead of assembling modules piecemeal.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{referenceBundles.length} paths
</span>
</div>
<div className="space-y-2">
{referenceBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertReferencePath ? (
<button
type="button"
onClick={() => onInsertReferencePath(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
<WorkflowBundleCatalog
bundles={referenceBundles}
emptyLabel="No workflow steps"
icon={Milestone}
title="Reference Paths"
description="Insert a complete canonical production route before making targeted stage swaps."
countLabel="paths"
insertLabel="Insert path"
onInsert={onInsertReferencePath}
/>
)
}
@@ -23,26 +23,31 @@ export function WorkflowStarterPathPanel({
return (
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Starter Path
</p>
<p className="mt-1 text-sm font-medium text-content">{plan.starterTitle}</p>
<p className="mt-1 text-xs text-content-muted">{plan.starterDescription}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{plan.starterCompletedCount}/{plan.starterItems.length} present
</span>
</div>
<p className="mt-1 text-sm font-medium text-content">{plan.starterTitle}</p>
<p className="mt-1 text-[11px] text-content-muted">{plan.starterDescription}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</div>
<div className="space-y-2">
<div className="grid gap-2">
{plan.starterItems.map(item => {
const { definition, index, isPresent } = item
return (
<div
key={definition.step}
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2"
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2.5"
>
<div className="min-w-0 flex items-start gap-2">
<span className="mt-0.5 text-content-secondary">
@@ -62,7 +67,9 @@ export function WorkflowStarterPathPanel({
{definition.category}
</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
<p className="mt-0.5 line-clamp-1 text-[11px] text-content-muted">
{definition.description}
</p>
</div>
</div>
@@ -70,7 +77,7 @@ export function WorkflowStarterPathPanel({
<button
type="button"
onClick={() => onSelectStep(definition.step)}
className="shrink-0 rounded-lg border border-border-default px-2 py-1 text-xs font-medium text-content hover:bg-surface-hover"
className="shrink-0 rounded-lg border border-border-default px-2.5 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
>
<span className="inline-flex items-center gap-1">
<Plus size={12} />
@@ -22,21 +22,23 @@ export function WorkflowUtilityRail<T extends string>({
onTabChange,
children,
}: WorkflowUtilityRailProps<T>) {
const activeTabLabel = tabs.find(tab => tab.key === activeTab)?.label ?? 'Tools'
return (
<div className="flex w-full flex-col border-t border-border-default bg-surface xl:w-[22rem] xl:flex-shrink-0 xl:border-l xl:border-t-0">
<div className="border-b border-border-default px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-border-default bg-surface-hover text-content-secondary">
<PanelRight size={15} />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-content">Utility Rail</p>
<p className="text-xs text-content-muted">
Context-aware tools without sacrificing canvas space.
</p>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-content">Tools</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeTabLabel}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-2 grid grid-cols-2 gap-2">
{tabs.map(tab => {
const Icon = tab.icon
const isActive = activeTab === tab.key
@@ -1,10 +1,59 @@
import { AlertTriangle } from 'lucide-react'
import {
getWorkflowValidationSummaryToneClassName,
summarizeWorkflowDraftValidationBySeverity,
summarizeWorkflowDraftValidationMessages,
} from './workflowValidationPresentation'
interface WorkflowValidationBannerProps {
errors: string[]
warnings: string[]
}
function ValidationCategoryPills({
messages,
}: {
messages: string[]
}) {
const categories = summarizeWorkflowDraftValidationMessages(messages)
if (categories.length === 0) return null
return (
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{categories.map(category => (
<span
key={category.kind}
className="rounded-full border border-current/15 bg-white/40 px-1.5 py-0.5 font-medium dark:bg-white/5"
>
{category.label}: {category.count}
</span>
))}
</div>
)
}
function ValidationSummaryPills({
errors,
warnings,
}: WorkflowValidationBannerProps) {
const categories = summarizeWorkflowDraftValidationBySeverity({ errors, warnings })
if (categories.length === 0) return null
return (
<div className="flex flex-wrap gap-1.5 text-[10px]">
{categories.map(category => (
<span
key={`${category.severity}:${category.kind}`}
className={`rounded-full border px-1.5 py-0.5 font-medium ${getWorkflowValidationSummaryToneClassName(category.severity)}`}
>
{category.label}: {category.count}
</span>
))}
</div>
)
}
export function WorkflowValidationBanner({
errors,
warnings,
@@ -15,12 +64,14 @@ export function WorkflowValidationBanner({
return (
<div className="space-y-2 border-b border-border-default bg-surface px-4 py-3">
<ValidationSummaryPills errors={errors} warnings={warnings} />
{errors.length > 0 && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
<div className="flex items-center gap-2 font-medium">
<AlertTriangle size={14} />
{errors.length} validation error{errors.length === 1 ? '' : 's'}
</div>
<ValidationCategoryPills messages={errors} />
<ul className="mt-2 space-y-1 text-xs">
{errors.map(error => (
<li key={error}>{error}</li>
@@ -33,6 +84,7 @@ export function WorkflowValidationBanner({
<div className="font-medium">
{warnings.length} warning{warnings.length === 1 ? '' : 's'}
</div>
<ValidationCategoryPills messages={warnings} />
<ul className="mt-2 space-y-1 text-xs">
{warnings.map(warning => (
<li key={warning}>{warning}</li>
@@ -71,6 +71,8 @@ export type WorkflowOrderLineContextOption = {
value: string
label: string
meta: string
isRenderable: boolean
renderabilityReason: string | null
}
export type WorkflowOrderLineContextGroup = {
@@ -89,6 +91,8 @@ function normalizeOrderLineContextGroups(
value: option.value,
label: option.label,
meta: option.meta,
isRenderable: option.is_renderable,
renderabilityReason: option.renderability_reason,
})),
}))
}
@@ -169,7 +173,13 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
return trimmed.length > 0 ? trimmed : null
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
const dispatchContextMeta = useMemo(() => {
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
if (isOrderLineGraph) {
if (!selectedOrderLineContext) return null
if (!selectedOrderLineContext.isRenderable && selectedOrderLineContext.renderabilityReason) {
return `${selectedOrderLineContext.meta} · blocked: ${selectedOrderLineContext.renderabilityReason}`
}
return selectedOrderLineContext.meta
}
if (graphFamily !== 'cad_file') return null
const trimmed = dispatchContextId.trim()
if (!trimmed) return null
@@ -242,6 +252,10 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
},
})
useEffect(() => {
setDispatchContextId('')
}, [workflow.id])
useEffect(() => {
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
@@ -260,7 +274,9 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
useEffect(() => {
if (!isOrderLineGraph) return
if (dispatchContextId.trim()) return
const firstOption = orderLineContextGroups[0]?.options[0]
const firstOption =
orderLineContextGroups.flatMap(group => group.options).find(option => option.isRenderable) ??
orderLineContextGroups[0]?.options[0]
if (firstOption) {
setDispatchContextId(firstOption.value)
}
@@ -330,24 +346,65 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
return
}
const nextEdgeId = `e_${connection.source}_${connection.target}_${matchingTargetPort.id}`
let replacedEdgeLabel: string | null = null
let rejectedDuplicate = false
setEdges(currentEdges => {
const duplicateEdge = currentEdges.some(
edge => edge.source === connection.source && edge.target === connection.target,
edge =>
edge.source === connection.source &&
edge.target === connection.target &&
edge.sourceHandle === matchingSourcePort.id &&
edge.targetHandle === matchingTargetPort.id,
)
if (duplicateEdge) {
toast.error('A connection between these nodes already exists.')
rejectedDuplicate = true
return currentEdges
}
const conflictingTargetEdge = currentEdges.find(
edge =>
edge.target === connection.target &&
edge.targetHandle === matchingTargetPort.id &&
edge.id !== nextEdgeId,
)
if (conflictingTargetEdge) {
replacedEdgeLabel = matchingTargetPort.label
}
const dedupedEdges = currentEdges.filter(
edge =>
!(edge.source === connection.source && edge.target === connection.target) &&
!(conflictingTargetEdge && edge.id === conflictingTargetEdge.id),
)
return addEdge(
{
...connection,
id: nextEdgeId,
sourceHandle: matchingSourcePort.id,
targetHandle: matchingTargetPort.id,
},
currentEdges,
)
dedupedEdges,
).map(edge => ({
...edge,
selected: edge.id === nextEdgeId,
}))
})
if (rejectedDuplicate) {
toast.error('This connection is already present.')
return
}
setSelectedNodeId(null)
setSelectedEdgeIds([nextEdgeId])
setNodeMenuAnchor(null)
if (replacedEdgeLabel) {
toast.success(`Reassigned ${replacedEdgeLabel} to the new upstream node.`)
}
},
[nodes, setEdges],
)
@@ -65,15 +65,15 @@ export function getWorkflowAuthoringEntryAction(
return {
label: 'Author',
title: 'Open guided workflow authoring browser',
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
helper: 'Open reference paths, stage modules, starter steps, and raw nodes.',
icon: Sparkles,
}
}
return {
label: 'Node',
title: 'Open raw node browser',
helper: 'Open the searchable node catalog directly on the canvas.',
title: 'Open raw node catalog',
helper: 'Open the searchable raw node catalog directly on the canvas.',
icon: Library,
}
}
@@ -103,8 +103,8 @@ function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAutho
description: 'Insert the full non-legacy still production baseline first, then tune modules or individual nodes.',
},
{
title: 'Swap stages with production modules',
description: 'Use render and publish bundles to change whole stages without breaking graph-safe sequencing.',
title: 'Swap stages with stage modules',
description: 'Use scene-prep, materials, render, and publish bundles to change whole production stages without breaking graph-safe sequencing.',
},
{
title: 'Use raw nodes last',
@@ -160,7 +160,7 @@ export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): Work
},
{
index: 2,
title: 'Production Modules',
title: 'Stage Modules',
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
},
{
@@ -186,7 +186,7 @@ export function getWorkflowAuthoringPlan(
const title = graphFamily === 'mixed' ? 'Guided Authoring' : STARTER_PATH_TITLES[graphFamily]
const description =
graphFamily === 'mixed'
? 'Keep the graph readable by preferring complete paths and bundles before raw node-level edits.'
? 'Keep the graph readable by preferring complete paths and stage bundles before raw node-level edits.'
: STARTER_PATH_DESCRIPTIONS[graphFamily]
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
.map(bundle => ({
@@ -10,11 +10,13 @@ import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowAuthoringSection = 'overview' | 'paths' | 'modules' | 'starter' | 'nodes'
type WorkflowAuthoringSectionConfig = {
export type WorkflowAuthoringSectionConfig = {
key: WorkflowAuthoringSection
label: string
helper: string
icon: LucideIcon
tone: 'Guided' | 'Escape Hatch'
summaryLabel?: string
}
type WorkflowAuthoringSectionOptions = {
@@ -40,41 +42,47 @@ export function getWorkflowAuthoringSections({
label: 'Overview',
helper: 'Start with guided authoring modes before dropping to raw nodes.',
icon: Compass,
tone: 'Guided',
})
}
if (hasReferencePaths) {
sections.push({
key: 'paths',
label: 'Paths',
label: 'Reference Paths',
helper: 'Insert complete canonical production paths.',
icon: Milestone,
tone: 'Guided',
})
}
if (hasModules) {
sections.push({
key: 'modules',
label: 'Modules',
helper: 'Insert reusable production bundles.',
label: 'Stage Modules',
helper: 'Insert reusable production bundles for one stage at a time.',
icon: Boxes,
tone: 'Guided',
})
}
if (hasStarter || graphFamily !== 'mixed') {
sections.push({
key: 'starter',
label: 'Starter',
helper: 'Follow the canonical family-safe assembly path.',
label: 'Starter Steps',
helper: 'Follow the canonical family-safe assembly path step by step.',
icon: Milestone,
tone: 'Guided',
})
}
sections.push({
key: 'nodes',
label: 'Nodes',
helper: 'Browse the full node catalog.',
label: 'Raw Nodes',
helper: 'Browse the full raw node catalog after the path is in place.',
icon: Library,
tone: 'Escape Hatch',
summaryLabel: 'Raw Node Catalog',
})
return sections
@@ -11,6 +11,7 @@ import { getWorkflowAuthoringPlan, type WorkflowAuthoringPlan } from './workflow
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import {
getWorkflowAuthoringSections,
type WorkflowAuthoringSectionConfig,
type WorkflowAuthoringSection,
} from './workflowAuthoringSections'
@@ -21,11 +22,29 @@ type WorkflowAuthoringSurfaceModelOptions = {
}
export type WorkflowAuthoringSurfaceModel = {
chrome: WorkflowAuthoringChrome
defaultSection: WorkflowAuthoringSection
sections: ReturnType<typeof getWorkflowAuthoringSections>
plan: WorkflowAuthoringPlan
}
export type WorkflowAuthoringSectionDetail = WorkflowAuthoringSectionConfig & {
isDefaultSection: boolean
isOverviewSection: boolean
isRawNodeSection: boolean
summaryLabel: string
}
export type WorkflowAuthoringChrome = {
browserEyebrow: string
browserTitle: string
browserHelper: string
menuTitle: string
menuHelper: string
guideEyebrow: string
guideHelper: string
}
type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions & {
actions?: WorkflowAuthoringActions
preferredPosition?: WorkflowAuthoringPosition
@@ -34,6 +53,7 @@ type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions &
export type WorkflowAuthoringSurfaceController = WorkflowAuthoringSurfaceModel & {
activeSection: WorkflowAuthoringSection
activeSectionDetail: WorkflowAuthoringSectionDetail | null
activeSectionMeta: WorkflowAuthoringSurfaceModel['sections'][number] | null
insertBindings: WorkflowAuthoringInsertHandlers
setActiveSection: (section: WorkflowAuthoringSection) => void
@@ -45,6 +65,38 @@ export function getDefaultWorkflowAuthoringSection(
return graphFamily === 'mixed' ? 'nodes' : 'overview'
}
export function getWorkflowAuthoringChrome(): WorkflowAuthoringChrome {
return {
browserEyebrow: 'Node Library',
browserTitle: 'Authoring Browser',
browserHelper:
'Start with reference paths or stage modules. Drop to starter steps and raw nodes only when precision is required.',
menuTitle: 'Workflow Authoring',
menuHelper:
'Guided first: prefer paths and modules before starter steps or raw nodes.',
guideEyebrow: 'Guided First',
guideHelper:
'Choose the highest-level insert that solves the job, then descend only if needed.',
}
}
export function getWorkflowAuthoringSectionDetail(
activeSection: WorkflowAuthoringSection,
sections: WorkflowAuthoringSurfaceModel['sections'],
defaultSection: WorkflowAuthoringSection,
): WorkflowAuthoringSectionDetail | null {
const sectionMeta = sections.find(section => section.key === activeSection)
if (!sectionMeta) return null
return {
...sectionMeta,
isDefaultSection: activeSection === defaultSection,
isOverviewSection: activeSection === 'overview',
isRawNodeSection: activeSection === 'nodes',
summaryLabel: sectionMeta.summaryLabel ?? sectionMeta.label,
}
}
export function getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily,
@@ -61,6 +113,7 @@ export function getWorkflowAuthoringSurfaceModel({
})
return {
chrome: getWorkflowAuthoringChrome(),
defaultSection,
sections,
plan,
@@ -107,7 +160,7 @@ export function useWorkflowAuthoringSurface({
() => getWorkflowAuthoringSurfaceModel({ definitions, graphFamily, activeSteps }),
[activeSteps, definitions, graphFamily],
)
const { defaultSection, plan, sections } = surfaceModel
const { chrome, defaultSection, plan, sections } = surfaceModel
const insertBindings = useMemo(
() =>
bindWorkflowAuthoringInsertActions(actions, {
@@ -132,12 +185,18 @@ export function useWorkflowAuthoringSurface({
() => sections.find(section => section.key === activeSection) ?? null,
[activeSection, sections],
)
const activeSectionDetail = useMemo(
() => getWorkflowAuthoringSectionDetail(activeSection, sections, defaultSection),
[activeSection, defaultSection, sections],
)
return {
chrome,
defaultSection,
sections,
plan,
activeSection,
activeSectionDetail,
activeSectionMeta,
insertBindings,
setActiveSection,
@@ -6,6 +6,8 @@ export const BLUEPRINT_LABELS: Record<string, string> = {
cad_intake: 'Reference Blueprint',
order_rendering: 'Reference Blueprint',
still_graph_reference: 'Graph Reference',
still_graph_alpha_reference: 'Alpha Reference',
still_graph_blend_reference: 'Blend Reference',
starter_cad_intake: 'Starter',
starter_order_rendering: 'Starter',
}
@@ -14,6 +16,8 @@ export const BLUEPRINT_DESCRIPTION: Record<string, string> = {
cad_intake: 'Canonical CAD-file workflow for intake, preview generation, and material discovery.',
order_rendering: 'Canonical order-line workflow for production rendering, exports, and notifications.',
still_graph_reference: 'Reference still-render graph that stays parallel to the legacy workflow while native nodes reach parity.',
still_graph_alpha_reference: 'Reference still-render graph with transparent-alpha defaults so PNG/overlay outputs stay authorable without legacy fallbacks.',
still_graph_blend_reference: 'Reference still-render graph with explicit .blend export delivery branches for workflow-first output bindings.',
starter_cad_intake: 'Minimal CAD-file starter graph.',
starter_order_rendering: 'Minimal order-line starter graph.',
}
@@ -57,7 +61,13 @@ export function getWorkflowBlueprint(config: WorkflowConfig): string | null {
export function isReferenceBlueprint(config: WorkflowConfig): boolean {
const blueprint = getWorkflowBlueprint(config)
return blueprint === 'cad_intake' || blueprint === 'order_rendering' || blueprint === 'still_graph_reference'
return (
blueprint === 'cad_intake' ||
blueprint === 'order_rendering' ||
blueprint === 'still_graph_reference' ||
blueprint === 'still_graph_alpha_reference' ||
blueprint === 'still_graph_blend_reference'
)
}
export function cloneWorkflowConfig(config: WorkflowConfig, options?: { stripBlueprint?: boolean }): WorkflowConfig {
@@ -8,6 +8,15 @@ import type {
WorkflowNodeDefinition,
WorkflowParams,
} from '../../api/workflows'
import {
TEMPLATE_INPUT_PARAM_PREFIX,
formatContractValue,
getContractContext,
getWorkflowNodeContractPresentation,
getWorkflowNodeContractSummary,
getWorkflowNodeInputSocketDescriptors,
getWorkflowNodeOutputSocketDescriptors,
} from './workflowNodeContracts'
import { getNodeFamily, type WorkflowGraphFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
export type WorkflowCanvasNodeData = {
@@ -19,20 +28,20 @@ export type WorkflowCanvasNodeData = {
category?: StepCategory
inputContextLabel?: string | null
outputContextLabel?: string | null
contextInputs?: string[]
inputPorts?: WorkflowCanvasPort[]
outputPorts?: WorkflowCanvasPort[]
requiredAnyInputs?: string[][]
editableFieldCount?: number
editableFieldLabels?: string[]
dynamicVariableHint?: string | null
authoringPatternLabel?: string
authoringPatternDescription?: string
socketRequirementDescription?: string
variableSummaryText?: string
}
export type WorkflowCanvasPort = {
id: string
label: string
roles: string[]
kind: 'required' | 'alternative' | 'provided'
}
export type WorkflowCanvasPort = ReturnType<typeof getWorkflowNodeInputSocketDescriptors>[number]
export type WorkflowValidationResult = {
errors: string[]
@@ -52,8 +61,6 @@ type WorkflowSemanticState = {
executedSteps: Set<string>
}
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const CAD_FILE_ENTRY_STEPS = new Set([
'resolve_step_path',
'occ_object_extract',
@@ -89,164 +96,15 @@ const ROOT_CONTEXT_VALUES: Record<WorkflowNodeContractContext, string[]> = {
const ORDER_LINE_SETUP_COMPATIBILITY_VALUES = ['cad_materials', 'glb_preview', 'bbox']
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
const CONTRACT_TOKEN_LABELS: Record<string, string> = {
api: 'API',
bbox: 'Bounding Box',
cad: 'CAD',
fps: 'FPS',
glb: 'GLB',
gpu: 'GPU',
id: 'ID',
occ: 'OCC',
step: 'STEP',
stl: 'STL',
usd: 'USD',
}
function getContractContext(
contract: Record<string, unknown> | undefined,
): WorkflowNodeContractContext | null {
const value = contract?.context
return value === 'cad_file' || value === 'order_line' ? value : null
}
export function getContractContextLabel(
contract: Record<string, unknown> | undefined,
): string | null {
const context = getContractContext(contract)
if (!context) return null
return context === 'cad_file' ? 'CAD File' : 'Order Line'
}
function getContractValues(
contract: Record<string, unknown> | undefined,
key: string,
): string[] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
}
function getContractAlternativeValues(
contract: Record<string, unknown> | undefined,
key: string,
): string[][] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
return [value as string[]]
}
return value
.filter((entry): entry is string[] => Array.isArray(entry))
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
.filter(group => group.length > 0)
}
export function formatContractValue(value: string): string {
return value
.split('_')
.map(part => {
const normalized = part.trim().toLowerCase()
if (!normalized) return ''
return CONTRACT_TOKEN_LABELS[normalized] ?? (normalized.charAt(0).toUpperCase() + normalized.slice(1))
})
.filter(part => part.length > 0)
.join(' ')
}
function dedupeRoles(values: string[]): string[] {
return Array.from(new Set(values.filter(value => value.trim().length > 0)))
}
function createPortId(prefix: string, roles: string[]): string {
return `${prefix}:${roles.join('|')}`
}
function formatAlternativePortLabel(roles: string[]): string {
if (roles.length === 1) return formatContractValue(roles[0])
return `Any of ${roles.map(formatContractValue).join(' / ')}`
}
function getNodeAlternativeInputGroups(definition: WorkflowNodeDefinition | undefined): string[][] {
if (!definition) return []
const alternativeGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | undefined,
'requires_any',
)
if (definition.step === 'output_save' && alternativeGroups.length === 0) {
alternativeGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
}
if (definition.step === 'notify') {
if (alternativeGroups.length === 0) {
alternativeGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else {
alternativeGroups[0] = Array.from(new Set([...alternativeGroups[0], 'blend_asset']))
}
}
return alternativeGroups.map(group => dedupeRoles(group))
}
function getNodeRequiredInputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
const alternativeRoles = new Set(getNodeAlternativeInputGroups(definition).flat())
const directRoles = dedupeRoles([
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
return directRoles.filter(role => !alternativeRoles.has(role))
}
function getNodeProvidedOutputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return dedupeRoles([
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
...(definition.artifact_roles_produced ?? []),
])
}
function getNodeEditableFieldLabels(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return Array.from(
new Set(
definition.fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function getDynamicVariableHint(definition: WorkflowNodeDefinition | undefined): string | null {
if (!definition) return null
const providedRoles = new Set(getNodeProvidedOutputRoles(definition))
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
return 'Template-selected variables appear after choosing a template.'
}
return null
}
export function buildWorkflowCanvasNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
const requiredInputRoles = getNodeRequiredInputRoles(definition)
const alternativeInputGroups = getNodeAlternativeInputGroups(definition)
const providedOutputRoles = getNodeProvidedOutputRoles(definition)
const contractSummary = getWorkflowNodeContractSummary(definition)
const contractPresentation = getWorkflowNodeContractPresentation(contractSummary)
const alternativeInputGroups = contractSummary.requiredAnyInputs
return {
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
@@ -255,32 +113,19 @@ export function buildWorkflowCanvasNodeData(
description: overrides?.description ?? definition?.description,
icon: overrides?.icon ?? definition?.icon,
category: overrides?.category ?? definition?.category,
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
inputPorts: [
...requiredInputRoles.map(role => ({
id: createPortId('input', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'required' as const,
})),
...alternativeInputGroups.map(group => ({
id: createPortId('input-any', group),
label: formatAlternativePortLabel(group),
roles: group,
kind: 'alternative' as const,
})),
],
outputPorts: providedOutputRoles.map(role => ({
id: createPortId('output', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'provided' as const,
})),
inputContextLabel: contractSummary.inputContextLabel,
outputContextLabel: contractSummary.outputContextLabel,
contextInputs: contractSummary.contextInputs,
inputPorts: getWorkflowNodeInputSocketDescriptors(contractSummary),
outputPorts: getWorkflowNodeOutputSocketDescriptors(contractSummary),
requiredAnyInputs: alternativeInputGroups,
editableFieldCount: definition?.fields.length ?? 0,
editableFieldLabels: getNodeEditableFieldLabels(definition),
dynamicVariableHint: getDynamicVariableHint(definition),
editableFieldCount: contractSummary.editableFieldCount,
editableFieldLabels: contractSummary.editableFieldLabels,
dynamicVariableHint: contractSummary.dynamicVariableHint,
authoringPatternLabel: contractSummary.authoringPatternLabel,
authoringPatternDescription: contractSummary.authoringPatternDescription,
socketRequirementDescription: contractPresentation.socketRequirementDescription,
variableSummaryText: contractPresentation.variableSummaryText,
}
}
@@ -682,30 +527,9 @@ export function validateWorkflowDraft(
warnings.push(`Node "${label}" has no earlier "Resolve Template" node. Render defaults may drift from legacy behavior.`)
}
const requiredValues = new Set([
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
const alternativeRequiredGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | undefined,
'requires_any',
)
if (step === 'output_save') {
if (alternativeRequiredGroups.length === 0) {
alternativeRequiredGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
}
}
if (step === 'notify') {
requiredValues.delete('workflow_result')
requiredValues.delete('blend_asset')
if (alternativeRequiredGroups.length === 0) {
alternativeRequiredGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else if (alternativeRequiredGroups.length > 0) {
alternativeRequiredGroups[0] = Array.from(new Set([...alternativeRequiredGroups[0], 'blend_asset']))
}
}
const contractSummary = getWorkflowNodeContractSummary(definition)
const requiredValues = new Set(contractSummary.requiredInputs)
const alternativeRequiredGroups = contractSummary.requiredAnyInputs
const compatibilityValues = new Set<string>()
if (executedSteps.has('order_line_setup') || step === 'order_line_setup') {
@@ -737,10 +561,7 @@ export function validateWorkflowDraft(
if (outputContext) {
availableValues.add(outputContext)
}
for (const value of getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides')) {
availableValues.add(value)
}
for (const value of definition.artifact_roles_produced ?? []) {
for (const value of contractSummary.providedOutputs) {
availableValues.add(value)
}
if (step === 'order_line_setup') {
@@ -888,6 +709,29 @@ function nodesOverlapAtPosition(
)
}
function getOrderedSearchOffsets(radius: number): Array<{ column: number; row: number }> {
if (radius <= 0) {
return [{ column: 0, row: 0 }]
}
const positiveColumns = Array.from({ length: radius }, (_, index) => index + 1)
const negativeColumns = Array.from({ length: radius }, (_, index) => -(index + 1))
const positiveRows = Array.from({ length: radius }, (_, index) => index + 1)
const negativeRows = Array.from({ length: radius }, (_, index) => -(index + 1))
const columns = [0, ...positiveColumns, ...negativeColumns]
const rows = [0, ...positiveRows, ...negativeRows]
const offsets: Array<{ column: number; row: number }> = []
for (const row of rows) {
for (const column of columns) {
if (Math.max(Math.abs(column), Math.abs(row)) !== radius) continue
offsets.push({ column, row })
}
}
return offsets
}
export function graphNeedsAutoLayout(nodes: Node[]): boolean {
if (nodes.length <= 1) return false
@@ -930,11 +774,8 @@ export function findOpenNodePosition(
return normalized
}
for (let radius = 0; radius < 12; radius += 1) {
const horizontalRange = radius + 1
const verticalRange = radius + 1
for (let row = -verticalRange; row <= verticalRange; row += 1) {
for (let column = -horizontalRange; column <= horizontalRange; column += 1) {
for (let radius = 1; radius < 12; radius += 1) {
for (const { column, row } of getOrderedSearchOffsets(radius)) {
const candidate = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, normalized.x + column * horizontalStep),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, normalized.y + row * verticalStep),
@@ -944,10 +785,9 @@ export function findOpenNodePosition(
}
}
}
}
return {
x: normalized.x + horizontalStep,
x: normalized.x,
y: normalized.y + verticalStep,
}
}
@@ -1066,9 +906,7 @@ export function resolveNodeCollisions(nodes: Node[], anchorNodeIds: string[]): N
return nextNodes
}
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
if (nodes.length === 0) return nodes
function layoutConnectedComponent(nodes: Node[], edges: Edge[]): Node[] {
const horizontalSpacing = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
const verticalSpacing = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
@@ -1202,3 +1040,103 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
}
})
}
function getWeaklyConnectedComponentIds(nodes: Node[], edges: Edge[]): string[][] {
const adjacency = new Map<string, Set<string>>()
const knownNodeIds = new Set(nodes.map(node => node.id))
for (const node of nodes) {
adjacency.set(node.id, new Set())
}
for (const edge of edges) {
if (!knownNodeIds.has(edge.source) || !knownNodeIds.has(edge.target)) continue
adjacency.get(edge.source)?.add(edge.target)
adjacency.get(edge.target)?.add(edge.source)
}
const visited = new Set<string>()
const components: string[][] = []
for (const node of nodes) {
if (visited.has(node.id)) continue
const queue = [node.id]
const component: string[] = []
visited.add(node.id)
while (queue.length > 0) {
const nodeId = queue.shift()!
component.push(nodeId)
for (const neighbor of adjacency.get(nodeId) ?? []) {
if (visited.has(neighbor)) continue
visited.add(neighbor)
queue.push(neighbor)
}
}
components.push(component)
}
return components
}
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
if (nodes.length === 0) return nodes
const nodeById = new Map(nodes.map(node => [node.id, node]))
const edgeByComponent = getWeaklyConnectedComponentIds(nodes, edges)
.map(componentIds => {
const componentNodeIds = new Set(componentIds)
const componentNodes = componentIds
.map(nodeId => nodeById.get(nodeId))
.filter((node): node is Node => Boolean(node))
const componentEdges = edges.filter(
edge => componentNodeIds.has(edge.source) && componentNodeIds.has(edge.target),
)
return {
nodes: componentNodes,
edges: componentEdges,
}
})
.sort((left, right) => {
const leftTop = Math.min(...left.nodes.map(node => node.position.y))
const rightTop = Math.min(...right.nodes.map(node => node.position.y))
const topDelta = leftTop - rightTop
if (topDelta !== 0) return topDelta
const leftLeft = Math.min(...left.nodes.map(node => node.position.x))
const rightLeft = Math.min(...right.nodes.map(node => node.position.x))
return leftLeft - rightLeft
})
const positionedNodes = new Map<string, Node>()
let nextComponentTop = WORKFLOW_LAYOUT_PADDING_Y
const componentGapY = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP * 2
for (const component of edgeByComponent) {
const laidOutComponent = layoutConnectedComponent(component.nodes, component.edges)
const minX = Math.min(...laidOutComponent.map(node => node.position.x))
const minY = Math.min(...laidOutComponent.map(node => node.position.y))
const normalizedComponent = laidOutComponent.map(node => ({
...node,
position: {
x: WORKFLOW_LAYOUT_PADDING_X + (node.position.x - minX),
y: nextComponentTop + (node.position.y - minY),
},
}))
for (const node of normalizedComponent) {
positionedNodes.set(node.id, node)
}
const componentBottom = Math.max(
...normalizedComponent.map(node => node.position.y + WORKFLOW_NODE_MIN_HEIGHT),
)
nextComponentTop = componentBottom + componentGapY
}
return nodes.map(node => positionedNodes.get(node.id) ?? node)
}
@@ -10,8 +10,11 @@ import {
export type WorkflowModuleBundleId =
| 'cad_intake_core'
| 'scene_prep_core'
| 'materials_core'
| 'still_render_core'
| 'output_publish_notify'
| 'blend_export_publish'
export type WorkflowModuleBundleDefinition = {
id: WorkflowModuleBundleId
@@ -47,13 +50,33 @@ const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
stage: AUTHORING_STAGE_LABELS.cad_intake,
stageId: 'cad_intake',
},
{
id: 'scene_prep_core',
label: 'Scene Prep Core',
shortLabel: 'Scene Prep',
description: 'Prepare order-line runtime context, resolve the active template, and compute shared geometry metadata before material or render stages are attached.',
family: 'order_line',
stepIds: ['order_line_setup', 'resolve_template', 'glb_bbox'],
stage: AUTHORING_STAGE_LABELS.scene_prep,
stageId: 'scene_prep',
},
{
id: 'materials_core',
label: 'Materials Core',
shortLabel: 'Materials',
description: 'Populate CAD material candidates and resolve the production material map as a reusable graph stage.',
family: 'order_line',
stepIds: ['auto_populate_materials', 'material_map_resolve'],
stage: AUTHORING_STAGE_LABELS.materials,
stageId: 'materials',
},
{
id: 'still_render_core',
label: 'Still Render Core',
shortLabel: 'Still Render',
description: 'Prepare order-line context, resolve template and materials, compute geometry, and run the still render.',
description: 'Execute the still-render stage against already-prepared scene, template, geometry, and material inputs.',
family: 'order_line',
stepIds: ['order_line_setup', 'resolve_template', 'auto_populate_materials', 'glb_bbox', 'material_map_resolve', 'blender_still'],
stepIds: ['blender_still'],
stage: AUTHORING_STAGE_LABELS.render,
stageId: 'render',
},
@@ -67,6 +90,16 @@ const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
stage: AUTHORING_STAGE_LABELS.publish,
stageId: 'publish',
},
{
id: 'blend_export_publish',
label: 'Blend Export Publish',
shortLabel: 'Blend Publish',
description: 'Publish a .blend artifact, persist it through the standard output handoff, and emit the completion notification branch.',
family: 'order_line',
stepIds: ['export_blend', 'output_save', 'notify'],
stage: AUTHORING_STAGE_LABELS.publish,
stageId: 'publish',
},
]
function buildNodeData(
@@ -0,0 +1,729 @@
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition } from '../../api/workflows'
export const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'blend_asset']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
const CONTRACT_TOKEN_LABELS: Record<string, string> = {
api: 'API',
bbox: 'Bounding Box',
cad: 'CAD',
fps: 'FPS',
glb: 'GLB',
gpu: 'GPU',
id: 'ID',
occ: 'OCC',
step: 'STEP',
stl: 'STL',
usd: 'USD',
}
export type WorkflowNodeContractSummary = {
inputContextLabel: string | null
outputContextLabel: string | null
contextInputs: string[]
requiredInputs: string[]
requiredAnyInputs: string[][]
providedOutputs: string[]
consumedArtifacts: string[]
producedArtifacts: string[]
editableFieldCount: number
editableFieldLabels: string[]
dynamicVariableHint: string | null
authoringPatternLabel: string
authoringPatternDescription: string
}
export type WorkflowNodeContractMetric = {
value: string
title: string
}
export type WorkflowNodeContractPresentation = {
inputMetric: WorkflowNodeContractMetric
variableMetric: WorkflowNodeContractMetric
outputMetric: WorkflowNodeContractMetric
socketRequirementDescription: string
variableSummaryText: string
noSettingsDescription: string
socketCount: number
variableCount: number
declaredOutputCount: number
}
export type WorkflowNodeSocketDescriptor = {
id: string
label: string
roles: string[]
kind: 'required' | 'alternative' | 'provided'
}
export type WorkflowNodeValidationWatchpoint = {
kind: 'context' | 'setup-chain' | 'data-source' | 'runtime-gap' | 'legacy-drift' | 'artifact-flow'
label: string
reason: string
}
export type WorkflowNodeContractSignal = {
id: string
label: string
title?: string
tone: 'default' | 'watch'
}
function definitionHasField(
definition: WorkflowNodeDefinition | undefined,
fieldKey: string,
): boolean {
return (definition?.fields ?? []).some(field => field.key === fieldKey)
}
const ROOT_CONTEXT_INPUTS_BY_CONTEXT: Record<'cad_file' | 'order_line', string[]> = {
cad_file: ['cad_file_record'],
order_line: ['order_line_record'],
}
function dedupeRoles(values: string[]): string[] {
return Array.from(new Set(values.filter(value => value.trim().length > 0)))
}
function createPortId(prefix: string, roles: string[]): string {
return `${prefix}:${roles.join('|')}`
}
export function formatContractValue(value: string): string {
return value
.split('_')
.map(part => {
const normalized = part.trim().toLowerCase()
if (!normalized) return ''
return CONTRACT_TOKEN_LABELS[normalized] ?? (normalized.charAt(0).toUpperCase() + normalized.slice(1))
})
.filter(part => part.length > 0)
.join(' ')
}
export function formatContractValues(values: string[]): string {
return values.map(formatContractValue).join(', ')
}
export function formatContractAlternativeGroup(values: string[]): string {
return `Any of: ${values.map(formatContractValue).join(' / ')}`
}
function formatContractSentenceList(values: string[]): string {
const formatted = values.map(formatContractValue)
if (formatted.length <= 1) {
return formatted[0] ?? ''
}
if (formatted.length === 2) {
return `${formatted[0]} and ${formatted[1]}`
}
return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}`
}
export function getContractValues(
contract: Record<string, unknown> | undefined,
key: string,
): string[] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
}
export function getContractAlternativeValues(
contract: Record<string, unknown> | undefined,
key: string,
): string[][] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
return [value as string[]]
}
return value
.filter((entry): entry is string[] => Array.isArray(entry))
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
.filter(group => group.length > 0)
}
export function getContractContext(
contract: Record<string, unknown> | undefined,
): 'cad_file' | 'order_line' | null {
const value = contract?.context
return value === 'cad_file' || value === 'order_line' ? value : null
}
export function getContractContextLabel(
contract: Record<string, unknown> | undefined,
): string | null {
const context = getContractContext(contract)
if (!context) return null
return context === 'cad_file' ? 'CAD File' : 'Order Line'
}
export function getWorkflowNodeAlternativeInputGroups(
definition: WorkflowNodeDefinition | undefined,
): string[][] {
if (!definition) return []
const alternativeGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | undefined,
'requires_any',
)
if (definition.step === 'output_save' && alternativeGroups.length === 0) {
alternativeGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
}
if (definition.step === 'notify') {
if (alternativeGroups.length === 0) {
alternativeGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else {
alternativeGroups[0] = Array.from(new Set([...alternativeGroups[0], 'blend_asset']))
}
}
return alternativeGroups.map(group => dedupeRoles(group))
}
export function getWorkflowNodeRequiredInputRoles(
definition: WorkflowNodeDefinition | undefined,
): string[] {
if (!definition) return []
const contextInputs = new Set(getWorkflowNodeContextInputs(definition))
const alternativeRoles = new Set(getWorkflowNodeAlternativeInputGroups(definition).flat())
const directRoles = dedupeRoles([
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
return directRoles.filter(role => !alternativeRoles.has(role) && !contextInputs.has(role))
}
export function getWorkflowNodeContextInputs(
definition: WorkflowNodeDefinition | undefined,
): string[] {
if (!definition) return []
const context = getContractContext(definition.input_contract as Record<string, unknown> | undefined)
if (!context) return []
const directRoles = dedupeRoles(
getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
)
const rootContextInputs = new Set(ROOT_CONTEXT_INPUTS_BY_CONTEXT[context] ?? [])
return directRoles.filter(role => rootContextInputs.has(role))
}
export function getWorkflowNodeProvidedOutputRoles(
definition: WorkflowNodeDefinition | undefined,
): string[] {
if (!definition) return []
return dedupeRoles([
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
...(definition.artifact_roles_produced ?? []),
])
}
export function getWorkflowVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
return Array.from(
new Set(
fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
export function getWorkflowNodeEditableFieldLabels(
definition: WorkflowNodeDefinition | undefined,
): string[] {
return getWorkflowVariableLabels(definition?.fields ?? [])
}
export function getWorkflowNodeDynamicVariableHint(
definition: WorkflowNodeDefinition | undefined,
): string | null {
if (!definition) return null
const providedRoles = new Set(getWorkflowNodeProvidedOutputRoles(definition))
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
return 'Template-selected variables appear after choosing a template.'
}
return null
}
export function getWorkflowNodeContractSummary(
definition: WorkflowNodeDefinition | undefined,
): WorkflowNodeContractSummary {
const contextInputs = getWorkflowNodeContextInputs(definition)
const requiredInputs = getWorkflowNodeRequiredInputRoles(definition)
const requiredAnyInputs = getWorkflowNodeAlternativeInputGroups(definition)
const editableFieldLabels = getWorkflowNodeEditableFieldLabels(definition)
const dynamicVariableHint = getWorkflowNodeDynamicVariableHint(definition)
const inspectorVariableCount =
editableFieldLabels.length + (dynamicVariableHint ? 1 : 0)
const upstreamSocketCount = requiredInputs.length + requiredAnyInputs.length
let authoringPatternLabel = 'Hybrid'
let authoringPatternDescription =
'Wire required upstream artifacts on the canvas and configure local variables in the inspector.'
if (contextInputs.length > 0 && upstreamSocketCount === 0 && inspectorVariableCount === 0) {
authoringPatternLabel = 'Context Entry'
authoringPatternDescription =
'The workflow context supplies the required record. No upstream canvas wiring is needed before this node can run.'
} else if (upstreamSocketCount > 0 && inspectorVariableCount === 0) {
authoringPatternLabel = 'Connection-Driven'
authoringPatternDescription =
'Configure this node by wiring upstream artifacts. It has no local inspector variables.'
} else if (upstreamSocketCount === 0) {
authoringPatternLabel = 'Inspector-Driven'
authoringPatternDescription =
'Configure this node entirely in the inspector. It does not require upstream artifact wiring.'
}
return {
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
contextInputs,
requiredInputs,
requiredAnyInputs,
providedOutputs: getWorkflowNodeProvidedOutputRoles(definition),
consumedArtifacts: definition?.artifact_roles_consumed ?? [],
producedArtifacts: definition?.artifact_roles_produced ?? [],
editableFieldCount: definition?.fields.length ?? 0,
editableFieldLabels,
dynamicVariableHint,
authoringPatternLabel,
authoringPatternDescription,
}
}
export function getWorkflowNodeInputSocketDescriptors(
contract: WorkflowNodeContractSummary,
): WorkflowNodeSocketDescriptor[] {
return [
...contract.requiredInputs.map(role => ({
id: createPortId('input', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'required' as const,
})),
...contract.requiredAnyInputs.map(group => ({
id: createPortId('input-any', group),
label: formatContractAlternativeGroup(group),
roles: group,
kind: 'alternative' as const,
})),
]
}
export function getWorkflowNodeOutputSocketDescriptors(
contract: WorkflowNodeContractSummary,
): WorkflowNodeSocketDescriptor[] {
return contract.providedOutputs.map(role => ({
id: createPortId('output', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'provided' as const,
}))
}
export function getWorkflowNodeSocketCount(contract: WorkflowNodeContractSummary): number {
return contract.requiredInputs.length + contract.requiredAnyInputs.length
}
export function getWorkflowNodeInspectorVariableLabels(
contract: WorkflowNodeContractSummary,
dynamicVariableLabels: string[] = [],
): string[] {
return Array.from(new Set([...contract.editableFieldLabels, ...dynamicVariableLabels]))
}
export function getWorkflowNodeVariableCount(contract: WorkflowNodeContractSummary): number {
return getWorkflowNodeInspectorVariableLabels(contract).length + (contract.dynamicVariableHint ? 1 : 0)
}
export function getWorkflowNodeTotalVariableCount(
contract: WorkflowNodeContractSummary,
dynamicVariableLabels: string[] = [],
): number {
return getWorkflowNodeInspectorVariableLabels(contract, dynamicVariableLabels).length
}
export function getWorkflowNodeDeclaredOutputCount(contract: WorkflowNodeContractSummary): number {
return contract.providedOutputs.length
}
export function getWorkflowNodeInputMetric(
contract: WorkflowNodeContractSummary,
): WorkflowNodeContractMetric {
const socketCount = getWorkflowNodeSocketCount(contract)
if (contract.contextInputs.length > 0 && socketCount === 0) {
return {
value: `Context only (${contract.contextInputs.length})`,
title: formatContractValues(contract.contextInputs),
}
}
return {
value: `${socketCount} socket${socketCount === 1 ? '' : 's'}`,
title:
socketCount > 0
? `Required: ${contract.requiredInputs.length}, alternative groups: ${contract.requiredAnyInputs.length}`
: 'No upstream inputs required',
}
}
export function getWorkflowNodeVariableMetric(
contract: WorkflowNodeContractSummary,
): WorkflowNodeContractMetric {
const variableCount = getWorkflowNodeVariableCount(contract)
return {
value: `${variableCount} inspector`,
title:
contract.editableFieldLabels.length > 0
? contract.editableFieldLabels.join(', ')
: contract.dynamicVariableHint ?? 'No local inspector variables',
}
}
export function getWorkflowNodeOutputMetric(
contract: WorkflowNodeContractSummary,
): WorkflowNodeContractMetric {
const outputCount = contract.providedOutputs.length
return {
value: `${outputCount} role${outputCount === 1 ? '' : 's'}`,
title:
outputCount > 0
? formatContractValues(contract.providedOutputs)
: 'No downstream output roles',
}
}
export function getWorkflowNodeSocketRequirementDescription(
contract: WorkflowNodeContractSummary,
): string {
const socketCount = getWorkflowNodeSocketCount(contract)
const requiredSocketCount = contract.requiredInputs.length
const alternativeGroupCount = contract.requiredAnyInputs.length
if (socketCount === 0) {
if (contract.contextInputs.length > 0) {
return `Workflow context supplies ${formatContractValues(contract.contextInputs)}. No additional upstream sockets are required.`
}
return 'This entry node does not declare additional upstream sockets.'
}
if (requiredSocketCount === 1 && alternativeGroupCount === 0) {
return `This node waits for ${formatContractValue(contract.requiredInputs[0])} from upstream.`
}
if (requiredSocketCount > 1 && alternativeGroupCount === 0) {
return `Wire ${requiredSocketCount} required upstream sockets: ${formatContractSentenceList(contract.requiredInputs)}.`
}
if (requiredSocketCount === 0 && alternativeGroupCount === 1) {
return `This node accepts one upstream artifact from ${formatContractAlternativeGroup(contract.requiredAnyInputs[0]).toLowerCase()}.`
}
if (requiredSocketCount === 0 && alternativeGroupCount > 1) {
return `Connect one upstream artifact for each of the ${alternativeGroupCount} alternative socket groups before dispatch.`
}
if (requiredSocketCount === 1 && alternativeGroupCount === 1) {
return `This node requires ${formatContractValue(contract.requiredInputs[0])} and one additional upstream artifact from ${formatContractAlternativeGroup(contract.requiredAnyInputs[0]).toLowerCase()}.`
}
return `Wire ${requiredSocketCount} required upstream sockets and satisfy ${alternativeGroupCount} alternative input group${alternativeGroupCount === 1 ? '' : 's'} before dispatch.`
}
export function getWorkflowNodeVariableSummaryText(
contract: WorkflowNodeContractSummary,
dynamicVariableLabels: string[] = [],
): string {
const variableCount = getWorkflowNodeTotalVariableCount(contract, dynamicVariableLabels)
if (variableCount > 0) {
return `${variableCount} local variable${variableCount === 1 ? ' is' : 's are'} edited in the inspector.`
}
if (contract.dynamicVariableHint) {
return contract.dynamicVariableHint
}
return 'This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.'
}
export function getWorkflowNodeNoSettingsDescription(
contract: WorkflowNodeContractSummary,
): string {
if (contract.contextInputs.length > 0) {
return `Workflow context already provides ${formatContractValues(contract.contextInputs)}.`
}
return getWorkflowNodeSocketRequirementDescription(contract)
}
export function getWorkflowNodeContractPresentation(
contract: WorkflowNodeContractSummary,
dynamicVariableLabels: string[] = [],
): WorkflowNodeContractPresentation {
return {
inputMetric: getWorkflowNodeInputMetric(contract),
variableMetric: getWorkflowNodeVariableMetric(contract),
outputMetric: getWorkflowNodeOutputMetric(contract),
socketRequirementDescription: getWorkflowNodeSocketRequirementDescription(contract),
variableSummaryText: getWorkflowNodeVariableSummaryText(contract, dynamicVariableLabels),
noSettingsDescription: getWorkflowNodeNoSettingsDescription(contract),
socketCount: getWorkflowNodeSocketCount(contract),
variableCount: getWorkflowNodeTotalVariableCount(contract, dynamicVariableLabels),
declaredOutputCount: getWorkflowNodeDeclaredOutputCount(contract),
}
}
export function getWorkflowNodeValidationWatchpoints(
definition: WorkflowNodeDefinition | undefined,
contract: WorkflowNodeContractSummary,
): WorkflowNodeValidationWatchpoint[] {
if (!definition) return []
const watchpoints: WorkflowNodeValidationWatchpoint[] = []
const consumedRoles = new Set([
...contract.requiredInputs,
...contract.requiredAnyInputs.flat(),
...contract.consumedArtifacts,
])
const producedRoles = new Set([
...contract.providedOutputs,
...contract.producedArtifacts,
])
if (contract.inputContextLabel) {
watchpoints.push({
kind: 'context',
label: 'Context',
reason: `${definition.label} expects ${contract.inputContextLabel} workflow context before it can run.`,
})
}
if (contract.contextInputs.includes('order_line_record') || producedRoles.has('order_line_context')) {
watchpoints.push({
kind: 'setup-chain',
label: 'Setup Chain',
reason: 'This node participates in order-line setup and will fail if setup prerequisites are missing or not renderable.',
})
}
if (
['cad_file_record', 'step_path', 'glb_preview', 'glb_reuse_path'].some(
role => consumedRoles.has(role) || producedRoles.has(role),
)
) {
watchpoints.push({
kind: 'data-source',
label: 'Data Source',
reason: 'This node depends on stored CAD or derived geometry files being present on disk and resolvable at runtime.',
})
}
if (definition.execution_kind === 'bridge') {
watchpoints.push({
kind: 'runtime-gap',
label: 'Runtime Gap',
reason: 'This step still runs through bridge execution. Verify native graph coverage before promoting it to full graph rollout.',
})
}
if (
definition.step === 'resolve_template' ||
consumedRoles.has('render_template') ||
producedRoles.has('render_template') ||
producedRoles.has('template_inputs')
) {
watchpoints.push({
kind: 'legacy-drift',
label: 'Legacy Drift',
reason: 'Template resolution and render-template handoff must stay aligned with the legacy production path.',
})
}
if (getWorkflowNodeSocketCount(contract) > 0 || contract.consumedArtifacts.length > 0) {
watchpoints.push({
kind: 'artifact-flow',
label: 'Artifact Flow',
reason: 'This node relies on upstream artifacts or sockets. Missing connections will surface as artifact-flow issues during preflight.',
})
}
return watchpoints.filter(
(watchpoint, index, items) => items.findIndex(item => item.kind === watchpoint.kind) === index,
)
}
export function getWorkflowNodeContractSignals(
definition: WorkflowNodeDefinition | undefined,
contract: WorkflowNodeContractSummary,
limits: Partial<{
contextInputs: number
requiredInputs: number
providedOutputs: number
consumedArtifacts: number
producedArtifacts: number
watchpoints: number
}> = {},
): WorkflowNodeContractSignal[] {
const {
contextInputs = 1,
requiredInputs = 2,
providedOutputs = 2,
consumedArtifacts = 1,
producedArtifacts = 1,
watchpoints = 2,
} = limits
const signals: WorkflowNodeContractSignal[] = [
{
id: 'authoring-pattern',
label: contract.authoringPatternLabel,
title: contract.authoringPatternDescription,
tone: 'default',
},
]
if (
definition?.step === 'resolve_template' ||
contract.providedOutputs.includes('render_template') ||
contract.providedOutputs.includes('template_inputs') ||
contract.producedArtifacts.includes('template_inputs')
) {
signals.push({
id: 'template-contract',
label: 'Template Inputs',
title: 'This module resolves render-template state and can expose template-defined workflow variables.',
tone: 'default',
})
}
if (definitionHasField(definition, 'use_custom_render_settings')) {
signals.push({
id: 'render-overrides',
label: 'Render Overrides',
title: 'Output Type and Template stay authoritative until Custom Render Settings is enabled.',
tone: 'default',
})
}
if (definitionHasField(definition, 'transparent_bg')) {
signals.push({
id: 'alpha-output',
label: 'Alpha Output',
title: 'This module can emit transparent renders for PNG or compositing-driven output types.',
tone: 'default',
})
}
if (contract.providedOutputs.includes('blend_asset') || contract.producedArtifacts.includes('blend_asset')) {
signals.push({
id: 'blend-delivery',
label: 'Blend Delivery',
title: 'This module emits a downstream blend asset instead of final pixels.',
tone: 'default',
})
}
if (definition?.step === 'output_save') {
signals.push({
id: 'publish-handoff',
label: 'Publish Handoff',
title: 'This module persists the connected artifact as the authoritative workflow result.',
tone: 'default',
})
}
if (definition?.step === 'notify') {
signals.push({
id: 'notification-handoff',
label: 'Notification Handoff',
title: 'This module depends on an armed upstream render or export handoff before it can emit notifications.',
tone: 'default',
})
}
if (contract.inputContextLabel) {
signals.push({
id: `input-context:${contract.inputContextLabel}`,
label: `In ${contract.inputContextLabel}`,
tone: 'default',
})
}
contract.contextInputs.slice(0, contextInputs).forEach(input => {
signals.push({
id: `context-input:${input}`,
label: `Context ${formatContractValue(input)}`,
tone: 'default',
})
})
if (contract.outputContextLabel) {
signals.push({
id: `output-context:${contract.outputContextLabel}`,
label: `Out ${contract.outputContextLabel}`,
tone: 'default',
})
}
contract.requiredInputs.slice(0, requiredInputs).forEach(input => {
signals.push({
id: `requires:${input}`,
label: `Requires ${formatContractValue(input)}`,
tone: 'default',
})
})
contract.providedOutputs.slice(0, providedOutputs).forEach(output => {
signals.push({
id: `provides:${output}`,
label: `Provides ${formatContractValue(output)}`,
tone: 'default',
})
})
contract.consumedArtifacts.slice(0, consumedArtifacts).forEach(artifact => {
signals.push({
id: `consumes:${artifact}`,
label: `Consumes ${formatContractValue(artifact)}`,
tone: 'default',
})
})
contract.producedArtifacts.slice(0, producedArtifacts).forEach(artifact => {
signals.push({
id: `produces:${artifact}`,
label: `Produces ${formatContractValue(artifact)}`,
tone: 'default',
})
})
getWorkflowNodeValidationWatchpoints(definition, contract)
.slice(0, watchpoints)
.forEach(watchpoint => {
signals.push({
id: `watch:${watchpoint.kind}`,
label: `Watch ${watchpoint.label}`,
title: watchpoint.reason,
tone: 'watch',
})
})
return signals
}
@@ -14,7 +14,10 @@ import {
} from './workflowGraphDraft'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowReferenceBundleId = 'still_render_reference'
export type WorkflowReferenceBundleId =
| 'still_render_reference'
| 'still_render_alpha_reference'
| 'still_render_blend_reference'
| 'cad_intake_reference'
export type WorkflowReferenceBundleDefinition = {
@@ -73,6 +76,43 @@ const WORKFLOW_REFERENCE_BUNDLE_REGISTRY: WorkflowReferenceBundleDefinition[] =
],
stage: 'Reference Path',
},
{
id: 'still_render_alpha_reference',
label: 'Still Render Alpha Reference',
shortLabel: 'Still Alpha',
description: 'Insert the canonical still-render path with transparent-alpha defaults so compositing-ready PNG outputs stay workflow-first.',
family: 'order_line',
stepIds: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
],
stage: 'Reference Path',
},
{
id: 'still_render_blend_reference',
label: 'Still Render + Blend Reference',
shortLabel: 'Still + Blend',
description: 'Insert the canonical still-render path plus explicit .blend export delivery nodes so still and blend output-types can bind to the same graph family cleanly.',
family: 'order_line',
stepIds: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
'export_blend',
],
stage: 'Reference Path',
},
]
function buildNodeData(
@@ -102,6 +142,10 @@ function getReferenceTemplate(bundleId: WorkflowReferenceBundleId) {
return toTemplate(buildWorkflowBlueprintConfig('cad_intake'))
case 'still_render_reference':
return toTemplate(buildWorkflowBlueprintConfig('still_graph_reference'))
case 'still_render_alpha_reference':
return toTemplate(buildWorkflowBlueprintConfig('still_graph_alpha_reference'))
case 'still_render_blend_reference':
return toTemplate(buildWorkflowBlueprintConfig('still_graph_blend_reference'))
default:
return null
}
@@ -0,0 +1,277 @@
import type { WorkflowPreflightIssue } from '../../api/workflows'
export type WorkflowValidationKind =
| 'context'
| 'setup-chain'
| 'data-source'
| 'runtime-gap'
| 'legacy-drift'
| 'artifact-flow'
| 'general'
export type WorkflowValidationSeverity = 'error' | 'warning' | 'info'
export type WorkflowValidationSummaryItem = {
kind: WorkflowValidationKind
label: string
count: number
severity: WorkflowValidationSeverity
}
export function getWorkflowValidationKind(
issue: Pick<WorkflowPreflightIssue, 'code' | 'message'>,
): WorkflowValidationKind {
switch (issue.code) {
case 'invalid_context_id':
case 'context_not_found':
case 'context_kind_mismatch':
case 'invalid_context_kind':
case 'cad_file_only_node':
return 'context'
case 'order_line_missing':
case 'order_line_not_renderable':
case 'order_line_skipped':
case 'missing_order_line_setup':
case 'setup_not_ready':
return 'setup-chain'
case 'cad_file_missing_path':
case 'cad_file_step_missing':
case 'bbox_unresolved':
return 'data-source'
case 'unsupported_node':
return 'runtime-gap'
case 'missing_resolve_template':
case 'template_missing':
return 'legacy-drift'
default:
if (
issue.code.toLowerCase().includes('artifact') ||
issue.message.toLowerCase().includes('artifact')
) {
return 'artifact-flow'
}
return 'general'
}
}
export function getWorkflowValidationKindLabel(kind: WorkflowValidationKind): string {
switch (kind) {
case 'context':
return 'Context'
case 'setup-chain':
return 'Setup Chain'
case 'data-source':
return 'Data Source'
case 'runtime-gap':
return 'Runtime Gap'
case 'legacy-drift':
return 'Legacy Drift'
case 'artifact-flow':
return 'Artifact Flow'
default:
return 'General'
}
}
export function getWorkflowPreflightActionHint(
issue: Pick<WorkflowPreflightIssue, 'code' | 'message'>,
): string {
const message = issue.message.toLowerCase()
switch (issue.code) {
case 'invalid_context_id':
return 'Use a valid UUID from the matching order line or CAD file record before running preflight again.'
case 'context_not_found':
return 'Pick an existing order line or CAD file. The supplied ID does not resolve to a stored workflow context.'
case 'order_line_missing':
return 'Reload the order-line context or repair the underlying record before dispatching this graph.'
case 'order_line_not_renderable':
return 'Fix the order-line render prerequisites first, then rerun preflight to confirm the setup path is renderable.'
case 'order_line_skipped':
return 'This order line would be skipped by legacy setup logic. Clear the skip reason before promoting the graph path.'
case 'missing_order_line_setup':
return 'Insert an "Order Line Setup" node earlier in the graph and reconnect the downstream chain.'
case 'missing_resolve_template':
return 'Add a "Resolve Template" node before render or export nodes to keep graph behavior aligned with legacy.'
case 'context_kind_mismatch':
case 'invalid_context_kind':
return 'Use a context ID that matches the workflow family, or split the workflow into the correct family.'
case 'cad_file_only_node':
return 'Move this node into a cad_file workflow, or replace it with an order-line-safe module for render graphs.'
case 'setup_not_ready':
return 'Repair the order-line setup prerequisites and rerun preflight until the setup chain reports ready.'
case 'unsupported_node':
return 'Keep this path on legacy/bridge execution for now, or replace the node with a supported graph module.'
case 'template_missing':
return 'Assign an active render template or use a template override on the resolve_template node.'
case 'bbox_unresolved':
return 'Ensure a reusable GLB exists upstream or provide a valid GLB override for the bounding-box node.'
case 'cad_file_missing_path':
case 'cad_file_step_missing':
return 'Repair the CAD source path before dispatching the graph.'
default:
if (
message.includes('artifact') ||
message.includes('produced upstream') ||
message.includes('available before this step') ||
message.includes('missing upstream')
) {
return 'Add the missing upstream node or connection so the required artifact is available before this step.'
}
return 'Resolve this issue before relying on graph dispatch for this workflow.'
}
}
export function getWorkflowDraftValidationKind(
message: string,
): WorkflowValidationKind {
const normalized = message.toLowerCase()
if (
normalized.includes('context') ||
normalized.includes('cad-file and order-line') ||
normalized.includes('workflow is cad file based') ||
normalized.includes('workflow is order line based')
) {
return 'context'
}
if (
normalized.includes('order line setup') ||
normalized.includes('renderable') ||
normalized.includes('requires an order-line workflow')
) {
return 'setup-chain'
}
if (
normalized.includes('step path') ||
normalized.includes('cad preview') ||
normalized.includes('cad input') ||
normalized.includes('glb') ||
normalized.includes('bbox') ||
normalized.includes('geometry')
) {
return 'data-source'
}
if (
normalized.includes('resolve template') ||
normalized.includes('legacy behavior') ||
normalized.includes('drift from legacy')
) {
return 'legacy-drift'
}
if (
normalized.includes('upstream input') ||
normalized.includes('upstream artifact') ||
normalized.includes('socket') ||
normalized.includes('artifact')
) {
return 'artifact-flow'
}
return 'general'
}
export function summarizeWorkflowDraftValidationMessages(
messages: string[],
): Array<{ kind: WorkflowValidationKind; label: string; count: number }> {
const counts = new Map<WorkflowValidationKind, number>()
messages.forEach(message => {
const kind = getWorkflowDraftValidationKind(message)
counts.set(kind, (counts.get(kind) ?? 0) + 1)
})
return Array.from(counts.entries()).map(([kind, count]) => ({
kind,
label: getWorkflowValidationKindLabel(kind),
count,
}))
}
function summarizeValidationEntries(
entries: Array<{ kind: WorkflowValidationKind; severity: WorkflowValidationSeverity }>,
): WorkflowValidationSummaryItem[] {
const counts = new Map<string, WorkflowValidationSummaryItem>()
entries.forEach(entry => {
const key = `${entry.severity}:${entry.kind}`
const current = counts.get(key)
if (current) {
current.count += 1
return
}
counts.set(key, {
kind: entry.kind,
label: getWorkflowValidationKindLabel(entry.kind),
count: 1,
severity: entry.severity,
})
})
return Array.from(counts.values()).sort((left, right) => {
const severityOrder: Record<WorkflowValidationSeverity, number> = {
error: 0,
warning: 1,
info: 2,
}
return (
severityOrder[left.severity] - severityOrder[right.severity] ||
left.label.localeCompare(right.label)
)
})
}
export function summarizeWorkflowDraftValidationBySeverity({
errors,
warnings,
infos = [],
}: {
errors: string[]
warnings: string[]
infos?: string[]
}): WorkflowValidationSummaryItem[] {
return summarizeValidationEntries([
...errors.map(message => ({
kind: getWorkflowDraftValidationKind(message),
severity: 'error' as const,
})),
...warnings.map(message => ({
kind: getWorkflowDraftValidationKind(message),
severity: 'warning' as const,
})),
...infos.map(message => ({
kind: getWorkflowDraftValidationKind(message),
severity: 'info' as const,
})),
])
}
export function summarizeWorkflowPreflightIssues(
issues: Array<Pick<WorkflowPreflightIssue, 'code' | 'message' | 'severity'>>,
): WorkflowValidationSummaryItem[] {
return summarizeValidationEntries(
issues.map(issue => ({
kind: getWorkflowValidationKind(issue),
severity: issue.severity,
})),
)
}
export function getWorkflowValidationSummaryToneClassName(
severity: WorkflowValidationSeverity,
): string {
switch (severity) {
case 'error':
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300'
case 'warning':
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300'
default:
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-300'
}
}
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { fetchBranding } from '../api/branding'
export function useBranding() {
const { data } = useQuery({
queryKey: ['branding'],
queryFn: fetchBranding,
staleTime: 5 * 60 * 1000,
retry: false,
})
return {
appName: data?.app_name ?? 'Hart.O.Mat',
appSubtitle: data?.app_subtitle ?? 'Hartomatisierung',
}
}
+1
View File
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import App from './App'
import './index.css'
import '@xyflow/react/dist/style.css'
import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey } from './store/theme'
/* ---------------------------------------------------------------
+83 -2
View File
@@ -82,6 +82,8 @@ export default function AdminPage() {
})
type Settings = {
app_name: string
app_subtitle: string
thumbnail_renderer: string
blender_engine: string
blender_cycles_samples: number
@@ -117,6 +119,9 @@ export default function AdminPage() {
enabled: isAdmin,
})
const [brandingDraft, setBrandingDraft] = useState<Partial<Settings>>({})
const branding = { ...settings, ...brandingDraft } as Settings
// Local draft for Blender options so the user can change multiple fields before saving
const [blenderDraft, setBlenderDraft] = useState<Partial<Settings>>({})
const blender = { ...settings, ...blenderDraft } as Settings
@@ -130,9 +135,12 @@ export default function AdminPage() {
const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
onSuccess: () => {
onSuccess: (_data, variables) => {
toast.success('Settings saved')
qc.invalidateQueries({ queryKey: ['admin-settings'] })
if ('app_name' in variables || 'app_subtitle' in variables) {
qc.invalidateQueries({ queryKey: ['branding'] })
}
setBlenderDraft({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
@@ -309,7 +317,7 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
type AdminTab = 'overview' | 'users' | 'branding' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
// Blender Status (via Celery on render-worker)
@@ -324,6 +332,7 @@ export default function AdminPage() {
})
const hasUnsavedChanges =
Object.keys(brandingDraft).length > 0 ||
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
@@ -332,6 +341,7 @@ export default function AdminPage() {
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'branding', label: 'Branding' },
{ id: 'render-settings', label: 'Render Settings' },
{ id: 'output-types', label: 'Output Types' },
{ id: 'templates', label: 'Templates & Positions' },
@@ -533,6 +543,77 @@ export default function AdminPage() {
</div>
</div>}
{/* ================================================================== */}
{/* Branding */}
{/* ================================================================== */}
{activeTab === 'branding' && isAdmin && (
<div className="mb-6">
<div className="card p-6 max-w-lg">
<div className="flex items-center gap-2 mb-6">
<Monitor size={18} className="text-accent" />
<h2 className="text-base font-semibold text-content">App Branding</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Application Name
</label>
<input
type="text"
value={branding.app_name ?? 'Hart.O.Mat'}
onChange={(e) => setBrandingDraft((d) => ({ ...d, app_name: e.target.value }))}
maxLength={100}
className="input-base w-full"
placeholder="Hart.O.Mat"
/>
<p className="text-xs text-content-muted mt-1">
Shown in the sidebar, mobile header, and login page.
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Subtitle
</label>
<input
type="text"
value={branding.app_subtitle ?? 'Hartomatisierung'}
onChange={(e) => setBrandingDraft((d) => ({ ...d, app_subtitle: e.target.value }))}
maxLength={100}
className="input-base w-full"
placeholder="Hartomatisierung"
/>
<p className="text-xs text-content-muted mt-1">
Shown below the app name in the sidebar.
</p>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
className="btn-primary"
onClick={() => {
updateSettingsMut.mutate(brandingDraft, {
onSuccess: () => {
setBrandingDraft({})
},
})
}}
disabled={Object.keys(brandingDraft).length === 0 || updateSettingsMut.isPending}
>
{updateSettingsMut.isPending ? 'Saving...' : 'Save Branding'}
</button>
{Object.keys(brandingDraft).length > 0 && (
<button
className="btn-secondary"
onClick={() => setBrandingDraft({})}
>
Discard
</button>
)}
</div>
</div>
</div>
)}
{/* ================================================================== */}
{/* Render Settings */}
{/* ================================================================== */}
+1 -1
View File
@@ -119,7 +119,7 @@ export default function CadPreviewPage() {
<ThreeDViewer
cadFileId={id}
onClose={() => navigate(-1)}
geometryGltfUrl={latestGltf?.download_url ?? undefined}
glbUrl={latestGltf?.download_url ?? undefined}
hasGeometryGlb={!!latestGltf}
isGeneratingGeometry={generating}
onGenerateGeometry={() => generateMutation.mutate()}
+3 -1
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
import { useBranding } from '../hooks/useBranding'
export default function LoginPage() {
const navigate = useNavigate()
@@ -13,6 +14,7 @@ export default function LoginPage() {
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const { appName } = useBranding()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -36,7 +38,7 @@ export default function LoginPage() {
<div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl font-bold">S</span>
</div>
<h1 className="text-2xl font-bold text-content">HartOMat</h1>
<h1 className="text-2xl font-bold text-content">{appName}</h1>
<p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
</div>
+1 -1
View File
@@ -20,7 +20,7 @@ const EVENT_LABELS: Record<string, string> = {
const ALL_EVENTS = Object.keys(EVENT_LABELS)
const CHANNELS: Array<{ key: 'in_app' | 'email'; label: string; comingSoon?: boolean }> = [
{ key: 'in_app', label: 'In-App' },
{ key: 'email', label: 'E-Mail', comingSoon: true },
{ key: 'email', label: 'E-Mail' },
]
const FREQUENCY_OPTIONS: Array<{ value: string; label: string }> = [

Some files were not shown because too many files have changed in this diff Show More