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
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Workflow module."""
__version__ = "0.1.14"
@@ -0,0 +1 @@
"""Workflow backend."""
+166
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",
]
@@ -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",
]
+126
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"]
+93
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]
@@ -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"]
+1
View File
@@ -0,0 +1 @@