Compare commits
28
Commits
9f1ccec8f0
...
271ce510c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271ce510c1 | ||
|
|
ed61fd0328 | ||
|
|
34ed511d9d | ||
|
|
23605783bd | ||
|
|
3401b06b19 | ||
|
|
10a8bbd977 | ||
|
|
507858cf31 | ||
|
|
3c2d0816e5 | ||
|
|
df304dc021 | ||
|
|
b69190dd86 | ||
|
|
a11d2fb1e7 | ||
|
|
1c4ca6998f | ||
|
|
e8b4580608 | ||
|
|
53b19f2ba1 | ||
|
|
4ea6c4ee9c | ||
|
|
4946cc300a | ||
|
|
c262b31bc5 | ||
|
|
c87857f836 | ||
|
|
44b52d05cc | ||
|
|
971074fedd | ||
|
|
7179023458 | ||
|
|
bb2b0f51b5 | ||
|
|
6459b9e62d | ||
|
|
d2e4934cca | ||
|
|
c51dd8cd67 | ||
|
|
84f40647b0 | ||
|
|
715cef7971 | ||
|
|
98455ee579 |
@@ -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": {
|
||||
|
||||
@@ -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 300–301), 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
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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=["*"],
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 2–5 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)
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
|
||||
@@ -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 */}
|
||||
{/* ================================================================== */}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user