feat: workflow graph system — blocks 1-20 complete checkpoint

Full graph-based workflow execution engine with:
- WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks)
- Node registry with contract validation, socket types, execution kinds
- WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B
- WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints
- OutputTypeContracts: workflow binding, rollout-mode resolution
- Admin router: output-type workflow binding endpoints
- Frontend: complete workflow editor with drag-drop canvas, node inspector,
  module bundles, reference bundles, preflight panel, validation banner,
  authoring guidance, blueprint templates, shadow/rollout gate UI
- Tests: comprehensive coverage for all workflow modules
- Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY

All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:09:54 +02:00
co-authored by Claude Sonnet 4.6
parent c51dd8cd67
commit d2e4934cca
63 changed files with 7035 additions and 2140 deletions
+8
View File
@@ -735,6 +735,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))
@@ -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):
+2
View File
@@ -347,6 +347,8 @@ class WorkflowOrderLineContextOptionOut(BaseModel):
value: uuid.UUID
label: str
meta: str
is_renderable: bool = True
renderability_reason: str | None = None
class WorkflowOrderLineContextGroupOut(BaseModel):
@@ -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,
},
@@ -142,6 +142,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",
@@ -1135,16 +1173,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)
@@ -1058,10 +1058,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 +1140,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,
@@ -322,6 +321,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 +366,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"
@@ -444,7 +470,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 +598,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 +823,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 +848,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
@@ -948,7 +1022,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,
@@ -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(
{
@@ -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',
])
})
})
+109 -46
View File
@@ -4,7 +4,12 @@ import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './ou
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export type WorkflowBlueprintType = 'cad_intake' | 'order_rendering' | 'still_graph_reference'
export type WorkflowBlueprintType =
| 'cad_intake'
| 'order_rendering'
| 'still_graph_reference'
| 'still_graph_alpha_reference'
| 'still_graph_blend_reference'
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
export interface WorkflowRolloutLatestRun {
@@ -173,6 +178,8 @@ export interface WorkflowOrderLineContextOption {
value: string
label: string
meta: string
is_renderable: boolean
renderability_reason: string | null
}
export interface WorkflowOrderLineContextGroup {
@@ -379,49 +386,89 @@ function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): Work
return normalizeRenderParams(match?.params ?? {})
}
function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
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, {
label: 'Auto Populate Materials',
type: 'processNode',
}),
buildWorkflowNode('bbox', 'glb_bbox', 220, 40, {
label: 'Compute Bounding Box',
type: 'processNode',
}),
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, {
label: 'Resolve Material Map',
type: 'processNode',
}),
buildWorkflowNode('render', 'blender_still', 680, 160, {
label: 'Still Render',
type: 'renderNode',
params: resolvedRenderParams,
}),
buildWorkflowNode('output', 'output_save', 920, 120, {
label: 'Save Output',
type: 'outputNode',
}),
buildWorkflowNode('notify', 'notify', 920, 220, {
label: 'Notify Result',
type: 'outputNode',
}),
]
const edges: WorkflowEdge[] = [
{ from: 'setup', to: 'template' },
{ from: 'setup', to: 'populate_materials' },
{ from: 'setup', to: 'bbox' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'populate_materials', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'render' },
{ from: 'bbox', to: 'render' },
{ 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: [
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, {
label: 'Auto Populate Materials',
type: 'processNode',
}),
buildWorkflowNode('bbox', 'glb_bbox', 220, 40, {
label: 'Compute Bounding Box',
type: 'processNode',
}),
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, {
label: 'Resolve Material Map',
type: 'processNode',
}),
buildWorkflowNode('render', 'blender_still', 680, 160, {
label: 'Still Render',
type: 'renderNode',
params: { use_custom_render_settings: false, ...renderParams },
}),
buildWorkflowNode('output', 'output_save', 920, 120, {
label: 'Save Output',
type: 'outputNode',
}),
buildWorkflowNode('notify', 'notify', 920, 220, {
label: 'Notify Result',
type: 'outputNode',
}),
],
edges: [
{ from: 'setup', to: 'template' },
{ from: 'setup', to: 'populate_materials' },
{ from: 'setup', to: 'bbox' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'populate_materials', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'render' },
{ from: 'bbox', to: 'render' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
nodes,
edges,
}
}
@@ -681,12 +728,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 +876,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,
@@ -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'}
</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}
<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>
{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>
</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">
Active
{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>
<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>
<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>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{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">
Operational
{hasIncompleteStage ? 'Needs attention' : 'Operational'}
</span>
</div>
</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,70 +189,150 @@ 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>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Start
</p>
<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>
</div>
</summary>
<div className="mt-3 flex flex-wrap gap-2">
{referenceBundles.map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertReferencePath?.(bundle.id)}
disabled={!onInsertReferencePath}
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 disabled:cursor-not-allowed disabled:opacity-60"
>
<Milestone size={12} />
Insert {bundle.shortLabel}
</button>
))}
<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>
{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 className="mt-2 flex flex-wrap gap-2">
{referenceBundles.map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertReferencePath?.(bundle.id)}
disabled={!onInsertReferencePath}
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 disabled:cursor-not-allowed disabled:opacity-60"
>
<Milestone size={12} />
Insert {bundle.shortLabel}
</button>
))}
</div>
</div>
)}
{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>
</div>
</details>
)}
{graphFamily !== 'mixed' && onSelectStep && (
{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>
<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.
<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>
<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,18 +340,18 @@ 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}
type="button"
onClick={() => onSelectStep(definition.step)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
Add {definition.label}
</button>
))}
<button
key={definition.step}
type="button"
onClick={() => onSelectStep(definition.step)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
Add {definition.label}
</button>
))}
</div>
</div>
)}
@@ -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,418 @@
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
}
export function WorkflowCanvas({ workflow, onSave, isSaving }: 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}
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,6 +120,7 @@ interface WorkflowCanvasToolbarProps {
canPreflight: boolean
canDispatch: boolean
hasValidationErrors: boolean
validationSummary?: WorkflowValidationSummaryItem[]
isPreflightPending: boolean
isDispatchPending: boolean
isContextOptionsLoading: boolean
@@ -155,6 +170,7 @@ export function WorkflowCanvasToolbar({
canPreflight,
canDispatch,
hasValidationErrors,
validationSummary = [],
isPreflightPending,
isDispatchPending,
isContextOptionsLoading,
@@ -188,13 +204,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,80 +243,120 @@ 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>
</div>
{blueprintDescription && <p className="text-[11px] text-content-muted">{blueprintDescription}</p>}
{(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>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 self-start">
<ToolbarActionButton
onClick={authoringActions.openNodeMenu}
disabled={!authoringActions.openNodeMenu}
title={authoringEntryAction.title}
>
<AuthoringEntryIcon size={14} />
{authoringEntryAction.label}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onAutoLayout}
disabled={!canAutoLayout}
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
title="Delete the currently selected connection(s)"
>
<Trash2 size={14} />
{selectedEdgeLabel}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onPreflight}
disabled={!canPreflight || isPreflightPending || hasValidationErrors}
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDispatch}
disabled={!canDispatch || isDispatchPending || hasValidationErrors}
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onSave}
disabled={isSaving || hasValidationErrors}
tone="primary"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</ToolbarActionButton>
<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}
title={authoringEntryAction.title}
>
<AuthoringEntryIcon size={14} />
{authoringEntryAction.label}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onAutoLayout}
disabled={!canAutoLayout}
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
title="Delete the currently selected connection(s)"
>
<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}
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDispatch}
disabled={!canDispatch || isDispatchPending || hasValidationErrors}
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onSave}
disabled={isSaving || hasValidationErrors}
tone="primary"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</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 +375,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 +410,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 +420,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>
<button
type="button"
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"
>
<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>
<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={`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="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>
)}
</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="text-xs text-content-muted">
Canonical reference workflow for parity work.
</p>
)}
</div>
)}
</button>
{selectedId === item.id && (
<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={() => 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>
<span className={`mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs 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>
{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">
{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>
{item.isReference && (
<p className="mt-1 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>
))}
@@ -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()
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"
/>
</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>
<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)
}
}}
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
moduleFilters={moduleFilters}
moduleFilter={moduleFilter}
onModuleFilterChange={setModuleFilter}
moduleQuery={moduleQuery}
onModuleQueryChange={setModuleQuery}
onClearModuleScope={() => {
setModuleFilter('all')
setModuleQuery('')
}}
/>
<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>
</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}
</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"
>
{definition.label}
</button>
<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>
<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,27 +362,16 @@ 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>
<div className="mt-3 space-y-3">
{familySection.modules.map(moduleGroup => (
<div
key={`${familySection.family}:${moduleGroup.namespace}`}
className="rounded-lg border border-border-default bg-surface/80 p-3"
>
<div
key={`${familySection.family}:${moduleGroup.namespace}`}
className="rounded-lg border border-border-default bg-surface/80 p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
@@ -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,
inputContextLabel,
outputContextLabel,
requiredInputs,
requiredAnyInputs,
consumedArtifacts,
providedOutputs,
producedArtifacts,
contract,
validationWatchpoints = [],
}: WorkflowNodeContractCardProps) {
const {
inputContextLabel,
outputContextLabel,
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',
),
)
}, [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 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
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>
<div className="mt-2 space-y-1.5">
{inputSocketDescriptors.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'
}`}
<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">
<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"
>
Socket {index + 1}
</span>
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
</div>
))}
</div>
<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>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Starter Path
</p>
<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>
<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-xs text-content-muted">{plan.starterDescription}</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">
{plan.starterCompletedCount}/{plan.starterItems.length} present
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),
@@ -942,12 +783,11 @@ export function findOpenNodePosition(
if (isPositionFree(candidate)) {
return candidate
}
}
}
}
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'
}
}
+1
View File
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import App from './App'
import './index.css'
import '@xyflow/react/dist/style.css'
import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey } from './store/theme'
/* ---------------------------------------------------------------
+3 -742
View File
@@ -1,37 +1,10 @@
import { useState, useEffect, useMemo, useLayoutEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
ReactFlow,
Background,
Controls,
MiniMap,
Handle,
Position,
type Node,
type Edge,
type NodeTypes,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useThemeStore, resolveTheme } from '../store/theme'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { WorkflowCanvas } from '../components/workflows/WorkflowCanvas'
import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal'
import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from '../components/workflows/NodeCommandMenu'
import { WorkflowCanvasToolbar } from '../components/workflows/WorkflowCanvasToolbar'
import { WorkflowCanvasUtilitySidebar } from '../components/workflows/WorkflowCanvasUtilitySidebar'
import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState'
import { WorkflowListSidebar } from '../components/workflows/WorkflowListSidebar'
import { WorkflowValidationBanner } from '../components/workflows/WorkflowValidationBanner'
import {
WORKFLOW_NODE_MIN_HEIGHT,
WORKFLOW_NODE_WIDTH,
type WorkflowCanvasNodeData,
} from '../components/workflows/workflowGraphDraft'
import {
getWorkflowNodePortBadgeLabel,
getWorkflowNodePortTitle,
} from '../components/workflows/workflowNodePresentation'
import {
BLUEPRINT_DESCRIPTION,
BLUEPRINT_LABELS,
compareWorkflows,
getWorkflowBlueprint,
@@ -47,19 +20,8 @@ import {
getWorkflowPresetType,
type WorkflowDefinition,
type WorkflowConfig,
type WorkflowExecutionMode,
type WorkflowPresetType,
} from '../api/workflows'
import { updateOutputType } from '../api/outputTypes'
import {
FileUp,
RefreshCw,
Camera,
Film,
Layers,
Download,
Bell,
} from 'lucide-react'
import { toast } from 'sonner'
import {
GRAPH_FAMILY_LABELS,
@@ -72,707 +34,6 @@ import {
EXECUTION_MODE_LABELS,
} from '../components/workflows/workflowRunPresentation'
import { getWorkflowRolloutPresentation } from '../components/workflows/workflowRolloutPresentation'
import {
getWorkflowAuthoringEntryAction,
type WorkflowAuthoringActions,
} from '../components/workflows/workflowAuthoringActions'
import { getWorkflowAuthoringSurfaceModel } from '../components/workflows/workflowAuthoringSurface'
import { useWorkflowCanvasController } from '../components/workflows/useWorkflowCanvasController'
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} />
}
}
// ─── Custom Node Components ──────────────────────────────────────────────────
interface BaseNodeProps {
data: WorkflowCanvasNodeData
icon: React.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 formatCountLabel(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
function getNodeConfigurationSummary(data: WorkflowCanvasNodeData) {
const inputPorts = data.inputPorts ?? []
const editableFieldCount = data.editableFieldCount ?? 0
const hasDynamicVariables = Boolean(data.dynamicVariableHint)
const inputSummary =
inputPorts.length > 0
? `Canvas expects ${formatCountLabel(inputPorts.length, 'input socket', 'input sockets')}.`
: 'Entry node, no upstream sockets required.'
if (editableFieldCount > 0) {
return `${inputSummary} Inspector exposes ${formatCountLabel(editableFieldCount, 'local variable', 'local variables')}.`
}
if (hasDynamicVariables) {
return `${inputSummary} Template-selected inspector variables appear after choosing a template.`
}
return `${inputSummary} No inspector variables, behavior comes from connections and runtime context.`
}
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} more
</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.6rem] 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 editableFieldCount = data.editableFieldCount ?? 0
const configurationSummary = getNodeConfigurationSummary(data)
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 bg-content-muted border-2 border-surface"
style={{ top: getHandleOffset(index, outputPorts.length) }}
/>
))}
<div className={`mb-1 min-h-[1.25rem] flex items-center gap-2 ${accentClass}`}>
{icon}
<span className="font-medium text-sm">{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={`Sockets${inputPorts.length > 0 ? ` · ${inputPorts.length}` : ''}`}
badges={inputBadges}
emptyLabel="Entry node"
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${editableFieldCount > 0 ? ` · ${editableFieldCount}` : ''}`}
badges={variableBadges}
emptyLabel="No inspector vars"
badgeClassName="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-900/40 dark:text-violet-300"
/>
<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 text-[10px] text-content-muted">
{configurationSummary}
</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}
/>
)
}
const nodeTypes: NodeTypes = {
inputNode: InputNode as any,
convertNode: ConvertNode as any,
processNode: ProcessNode as any,
renderNode: RenderNode as any,
renderFramesNode: RenderFramesNode as any,
outputNode: OutputNode as any,
}
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.',
}
// ─── Flow Canvas ──────────────────────────────────────────────────────────────
interface FlowCanvasProps {
workflow: WorkflowDefinition
onSave: (config: WorkflowConfig) => void
isSaving: boolean
}
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
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 HORIZONTAL_MARGIN = 16
const VERTICAL_MARGIN = 16
const width = nodeMenuSize.width
const height = Math.min(nodeMenuSize.height, window.innerHeight - VERTICAL_MARGIN * 2)
const left = Math.min(
Math.max(nodeMenuAnchor.clientX, HORIZONTAL_MARGIN),
Math.max(window.innerWidth - width - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
)
const top = Math.min(
Math.max(nodeMenuAnchor.clientY, VERTICAL_MARGIN),
Math.max(window.innerHeight - height - VERTICAL_MARGIN, VERTICAL_MARGIN),
)
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 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 flex-col flex-1 min-h-0">
<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}
isPreflightPending={preflightMutation.isPending}
isDispatchPending={dispatchMutation.isPending}
isContextOptionsLoading={isOrderLineContextsLoading}
isSaving={isSaving}
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} />
{/* Canvas + Sidepanel */}
<div className="flex h-full flex-1 min-h-0 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={nodeTypes}
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>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function WorkflowEditor() {
const queryClient = useQueryClient()
@@ -935,7 +196,7 @@ export default function WorkflowEditor() {
<div className="flex-1 flex flex-col min-w-0">
{/* Canvas or Empty State */}
{selectedWorkflow ? (
<FlowCanvas
<WorkflowCanvas
key={selectedWorkflow.id}
workflow={selectedWorkflow}
onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config })}