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).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- 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
|
The module should execute configurable state machines and command handoffs
|
||||||
between modules without importing their implementations. It coordinates cases,
|
between modules without importing their implementations. It coordinates cases,
|
||||||
tasks, forms, files, templates, mail, appointments, payments, and records
|
tasks, forms, files, templates, mail, appointments, payments, and records
|
||||||
through capabilities, events, commands, and DTOs.
|
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
|
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
|
become a separate module. It is quite viable to think about workflow
|
||||||
|
|||||||
@@ -95,7 +95,17 @@ Permit-to-payment MVP:
|
|||||||
|
|
||||||
## MVP Slice
|
## 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
|
- static workflow definition registration from configuration packages
|
||||||
- create/read/list workflow instances
|
- 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