chore: snapshot workflow migration progress
This commit is contained in:
@@ -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", []))
|
||||
|
||||
Reference in New Issue
Block a user