feat: add reusable workflow definition graphs
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Workflow module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow backend."""
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "workflow"
|
||||
MODULE_NAME = "Workflow"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
|
||||
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
||||
INSTANCE_READ_SCOPE = "workflow:instance:read"
|
||||
INSTANCE_START_SCOPE = "workflow:instance:start"
|
||||
INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition"
|
||||
ADMIN_SCOPE = "workflow:instance:admin"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Workflow",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
DEFINITION_READ_SCOPE,
|
||||
"View workflow definitions",
|
||||
"Read workflow graphs, versions, diagnostics, and referenced contracts.",
|
||||
),
|
||||
_permission(
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
"Manage workflow definitions",
|
||||
"Create, edit, validate, and publish workflow definitions.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_READ_SCOPE,
|
||||
"View workflow instances",
|
||||
"Read workflow progress, pending actions, and transition evidence.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_START_SCOPE,
|
||||
"Start workflows",
|
||||
"Start approved workflow definitions for authorized subjects.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
"Advance workflows",
|
||||
"Complete activities and invoke authorized workflow transitions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer workflows",
|
||||
"Manage workflow definitions and instances across the tenant.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="workflow_designer",
|
||||
name="Workflow designer",
|
||||
description="Design, validate, and publish workflow definitions.",
|
||||
permissions=(DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="workflow_operator",
|
||||
name="Workflow operator",
|
||||
description="Start and advance approved workflows.",
|
||||
permissions=(
|
||||
DEFINITION_READ_SCOPE,
|
||||
INSTANCE_READ_SCOPE,
|
||||
INSTANCE_START_SCOPE,
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _router(_context: ModuleContext):
|
||||
from govoplan_workflow.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=(),
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"audit",
|
||||
"dataflow",
|
||||
"datasources",
|
||||
"notifications",
|
||||
"policy",
|
||||
"tasks",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="workflow.definition-graphs",
|
||||
title="Workflow definition graphs",
|
||||
summary="Governed process graphs built on the shared definition editor contract.",
|
||||
body=(
|
||||
"Workflow provides its own trigger, activity, decision, wait, integration, "
|
||||
"and outcome node library on top of Core's domain-neutral graph contract. "
|
||||
"Unlike Dataflow, Workflow permits cycles for correction and retry paths. "
|
||||
"Module actions are addressed through versioned capabilities rather than "
|
||||
"implementation imports. Definition persistence and execution build on this "
|
||||
"validated contract in subsequent slices."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
|
||||
order=76,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_SCOPE",
|
||||
"DEFINITION_READ_SCOPE",
|
||||
"DEFINITION_WRITE_SCOPE",
|
||||
"INSTANCE_READ_SCOPE",
|
||||
"INSTANCE_START_SCOPE",
|
||||
"INSTANCE_TRANSITION_SCOPE",
|
||||
"MODULE_ID",
|
||||
"MODULE_VERSION",
|
||||
"get_manifest",
|
||||
"manifest",
|
||||
]
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.definition_graphs import (
|
||||
DefinitionConfigField,
|
||||
DefinitionGraphConstraints,
|
||||
DefinitionGraphLibrary,
|
||||
DefinitionNodeCountConstraint,
|
||||
DefinitionNodeType,
|
||||
DefinitionPort,
|
||||
)
|
||||
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"trigger": "Start",
|
||||
"activity": "Activities",
|
||||
"decision": "Decisions",
|
||||
"wait": "Wait",
|
||||
"integration": "Integrations",
|
||||
"outcome": "Outcomes",
|
||||
}
|
||||
|
||||
WORKFLOW_NODE_TYPES = (
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.manual",
|
||||
category="trigger",
|
||||
label="Manual start",
|
||||
description="Start an instance through an explicit user or API action.",
|
||||
icon="circle-play",
|
||||
default_config={"input_schema_ref": ""},
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="input_schema_ref",
|
||||
label="Input schema",
|
||||
kind="text",
|
||||
description="Optional schema contract for instance input.",
|
||||
),
|
||||
),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.event",
|
||||
category="trigger",
|
||||
label="Event start",
|
||||
description="Start when a matching platform event is received.",
|
||||
icon="radio",
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="event_type",
|
||||
label="Event type",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="filter",
|
||||
label="Event filter",
|
||||
kind="expression",
|
||||
description="A constrained expression evaluated against the event envelope.",
|
||||
),
|
||||
),
|
||||
default_config={"event_type": "", "filter": ""},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.schedule",
|
||||
category="trigger",
|
||||
label="Scheduled start",
|
||||
description="Start according to a governed schedule.",
|
||||
icon="calendar-clock",
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="schedule",
|
||||
label="Schedule",
|
||||
kind="schedule",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="timezone",
|
||||
label="Time zone",
|
||||
kind="timezone",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
default_config={"schedule": "", "timezone": "Europe/Berlin"},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.activity",
|
||||
category="activity",
|
||||
label="Activity",
|
||||
description="Record and complete a governed unit of work.",
|
||||
icon="square-check-big",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
config_fields=(
|
||||
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
|
||||
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
|
||||
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
|
||||
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
||||
),
|
||||
default_config={
|
||||
"title": "",
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.review",
|
||||
category="activity",
|
||||
label="Review",
|
||||
description="Pause for a human review with explicit outcomes.",
|
||||
icon="clipboard-check",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="approved", label="Approved", required=False),
|
||||
DefinitionPort(id="changes", label="Changes requested", required=False),
|
||||
DefinitionPort(id="rejected", label="Rejected", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
|
||||
DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"),
|
||||
DefinitionConfigField(
|
||||
id="required_evidence",
|
||||
label="Required evidence",
|
||||
kind="string_list",
|
||||
),
|
||||
),
|
||||
default_config={"title": "", "reviewer": "", "required_evidence": []},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.decision",
|
||||
category="decision",
|
||||
label="Decision",
|
||||
description="Choose a path using a constrained expression.",
|
||||
icon="split",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="true", label="Matches", required=False),
|
||||
DefinitionPort(id="false", label="Does not match", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="expression",
|
||||
label="Expression",
|
||||
kind="expression",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
default_config={"expression": ""},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.wait",
|
||||
category="wait",
|
||||
label="Wait",
|
||||
description="Wait for a duration, deadline, event, or explicit resume action.",
|
||||
icon="timer",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="resumed", label="Resumed", required=False),
|
||||
DefinitionPort(id="timed_out", label="Timed out", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="mode",
|
||||
label="Wait for",
|
||||
kind="select",
|
||||
required=True,
|
||||
options=(
|
||||
("duration", "Duration"),
|
||||
("deadline", "Deadline"),
|
||||
("event", "Event"),
|
||||
("manual", "Manual resume"),
|
||||
),
|
||||
),
|
||||
DefinitionConfigField(id="value", label="Value", kind="text"),
|
||||
),
|
||||
default_config={"mode": "manual", "value": ""},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.capability",
|
||||
category="integration",
|
||||
label="Module action",
|
||||
description="Invoke a versioned module capability without importing its implementation.",
|
||||
icon="plug-zap",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="success", label="Success", required=False),
|
||||
DefinitionPort(id="failure", label="Failure", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="capability",
|
||||
label="Capability",
|
||||
kind="capability",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="operation",
|
||||
label="Operation",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
||||
DefinitionConfigField(
|
||||
id="idempotency_key",
|
||||
label="Idempotency key",
|
||||
kind="expression",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="failure_policy",
|
||||
label="On failure",
|
||||
kind="select",
|
||||
required=True,
|
||||
options=(
|
||||
("retry", "Retry"),
|
||||
("manual", "Require intervention"),
|
||||
("continue", "Continue on failure"),
|
||||
),
|
||||
),
|
||||
),
|
||||
default_config={
|
||||
"capability": "",
|
||||
"operation": "",
|
||||
"input_mapping": {},
|
||||
"idempotency_key": "",
|
||||
"failure_policy": "manual",
|
||||
},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.dataflow",
|
||||
category="integration",
|
||||
label="Run dataflow",
|
||||
description="Run a published Dataflow pipeline and retain its output reference.",
|
||||
icon="waypoints",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="success", label="Success", required=False),
|
||||
DefinitionPort(id="failure", label="Failure", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="pipeline_ref",
|
||||
label="Pipeline",
|
||||
kind="dataflow",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
||||
),
|
||||
default_config={"pipeline_ref": "", "input_mapping": {}},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.end.completed",
|
||||
category="outcome",
|
||||
label="Completed",
|
||||
description="Complete the workflow successfully.",
|
||||
icon="circle-check-big",
|
||||
input_ports=(
|
||||
DefinitionPort(
|
||||
id="input",
|
||||
label="Input",
|
||||
multiple=True,
|
||||
minimum_connections=1,
|
||||
),
|
||||
),
|
||||
output_ports=(),
|
||||
config_fields=(
|
||||
DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"),
|
||||
),
|
||||
default_config={"output_mapping": {}},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.end.cancelled",
|
||||
category="outcome",
|
||||
label="Cancelled",
|
||||
description="End the workflow without a successful outcome.",
|
||||
icon="circle-x",
|
||||
input_ports=(
|
||||
DefinitionPort(
|
||||
id="input",
|
||||
label="Input",
|
||||
multiple=True,
|
||||
minimum_connections=1,
|
||||
),
|
||||
),
|
||||
output_ports=(),
|
||||
config_fields=(
|
||||
DefinitionConfigField(id="reason", label="Reason", kind="text"),
|
||||
),
|
||||
default_config={"reason": ""},
|
||||
),
|
||||
)
|
||||
|
||||
WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary(
|
||||
id="workflow",
|
||||
version="0.1.0",
|
||||
category_labels=CATEGORY_LABELS,
|
||||
node_types=WORKFLOW_NODE_TYPES,
|
||||
constraints=DefinitionGraphConstraints(
|
||||
max_nodes=150,
|
||||
max_edges=300,
|
||||
allow_cycles=True,
|
||||
require_connected=True,
|
||||
node_counts=(
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.trigger_count",
|
||||
label="start",
|
||||
minimum=1,
|
||||
maximum=1,
|
||||
categories=("trigger",),
|
||||
),
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.outcome_count",
|
||||
label="outcome",
|
||||
minimum=1,
|
||||
categories=("outcome",),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
WORKFLOW_NODE_TYPES_BY_ID = {
|
||||
definition.type: definition for definition in WORKFLOW_NODE_TYPES
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CATEGORY_LABELS",
|
||||
"WORKFLOW_GRAPH_LIBRARY",
|
||||
"WORKFLOW_NODE_TYPES",
|
||||
"WORKFLOW_NODE_TYPES_BY_ID",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_workflow.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
|
||||
from govoplan_workflow.backend.schemas import (
|
||||
WorkflowConfigFieldResponse,
|
||||
WorkflowDiagnosticResponse,
|
||||
WorkflowGraphValidationRequest,
|
||||
WorkflowGraphValidationResponse,
|
||||
WorkflowNodeLibraryResponse,
|
||||
WorkflowNodeTypeResponse,
|
||||
WorkflowPortResponse,
|
||||
)
|
||||
from govoplan_workflow.backend.validation import validate_workflow_graph
|
||||
|
||||
|
||||
router = APIRouter(prefix="/workflow", tags=["workflow"])
|
||||
|
||||
|
||||
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if any(has_scope(principal, scope) for scope in scopes):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing one of the required scopes: {', '.join(scopes)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
|
||||
def api_node_types(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowNodeLibraryResponse:
|
||||
_require_any_scope(
|
||||
principal,
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
)
|
||||
return WorkflowNodeLibraryResponse(
|
||||
id=WORKFLOW_GRAPH_LIBRARY.id,
|
||||
version=WORKFLOW_GRAPH_LIBRARY.version,
|
||||
allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles,
|
||||
nodes=[
|
||||
WorkflowNodeTypeResponse(
|
||||
type=definition.type,
|
||||
category=definition.category,
|
||||
category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category],
|
||||
label=definition.label,
|
||||
description=definition.description,
|
||||
icon=definition.icon,
|
||||
input_ports=[
|
||||
WorkflowPortResponse(
|
||||
id=port.id,
|
||||
label=port.label,
|
||||
required=port.required,
|
||||
multiple=port.multiple,
|
||||
minimum_connections=port.minimum_connections,
|
||||
)
|
||||
for port in definition.input_ports
|
||||
],
|
||||
output_ports=[
|
||||
WorkflowPortResponse(
|
||||
id=port.id,
|
||||
label=port.label,
|
||||
required=port.required,
|
||||
multiple=port.multiple,
|
||||
minimum_connections=port.minimum_connections,
|
||||
)
|
||||
for port in definition.output_ports
|
||||
],
|
||||
config_fields=[
|
||||
WorkflowConfigFieldResponse(
|
||||
id=field.id,
|
||||
label=field.label,
|
||||
kind=field.kind,
|
||||
required=field.required,
|
||||
description=field.description,
|
||||
options=list(field.options),
|
||||
)
|
||||
for field in definition.config_fields
|
||||
],
|
||||
default_config=dict(definition.default_config),
|
||||
)
|
||||
for definition in WORKFLOW_GRAPH_LIBRARY.node_types
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitions/validate",
|
||||
response_model=WorkflowGraphValidationResponse,
|
||||
)
|
||||
def api_validate_definition(
|
||||
payload: WorkflowGraphValidationRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowGraphValidationResponse:
|
||||
_require_any_scope(
|
||||
principal,
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
)
|
||||
diagnostics = validate_workflow_graph(payload.graph)
|
||||
return WorkflowGraphValidationResponse(
|
||||
valid=not any(item.severity == "error" for item in diagnostics),
|
||||
diagnostics=[
|
||||
WorkflowDiagnosticResponse(
|
||||
severity=item.severity,
|
||||
code=item.code,
|
||||
message=item.message,
|
||||
node_id=item.node_id,
|
||||
field=item.field,
|
||||
)
|
||||
for item in diagnostics
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class WorkflowPosition(BaseModel):
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
@field_validator("x", "y")
|
||||
@classmethod
|
||||
def finite_coordinate(cls, value: float) -> float:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Graph coordinates must be finite.")
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowNode(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
type: str = Field(min_length=1, max_length=120)
|
||||
label: str = Field(default="", max_length=300)
|
||||
position: WorkflowPosition = Field(default_factory=WorkflowPosition)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowEdge(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
source: str = Field(min_length=1, max_length=120)
|
||||
target: str = Field(min_length=1, max_length=120)
|
||||
source_port: str = Field(default="output", min_length=1, max_length=120)
|
||||
target_port: str = Field(default="input", min_length=1, max_length=120)
|
||||
|
||||
|
||||
class WorkflowGraph(BaseModel):
|
||||
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
|
||||
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
|
||||
|
||||
|
||||
class WorkflowGraphValidationRequest(BaseModel):
|
||||
graph: WorkflowGraph
|
||||
|
||||
|
||||
class WorkflowDiagnosticResponse(BaseModel):
|
||||
severity: Literal["error", "warning"]
|
||||
code: str
|
||||
message: str
|
||||
node_id: str | None = None
|
||||
field: str | None = None
|
||||
|
||||
|
||||
class WorkflowGraphValidationResponse(BaseModel):
|
||||
valid: bool
|
||||
diagnostics: list[WorkflowDiagnosticResponse]
|
||||
|
||||
|
||||
class WorkflowPortResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
required: bool
|
||||
multiple: bool
|
||||
minimum_connections: int
|
||||
|
||||
|
||||
class WorkflowConfigFieldResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
required: bool
|
||||
description: str | None
|
||||
options: list[tuple[str, str]]
|
||||
|
||||
|
||||
class WorkflowNodeTypeResponse(BaseModel):
|
||||
type: str
|
||||
category: str
|
||||
category_label: str
|
||||
label: str
|
||||
description: str
|
||||
icon: str
|
||||
input_ports: list[WorkflowPortResponse]
|
||||
output_ports: list[WorkflowPortResponse]
|
||||
config_fields: list[WorkflowConfigFieldResponse]
|
||||
default_config: dict[str, Any]
|
||||
|
||||
|
||||
class WorkflowNodeLibraryResponse(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
allows_cycles: bool
|
||||
nodes: list[WorkflowNodeTypeResponse]
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.definition_graphs import (
|
||||
DefinitionDiagnostic,
|
||||
DefinitionEdge,
|
||||
DefinitionNode,
|
||||
validate_definition_graph,
|
||||
)
|
||||
from govoplan_workflow.backend.node_library import (
|
||||
WORKFLOW_GRAPH_LIBRARY,
|
||||
WORKFLOW_NODE_TYPES_BY_ID,
|
||||
)
|
||||
from govoplan_workflow.backend.schemas import WorkflowGraph
|
||||
|
||||
|
||||
def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ...]:
|
||||
diagnostics = list(
|
||||
validate_definition_graph(
|
||||
WORKFLOW_GRAPH_LIBRARY,
|
||||
nodes=tuple(DefinitionNode(id=node.id, type=node.type) for node in graph.nodes),
|
||||
edges=tuple(
|
||||
DefinitionEdge(
|
||||
id=edge.id,
|
||||
source=edge.source,
|
||||
target=edge.target,
|
||||
source_port=edge.source_port,
|
||||
target_port=edge.target_port,
|
||||
)
|
||||
for edge in graph.edges
|
||||
),
|
||||
)
|
||||
)
|
||||
outgoing = {node.id: 0 for node in graph.nodes}
|
||||
for edge in graph.edges:
|
||||
if edge.source in outgoing:
|
||||
outgoing[edge.source] += 1
|
||||
for node in graph.nodes:
|
||||
definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type)
|
||||
if definition is None:
|
||||
continue
|
||||
for field in definition.config_fields:
|
||||
if field.required and _empty(node.config.get(field.id)):
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="node.config_required",
|
||||
message=f"{definition.label} requires {field.label.lower()}.",
|
||||
node_id=node.id,
|
||||
field=field.id,
|
||||
)
|
||||
)
|
||||
if definition.output_ports and outgoing[node.id] == 0:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="node.outgoing_required",
|
||||
message=f"{definition.label} must lead to another workflow step.",
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
return _deduplicate(diagnostics)
|
||||
|
||||
|
||||
def _empty(value: object) -> bool:
|
||||
return value is None or value == "" or value == [] or value == {}
|
||||
|
||||
|
||||
def _deduplicate(
|
||||
diagnostics: list[DefinitionDiagnostic],
|
||||
) -> tuple[DefinitionDiagnostic, ...]:
|
||||
seen: set[tuple[str, str | None, str | None]] = set()
|
||||
result: list[DefinitionDiagnostic] = []
|
||||
for item in diagnostics:
|
||||
key = (item.code, item.node_id, item.field)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
__all__ = ["validate_workflow_graph"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user