feat: add reusable workflow definition graphs

This commit is contained in:
2026-07-28 12:43:26 +02:00
parent 0d099b05b7
commit 85eef00913
13 changed files with 972 additions and 3 deletions

View File

@@ -4,14 +4,22 @@
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-workflow` will own process orchestration for GovOPlaN.
`govoplan-workflow` owns process orchestration for GovOPlaN.
The module should execute configurable state machines and command handoffs
between modules without importing their implementations. It coordinates cases,
tasks, forms, files, templates, mail, appointments, payments, and records
through capabilities, events, commands, and DTOs.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
The first executable slice exposes a workflow-specific node library and graph
validation API through `/workflow/node-types` and
`/workflow/definitions/validate`. It uses Core's domain-neutral definition graph
contract, but applies Workflow constraints: exactly one trigger, at least one
outcome, governed configuration fields, connected nodes, and permitted loops for
correction and retry paths. Dataflow uses the same graph contract with its own
acyclic transformation library.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
There is a BPMN component playing a major role here. Maybe this needs to
become a separate module. It is quite viable to think about workflow

View File

@@ -95,7 +95,17 @@ Permit-to-payment MVP:
## MVP Slice
The first implementation should provide:
The first executable slice now provides:
- a versioned, workflow-specific graphical node library
- shared Core graph DTO and validation primitives also consumed by Dataflow
- workflow constraints that permit loops while requiring one trigger and one or
more outcomes
- trigger, activity, review, decision, wait, module-action, Dataflow, and outcome
nodes
- API discovery and validation endpoints
The next execution slices should provide:
- static workflow definition registration from configuration packages
- create/read/list workflow instances

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow"
version = "0.1.14"
description = "Governed process definitions and orchestration contracts for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_workflow = ["py.typed"]
[project.entry-points."govoplan.modules"]
workflow = "govoplan_workflow.backend.manifest:get_manifest"

View File

@@ -0,0 +1,3 @@
"""GovOPlaN Workflow module."""
__version__ = "0.1.14"

View File

@@ -0,0 +1 @@
"""Workflow backend."""

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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]

View File

@@ -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"]

View File

@@ -0,0 +1 @@

32
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.manifest import (
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
get_manifest,
)
class WorkflowManifestTests(unittest.TestCase):
def test_manifest_exposes_definition_contracts(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "workflow")
self.assertIn(
"workflow.definition_graph",
{item.name for item in manifest.provides_interfaces},
)
self.assertIn(
DEFINITION_READ_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertIn(
DEFINITION_WRITE_SCOPE,
{item.scope for item in manifest.permissions},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
from govoplan_workflow.backend.validation import validate_workflow_graph
class WorkflowNodeLibraryTests(unittest.TestCase):
def test_valid_workflow_graph(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="workflow.start.manual"),
WorkflowNode(
id="work",
type="workflow.activity",
config={"title": "Check submission"},
),
WorkflowNode(id="done", type="workflow.end.completed"),
],
edges=[
WorkflowEdge(id="e1", source="start", target="work"),
WorkflowEdge(id="e2", source="work", target="done"),
],
)
self.assertEqual(validate_workflow_graph(graph), ())
def test_correction_loop_is_allowed(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="workflow.start.manual"),
WorkflowNode(
id="work",
type="workflow.activity",
config={"title": "Prepare"},
),
WorkflowNode(
id="review",
type="workflow.review",
config={"title": "Review"},
),
WorkflowNode(id="done", type="workflow.end.completed"),
],
edges=[
WorkflowEdge(id="e1", source="start", target="work"),
WorkflowEdge(id="e2", source="work", target="review"),
WorkflowEdge(
id="e3",
source="review",
source_port="changes",
target="work",
),
WorkflowEdge(
id="e4",
source="review",
source_port="approved",
target="done",
),
],
)
self.assertNotIn(
"graph.cycle",
{item.code for item in validate_workflow_graph(graph)},
)
def test_constraints_and_required_configuration_are_reported(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start-1", type="workflow.start.manual"),
WorkflowNode(
id="start-2",
type="workflow.start.event",
config={"event_type": ""},
),
],
edges=[
WorkflowEdge(id="e1", source="start-1", target="start-2"),
],
)
diagnostics = validate_workflow_graph(graph)
codes = {item.code for item in diagnostics}
self.assertIn("graph.trigger_count", codes)
self.assertIn("graph.outcome_count", codes)
self.assertIn("node.config_required", codes)
self.assertIn("node.outgoing_required", codes)
def test_library_has_domain_specific_cycle_policy(self) -> None:
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow")
if __name__ == "__main__":
unittest.main()