feat: add reusable workflow definition graphs
This commit is contained in:
12
README.md
12
README.md
@@ -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
|
||||
|
||||
@@ -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
22
pyproject.toml
Normal 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"
|
||||
3
src/govoplan_workflow/__init__.py
Normal file
3
src/govoplan_workflow/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Workflow module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
1
src/govoplan_workflow/backend/__init__.py
Normal file
1
src/govoplan_workflow/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Workflow backend."""
|
||||
166
src/govoplan_workflow/backend/manifest.py
Normal file
166
src/govoplan_workflow/backend/manifest.py
Normal 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",
|
||||
]
|
||||
328
src/govoplan_workflow/backend/node_library.py
Normal file
328
src/govoplan_workflow/backend/node_library.py
Normal 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",
|
||||
]
|
||||
126
src/govoplan_workflow/backend/router.py
Normal file
126
src/govoplan_workflow/backend/router.py
Normal 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
src/govoplan_workflow/backend/schemas.py
Normal file
93
src/govoplan_workflow/backend/schemas.py
Normal 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]
|
||||
82
src/govoplan_workflow/backend/validation.py
Normal file
82
src/govoplan_workflow/backend/validation.py
Normal 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"]
|
||||
1
src/govoplan_workflow/py.typed
Normal file
1
src/govoplan_workflow/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
32
tests/test_manifest.py
Normal file
32
tests/test_manifest.py
Normal 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()
|
||||
97
tests/test_node_library.py
Normal file
97
tests/test_node_library.py
Normal 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()
|
||||
Reference in New Issue
Block a user