chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
+159 -14
View File
@@ -18,6 +18,7 @@ Example config::
"""
from collections import deque
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -29,6 +30,14 @@ from app.domains.rendering.workflow_node_registry import (
)
_WORKFLOW_META_PARAM_KEYS = {"retry_policy", "failure_policy"}
_TEMPLATE_INPUT_PARAM_PREFIX = "template_input__"
_HEX_COLOR_LENGTHS = {7, 9}
_SAFE_FILENAME_SUFFIX_CHARS = set(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
)
def _context_seed_artifacts(definition: WorkflowNodeDefinition) -> set[str]:
if definition.family == "order_line":
return {"order_line_record"}
@@ -37,10 +46,43 @@ def _context_seed_artifacts(definition: WorkflowNodeDefinition) -> set[str]:
return set()
def _infer_concrete_workflow_family(
definitions: list[WorkflowNodeDefinition],
) -> Literal["cad_file", "order_line", "mixed"] | None:
concrete_families = {
definition.family
for definition in definitions
if definition.family in {"cad_file", "order_line"}
}
if not concrete_families:
return None
if len(concrete_families) > 1:
return "mixed"
return next(iter(concrete_families))
def _coerce_node_label(node: "WorkflowNode") -> str:
return f"{node.id!r} ({node.step.value})"
def _require_node_definition(node: "WorkflowNode") -> WorkflowNodeDefinition:
definition = get_node_definition(node.step)
if definition is None:
raise ValueError(
f"node {_coerce_node_label(node)} is not registered in workflow_node_registry"
)
return definition
def _is_dynamic_template_input_param(node: "WorkflowNode", key: str) -> bool:
return (
node.step == StepName.RESOLVE_TEMPLATE
and isinstance(key, str)
and key.startswith(_TEMPLATE_INPUT_PARAM_PREFIX)
and key[len(_TEMPLATE_INPUT_PARAM_PREFIX):].strip() != ""
)
def _validate_param_value(
*,
node: "WorkflowNode",
@@ -72,6 +114,105 @@ def _validate_param_value(
if value not in valid_values:
allowed_values = ", ".join(repr(option) for option in sorted(valid_values, key=repr))
raise ValueError(f"{field_label} must be one of: {allowed_values}")
return
if field_definition.type == "text":
if not isinstance(value, str):
raise ValueError(f"{field_label} must be a string")
stripped_value = value.strip()
if stripped_value == "":
if field_definition.allow_blank:
return
raise ValueError(f"{field_label} may not be blank")
if field_definition.max_length is not None and len(value) > field_definition.max_length:
raise ValueError(
f"{field_label} must be at most {field_definition.max_length} characters"
)
if field_definition.text_format == "plain":
return
if field_definition.text_format == "uuid":
try:
UUID(stripped_value)
except ValueError as exc:
raise ValueError(f"{field_label} must be a valid UUID") from exc
return
if field_definition.text_format == "absolute_path":
if not stripped_value.startswith("/"):
raise ValueError(f"{field_label} must be an absolute path")
return
if field_definition.text_format == "absolute_blend_path":
if not stripped_value.startswith("/"):
raise ValueError(f"{field_label} must be an absolute path")
if not stripped_value.lower().endswith(".blend"):
raise ValueError(f"{field_label} must point to a .blend file")
return
if field_definition.text_format == "absolute_glb_path":
if not stripped_value.startswith("/"):
raise ValueError(f"{field_label} must be an absolute path")
if not stripped_value.lower().endswith(".glb"):
raise ValueError(f"{field_label} must point to a .glb file")
return
if field_definition.text_format == "float_string":
try:
float(stripped_value)
except ValueError as exc:
raise ValueError(f"{field_label} must be a valid numeric string") from exc
return
if field_definition.text_format == "hex_color":
if len(stripped_value) not in _HEX_COLOR_LENGTHS or not stripped_value.startswith("#"):
raise ValueError(f"{field_label} must be a hex color like #FFFFFF or #FFFFFFFF")
color_digits = stripped_value[1:]
if any(character not in "0123456789abcdefABCDEF" for character in color_digits):
raise ValueError(f"{field_label} must be a hex color like #FFFFFF or #FFFFFFFF")
return
if field_definition.text_format == "safe_filename_suffix":
if any(character not in _SAFE_FILENAME_SUFFIX_CHARS for character in stripped_value):
raise ValueError(
f"{field_label} may only contain letters, numbers, '.', '-' or '_'"
)
return
raise ValueError(
f"{field_label} uses unsupported text format {field_definition.text_format!r}"
)
def _validate_meta_param_value(*, node: "WorkflowNode", key: str, value: Any) -> None:
field_label = f"node {_coerce_node_label(node)} meta param {key!r}"
if key == "retry_policy":
if not isinstance(value, dict):
raise ValueError(f"{field_label} must be an object")
unknown_keys = sorted(raw_key for raw_key in value if raw_key not in {"max_attempts"})
if unknown_keys:
joined = ", ".join(repr(raw_key) for raw_key in unknown_keys)
raise ValueError(f"{field_label} uses unknown key(s): {joined}")
max_attempts = value.get("max_attempts", 1)
if isinstance(max_attempts, bool) or not isinstance(max_attempts, int):
raise ValueError(f"{field_label} field 'max_attempts' must be an integer")
if max_attempts < 1 or max_attempts > 5:
raise ValueError(f"{field_label} field 'max_attempts' must be between 1 and 5")
return
if key == "failure_policy":
if not isinstance(value, dict):
raise ValueError(f"{field_label} must be an object")
allowed_keys = {"halt_workflow", "fallback_to_legacy"}
unknown_keys = sorted(raw_key for raw_key in value if raw_key not in allowed_keys)
if unknown_keys:
joined = ", ".join(repr(raw_key) for raw_key in unknown_keys)
raise ValueError(f"{field_label} uses unknown key(s): {joined}")
for bool_key in allowed_keys:
if bool_key not in value:
continue
if not isinstance(value[bool_key], bool):
raise ValueError(f"{field_label} field {bool_key!r} must be a boolean")
return
raise ValueError(f"{field_label} is not supported")
class WorkflowPosition(BaseModel):
@@ -149,18 +290,25 @@ class WorkflowConfig(BaseModel):
@model_validator(mode="after")
def node_params_match_registry(self) -> "WorkflowConfig":
for node in self.nodes:
definition = get_node_definition(node.step)
if definition is None:
continue
definition = _require_node_definition(node)
field_definitions = {field.key: field for field in definition.fields}
allowed_keys = {field.key for field in definition.fields}
unknown_keys = sorted(key for key in node.params if key not in allowed_keys)
allowed_keys = {field.key for field in definition.fields} | _WORKFLOW_META_PARAM_KEYS
unknown_keys = sorted(
key
for key in node.params
if key not in allowed_keys and not _is_dynamic_template_input_param(node, key)
)
if unknown_keys:
joined = ", ".join(repr(key) for key in unknown_keys)
raise ValueError(
f"node {node.id!r} ({node.step.value}) uses unknown param key(s): {joined}"
)
for key, value in node.params.items():
if _is_dynamic_template_input_param(node, key):
continue
if key in _WORKFLOW_META_PARAM_KEYS:
_validate_meta_param_value(node=node, key=key, value=value)
continue
field_definition = field_definitions.get(key)
if field_definition is None:
continue
@@ -173,20 +321,19 @@ class WorkflowConfig(BaseModel):
@model_validator(mode="after")
def ui_family_matches_node_families(self) -> "WorkflowConfig":
families = {
definition.family
for node in self.nodes
if (definition := get_node_definition(node.step)) is not None
}
definitions = [_require_node_definition(node) for node in self.nodes]
families = {definition.family for definition in definitions}
inferred_family = _infer_concrete_workflow_family(definitions)
if not families:
return self
inferred_family = "mixed" if len(families) > 1 else next(iter(families))
execution_mode = self.ui.execution_mode if self.ui is not None else "legacy"
if execution_mode in {"graph", "shadow"} and inferred_family == "mixed":
raise ValueError(
"workflow ui.execution_mode must stay single-family for graph/shadow execution"
)
if inferred_family is None:
return self
if self.ui is None or self.ui.family is None:
return self
if self.ui.family != inferred_family:
@@ -220,9 +367,7 @@ class WorkflowConfig(BaseModel):
node_id = queue.popleft()
processed += 1
node = node_by_id[node_id]
definition = get_node_definition(node.step)
if definition is None:
continue
definition = _require_node_definition(node)
node_inputs = available_artifacts[node_id] | _context_seed_artifacts(definition)
required = set(definition.input_contract.get("requires", []))