From 85eef00913905934da700ce67a0907bedc60fed9 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 12:43:26 +0200 Subject: [PATCH] feat: add reusable workflow definition graphs --- README.md | 12 +- docs/CONCEPT.md | 12 +- pyproject.toml | 22 ++ src/govoplan_workflow/__init__.py | 3 + src/govoplan_workflow/backend/__init__.py | 1 + src/govoplan_workflow/backend/manifest.py | 166 +++++++++ src/govoplan_workflow/backend/node_library.py | 328 ++++++++++++++++++ src/govoplan_workflow/backend/router.py | 126 +++++++ src/govoplan_workflow/backend/schemas.py | 93 +++++ src/govoplan_workflow/backend/validation.py | 82 +++++ src/govoplan_workflow/py.typed | 1 + tests/test_manifest.py | 32 ++ tests/test_node_library.py | 97 ++++++ 13 files changed, 972 insertions(+), 3 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/govoplan_workflow/__init__.py create mode 100644 src/govoplan_workflow/backend/__init__.py create mode 100644 src/govoplan_workflow/backend/manifest.py create mode 100644 src/govoplan_workflow/backend/node_library.py create mode 100644 src/govoplan_workflow/backend/router.py create mode 100644 src/govoplan_workflow/backend/schemas.py create mode 100644 src/govoplan_workflow/backend/validation.py create mode 100644 src/govoplan_workflow/py.typed create mode 100644 tests/test_manifest.py create mode 100644 tests/test_node_library.py diff --git a/README.md b/README.md index 31e596e..55a9c2d 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,22 @@ **Repository type:** module (platform). -`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 diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 84d1eda..061cc9e 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b7cca0 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_workflow/__init__.py b/src/govoplan_workflow/__init__.py new file mode 100644 index 0000000..cf761b5 --- /dev/null +++ b/src/govoplan_workflow/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Workflow module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_workflow/backend/__init__.py b/src/govoplan_workflow/backend/__init__.py new file mode 100644 index 0000000..da4885c --- /dev/null +++ b/src/govoplan_workflow/backend/__init__.py @@ -0,0 +1 @@ +"""Workflow backend.""" diff --git a/src/govoplan_workflow/backend/manifest.py b/src/govoplan_workflow/backend/manifest.py new file mode 100644 index 0000000..6d7c65e --- /dev/null +++ b/src/govoplan_workflow/backend/manifest.py @@ -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", +] diff --git a/src/govoplan_workflow/backend/node_library.py b/src/govoplan_workflow/backend/node_library.py new file mode 100644 index 0000000..e78d77d --- /dev/null +++ b/src/govoplan_workflow/backend/node_library.py @@ -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", +] diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py new file mode 100644 index 0000000..7fddf11 --- /dev/null +++ b/src/govoplan_workflow/backend/router.py @@ -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"] diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py new file mode 100644 index 0000000..3a13468 --- /dev/null +++ b/src/govoplan_workflow/backend/schemas.py @@ -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] diff --git a/src/govoplan_workflow/backend/validation.py b/src/govoplan_workflow/backend/validation.py new file mode 100644 index 0000000..93a97cc --- /dev/null +++ b/src/govoplan_workflow/backend/validation.py @@ -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"] diff --git a/src/govoplan_workflow/py.typed b/src/govoplan_workflow/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_workflow/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..0b69042 --- /dev/null +++ b/tests/test_manifest.py @@ -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() diff --git a/tests/test_node_library.py b/tests/test_node_library.py new file mode 100644 index 0000000..b477bf8 --- /dev/null +++ b/tests/test_node_library.py @@ -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()