feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows (domain controller setup, M365 onboarding, VPN config, etc). Includes intake form builder, two-panel step navigation, variable resolution, procedural exports, 3 seed templates, and UI rename from "Trees" to "Flows". Also archives 19 implemented plan docs and creates deferred features backlog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,10 @@ class TreeValidationError(Exception):
|
||||
super().__init__(f"{field}: {message}")
|
||||
|
||||
|
||||
# --- Troubleshooting Tree Validation ---
|
||||
|
||||
def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Validate tree structure for publishing.
|
||||
"""Validate troubleshooting tree structure for publishing.
|
||||
|
||||
A valid tree for publishing must have:
|
||||
- A root node with id, type, and appropriate content fields
|
||||
@@ -53,13 +55,7 @@ def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[
|
||||
|
||||
|
||||
def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]) -> None:
|
||||
"""Validate a single node in the tree structure.
|
||||
|
||||
Args:
|
||||
node: The node dict to validate
|
||||
path: Current path in the tree (for error messages)
|
||||
errors: List to append errors to
|
||||
"""
|
||||
"""Validate a single node in the tree structure."""
|
||||
node_type = node.get("type")
|
||||
|
||||
if node_type == "decision":
|
||||
@@ -99,13 +95,7 @@ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]
|
||||
|
||||
|
||||
def _validate_children(children: list[dict[str, Any]], path: str, errors: list[dict[str, str]]) -> None:
|
||||
"""Recursively validate child nodes.
|
||||
|
||||
Args:
|
||||
children: List of child nodes
|
||||
path: Current path in the tree (for error messages)
|
||||
errors: List to append errors to
|
||||
"""
|
||||
"""Recursively validate child nodes."""
|
||||
for i, child in enumerate(children):
|
||||
child_path = f"{path}[{i}]"
|
||||
|
||||
@@ -123,17 +113,106 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
|
||||
_validate_children(child["children"], f"{child_path}.children", errors)
|
||||
|
||||
|
||||
def can_publish_tree(tree_structure: dict[str, Any], name: str, description: str | None = None) -> tuple[bool, list[dict[str, str]]]:
|
||||
# --- Procedural Tree Validation ---
|
||||
|
||||
VALID_STEP_TYPES = {"procedure_step", "procedure_end"}
|
||||
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||
|
||||
|
||||
def validate_procedural_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Validate procedural tree structure for publishing.
|
||||
|
||||
Procedural trees store steps as a flat ordered array in tree_structure["steps"].
|
||||
|
||||
Rules:
|
||||
- Must have a non-empty "steps" array
|
||||
- Each step must have: id, type, title
|
||||
- Only procedure_step and procedure_end types allowed
|
||||
- Must have exactly one procedure_end (as the last step)
|
||||
- All other steps must be procedure_step
|
||||
- No duplicate step IDs
|
||||
- Steps with content_type must use valid values
|
||||
|
||||
Args:
|
||||
tree_structure: Dict with a "steps" key containing the ordered step array
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of errors)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not tree_structure:
|
||||
errors.append({"field": "tree_structure", "message": "Tree structure cannot be empty"})
|
||||
return False, errors
|
||||
|
||||
steps = tree_structure.get("steps")
|
||||
if not steps or not isinstance(steps, list):
|
||||
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a non-empty steps array"})
|
||||
return False, errors
|
||||
|
||||
# Track IDs for uniqueness
|
||||
seen_ids: set[str] = set()
|
||||
end_count = 0
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
path = f"steps[{i}]"
|
||||
|
||||
# Required fields
|
||||
step_id = step.get("id")
|
||||
if not step_id:
|
||||
errors.append({"field": f"{path}.id", "message": "Step must have an id"})
|
||||
elif step_id in seen_ids:
|
||||
errors.append({"field": f"{path}.id", "message": f"Duplicate step id: {step_id}"})
|
||||
else:
|
||||
seen_ids.add(step_id)
|
||||
|
||||
step_type = step.get("type")
|
||||
if not step_type:
|
||||
errors.append({"field": f"{path}.type", "message": "Step must have a type"})
|
||||
elif step_type not in VALID_STEP_TYPES:
|
||||
errors.append({"field": f"{path}.type", "message": f"Invalid step type: {step_type}. Must be one of: {', '.join(VALID_STEP_TYPES)}"})
|
||||
elif step_type == "procedure_end":
|
||||
end_count += 1
|
||||
# procedure_end must be last step
|
||||
if i != len(steps) - 1:
|
||||
errors.append({"field": f"{path}.type", "message": "procedure_end must be the last step"})
|
||||
|
||||
if not step.get("title"):
|
||||
errors.append({"field": f"{path}.title", "message": "Step must have a non-empty title"})
|
||||
|
||||
# Validate content_type if present
|
||||
content_type = step.get("content_type")
|
||||
if content_type and content_type not in VALID_CONTENT_TYPES:
|
||||
errors.append({"field": f"{path}.content_type", "message": f"Invalid content_type: {content_type}. Must be one of: {', '.join(VALID_CONTENT_TYPES)}"})
|
||||
|
||||
# Must have exactly one end step
|
||||
if end_count == 0:
|
||||
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a procedure_end step as the last step"})
|
||||
elif end_count > 1:
|
||||
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have exactly one procedure_end step"})
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
def can_publish_tree(
|
||||
tree_structure: dict[str, Any],
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
tree_type: str = "troubleshooting",
|
||||
intake_form: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Check if a tree can be published.
|
||||
|
||||
Validates:
|
||||
- Tree has a name (non-empty)
|
||||
- Tree structure is valid
|
||||
Dispatches to the appropriate validator based on tree_type.
|
||||
|
||||
Args:
|
||||
tree_structure: The tree structure to validate
|
||||
name: The tree name
|
||||
description: Optional tree description
|
||||
tree_type: 'troubleshooting' or 'procedural'
|
||||
intake_form: Optional intake form fields (procedural only)
|
||||
|
||||
Returns:
|
||||
Tuple of (can_publish, list of errors)
|
||||
@@ -144,8 +223,44 @@ def can_publish_tree(tree_structure: dict[str, Any], name: str, description: str
|
||||
if not name or not name.strip():
|
||||
errors.append({"field": "name", "message": "Tree must have a name to be published"})
|
||||
|
||||
# Validate tree structure
|
||||
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
||||
# Validate structure based on tree type
|
||||
if tree_type == "procedural":
|
||||
structure_valid, structure_errors = validate_procedural_structure(tree_structure)
|
||||
else:
|
||||
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
||||
errors.extend(structure_errors)
|
||||
|
||||
# Validate intake form if present (procedural only)
|
||||
if intake_form and tree_type == "procedural":
|
||||
form_valid, form_errors = _validate_intake_form(intake_form)
|
||||
errors.extend(form_errors)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def _validate_intake_form(intake_form: list[dict[str, Any]]) -> tuple[bool, list[dict[str, str]]]:
|
||||
"""Validate intake form field definitions."""
|
||||
errors = []
|
||||
variable_names: set[str] = set()
|
||||
|
||||
for i, field in enumerate(intake_form):
|
||||
path = f"intake_form[{i}]"
|
||||
|
||||
var_name = field.get("variable_name")
|
||||
if not var_name:
|
||||
errors.append({"field": f"{path}.variable_name", "message": "Field must have a variable_name"})
|
||||
elif var_name in variable_names:
|
||||
errors.append({"field": f"{path}.variable_name", "message": f"Duplicate variable_name: {var_name}"})
|
||||
else:
|
||||
variable_names.add(var_name)
|
||||
|
||||
if not field.get("label"):
|
||||
errors.append({"field": f"{path}.label", "message": "Field must have a label"})
|
||||
|
||||
field_type = field.get("field_type")
|
||||
if field_type in ("select", "multi_select"):
|
||||
options = field.get("options")
|
||||
if not options or len(options) == 0:
|
||||
errors.append({"field": f"{path}.options", "message": f"{field_type} fields must have at least one option"})
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
Reference in New Issue
Block a user