Implement native BPMN workflows and guided modes

This commit is contained in:
2026-07-31 02:48:57 +02:00
parent c505e81006
commit f4974b4949
40 changed files with 8203 additions and 489 deletions
+10 -5
View File
@@ -35,6 +35,14 @@ source path shown in the editor. Derivation copies an immutable graph revision
and records its hash, node-library version, source scope, actor, Policy
decision, and effective ancestor limits.
BPMN 2.0 is Workflow's canonical graph language. The existing native graph
editor models BPMN events, activities, gateways, data, collaborations, and
artifacts directly; there is no separate modeler or browser-side BPMN library.
XML import projects BPMN semantics and DI geometry into that graph, while XML
export renders a deterministic interchange document from the graph. Immutable
revisions pin both representations and the native profile version. Unsupported
runtime semantics remain editable and portable, but activation fails closed.
The start-node library distinguishes explicit user, API, scheduled, event, and
parent-workflow starts. Manual starts and Dataflow/human handoffs are
operational. The other trigger and generic capability nodes remain explicit
@@ -42,8 +50,5 @@ definition contracts until their event/schedule dispatchers and versioned
operation providers are implemented.
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
modelling (and consequently import and export) in terms of BPMN, permitting
a standardized configuration of the system.
See [docs/BPMN_INTEROPERABILITY.md](docs/BPMN_INTEROPERABILITY.md) for the
notation, conformance, and adapter boundary.
+60 -13
View File
@@ -1,24 +1,51 @@
# BPMN Interoperability
# Native BPMN Graph
GovOPlaN distinguishes BPMN notation and XML interchange from executable
workflow semantics.
GovOPlaN uses BPMN 2.0 as Workflow's canonical graph language while keeping
notation support distinct from executable runtime support.
## Current Contract
- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support
profile.
- The native Workflow graph stores BPMN element and flow types, process
membership, containment, geometry, properties, and preserved extension
content. There is one editor and one graph representation.
- BPMN XML import maps standard elements and BPMN DI into the native graph.
Export deterministically renders XML and DI from the current graph. The
normalized XML artifact, its hash, and the native profile version are pinned
with every immutable revision.
- No browser-side BPMN modeler is required. The WebUI uses the same graph
surface and shared controls as the rest of GovOPlaN.
- `GET /api/v1/workflow/bpmn/profile` publishes all installed, versioned
conformance profiles.
- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML,
inventories every BPMN model element, detects duplicate IDs and selected
dangling references, and classifies elements as interchange-only, natively
mappable, or natively executable.
- `POST /api/v1/workflow/bpmn/compile` imports a bounded BPMN document into the
canonical native graph.
- `POST /api/v1/workflow/bpmn/render` exports a native graph as normalized BPMN
XML with BPMN DI geometry.
- `GET /api/v1/workflow/definitions/{id}/revisions/{revision}/bpmn` returns
the exact pinned document and its current availability/conformance
assessment.
- XML entities, DTD-based expansion, oversized documents, and malformed roots
are rejected.
- The native GovOPlaN graph remains the authoritative executable definition.
Inspection is not XML Schema validation and does not claim that every BPMN
semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter
can provide complete visual notation and XML round-tripping without forcing
unsupported elements into the native runner.
Inspection is not XML Schema validation and does not claim that every editable
BPMN construct can be executed. Notation and interchange remain available when
the native runtime cannot activate the document.
## Built-In Profiles
- `govoplan.native.bpmn@1.0.0` is the canonical graph and interchange profile.
It maps the supported BPMN vocabulary into native nodes and edges. Activation
separately validates whether every execution semantic is implemented.
- `govoplan.native.linear@1.0.0` and `bpmn.interchange@1.0.0` remain registered
for historical revision compatibility; new editor revisions use the native
BPMN profile.
Gateways, subprocesses, event definitions, transactions, compensation,
collaboration, and choreography remain editable and exportable even when their
token or lifecycle semantics are not yet implemented.
## Execution Boundary
@@ -32,6 +59,26 @@ behavior. Each executable mapping therefore needs:
4. migration and round-trip fixtures;
5. a declared fallback when the installed runtime cannot execute it.
Unsupported constructs remain visible and preserved by the future interchange
adapter, but activation must remain blocked until an execution adapter declares
support.
Unsupported execution constructs remain visible in the native graph, but
activation remains blocked until an execution adapter declares support.
## Adapter Boundary
Adapter packages register through the
`govoplan.workflow.bpmn_adapters` Python entry-point group. Workflow discovers
them without importing a concrete module. An adapter publishes a stable ID,
version, runtime kind, conformance statement, supported elements and event
definitions, operational requirements, validation, and canonical graph
materialization.
Revisions pin the exact adapter version. If that version is unavailable after
an installation change, the document remains readable and exportable but
cannot activate. External-engine adapters must still materialize lifecycle,
handoff, retry, cancellation, and audit evidence through the canonical
Workflow instance contract; a remote engine's private state is not the
platform record.
The conformance fixtures under `tests/fixtures/bpmn` cover processes,
collaboration, choreography, events, transactions, compensation, and data
elements. Every fixture must import and export through the native graph without
losing modeled nodes or flows; activation has its own narrower test matrix.
+59 -24
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from typing import Literal
from xml.etree.ElementTree import ParseError
from xml.etree.ElementTree import Element, ParseError
from defusedxml.ElementTree import fromstring
from defusedxml.common import DefusedXmlException
@@ -27,33 +27,45 @@ NATIVE_EXECUTION_ELEMENTS = frozenset(
{
"startEvent",
"endEvent",
"task",
"manualTask",
"userTask",
"serviceTask",
"businessRuleTask",
"receiveTask",
"sendTask",
"exclusiveGateway",
"parallelGateway",
"sequenceFlow",
"receiveTask",
"intermediateCatchEvent",
"intermediateThrowEvent",
"sequenceFlow",
}
)
NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
{
"task",
"definitions",
"process",
"collaboration",
"documentation",
"extensionElements",
"incoming",
"outgoing",
"conditionExpression",
"scriptTask",
"serviceTask",
"businessRuleTask",
"receiveTask",
"sendTask",
"callActivity",
"subProcess",
"transaction",
"adHocSubProcess",
"exclusiveGateway",
"parallelGateway",
"inclusiveGateway",
"eventBasedGateway",
"complexGateway",
"boundaryEvent",
"eventSubProcess",
"intermediateCatchEvent",
"intermediateThrowEvent",
"dataObject",
"dataObjectReference",
"dataStoreReference",
@@ -64,6 +76,16 @@ NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
"textAnnotation",
"association",
"group",
"dataInputAssociation",
"dataOutputAssociation",
"choreography",
"choreographyTask",
"callChoreography",
"subChoreography",
"conversation",
"callConversation",
"subConversation",
"conversationLink",
}
)
@@ -116,7 +138,7 @@ def bpmn_support_level(element_type: str) -> SupportLevel:
return "interchange_only"
def inspect_bpmn_xml(xml: str) -> BpmnInspection:
def parse_bpmn_xml(xml: str) -> Element:
encoded = xml.encode("utf-8")
if not encoded:
raise BpmnInspectionError("BPMN XML is empty")
@@ -134,6 +156,15 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection:
raise BpmnInspectionError(
"BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace"
)
if sum(1 for _item in root.iter()) > MAX_BPMN_ELEMENTS:
raise BpmnInspectionError(
f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit"
)
return root
def inspect_bpmn_xml(xml: str) -> BpmnInspection:
root = parse_bpmn_xml(xml)
diagnostics: list[BpmnDiagnostic] = []
elements: list[BpmnElementInventoryItem] = []
@@ -149,10 +180,6 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection:
while stack:
element, parent_type, parent_id = stack.pop()
visited += 1
if visited > MAX_BPMN_ELEMENTS:
raise BpmnInspectionError(
f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit"
)
element_namespace, element_type = _qualified_name(element.tag)
element_id = _bounded_attribute(element.attrib.get("id"), 255)
if element_namespace == BPMN_MODEL_NAMESPACE:
@@ -268,17 +295,24 @@ def _collect_references(
attributes: dict[str, str],
references: list[tuple[str, str | None, str]],
) -> None:
fields: tuple[str, ...]
if element_type == "sequenceFlow":
fields = ("sourceRef", "targetRef")
elif element_type == "messageFlow":
fields = ("sourceRef", "targetRef", "messageRef")
elif element_type == "participant":
fields = ("processRef",)
elif element_type == "lane":
fields = ("partitionElementRef",)
else:
fields = ()
fields_by_element = {
"sequenceFlow": ("sourceRef", "targetRef"),
"messageFlow": ("sourceRef", "targetRef", "messageRef"),
"association": ("sourceRef", "targetRef"),
"participant": ("processRef",),
"lane": ("partitionElementRef",),
"boundaryEvent": ("attachedToRef",),
"dataInputAssociation": ("sourceRef", "targetRef"),
"dataOutputAssociation": ("sourceRef", "targetRef"),
"dataObjectReference": ("dataObjectRef",),
"dataStoreReference": ("dataStoreRef",),
"messageEventDefinition": ("messageRef", "operationRef"),
"signalEventDefinition": ("signalRef",),
"errorEventDefinition": ("errorRef",),
"escalationEventDefinition": ("escalationRef",),
"compensateEventDefinition": ("activityRef",),
}
fields = fields_by_element.get(element_type, ())
for field in fields:
value = attributes.get(field)
if value:
@@ -306,4 +340,5 @@ __all__ = [
"NATIVE_MAPPING_ELEMENTS",
"bpmn_support_level",
"inspect_bpmn_xml",
"parse_bpmn_xml",
]
@@ -0,0 +1,721 @@
from __future__ import annotations
import hashlib
import logging
from collections import Counter
from dataclasses import dataclass
from importlib.metadata import entry_points
from threading import Lock
from typing import Literal, Protocol, runtime_checkable
from xml.etree.ElementTree import Element
from govoplan_workflow.backend.bpmn import (
BPMN_DI_NAMESPACE,
BPMN_MODEL_NAMESPACE,
OMG_DC_NAMESPACE,
BpmnDiagnostic,
BpmnInspection,
inspect_bpmn_xml,
parse_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_graph import (
BPMN_EDGE_LOCAL_NAMES,
BPMN_NODE_LOCAL_NAMES,
BpmnGraphError,
NATIVE_BPMN_ADAPTER_ID,
NATIVE_BPMN_ADAPTER_VERSION,
import_bpmn_graph,
runtime_diagnostics,
)
from govoplan_workflow.backend.schemas import (
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
BPMN_ADAPTER_ENTRY_POINT_GROUP = "govoplan.workflow.bpmn_adapters"
INTERCHANGE_ADAPTER_ID = "bpmn.interchange"
NATIVE_LINEAR_ADAPTER_ID = "govoplan.native.linear"
RuntimeKind = Literal["model_only", "native_graph", "external"]
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class BpmnExecutionProfile:
id: str
version: str
label: str
description: str
conformance: str
runtime_kind: RuntimeKind
executable: bool
supported_elements: tuple[str, ...]
supported_event_definitions: tuple[str, ...] = ()
requirements: tuple[str, ...] = ()
@runtime_checkable
class BpmnExecutionAdapter(Protocol):
profile: BpmnExecutionProfile
def diagnostics(
self,
xml: str,
inspection: BpmnInspection,
*,
activation: bool,
) -> tuple[BpmnDiagnostic, ...]:
...
def compile(
self,
xml: str,
inspection: BpmnInspection,
) -> WorkflowGraph | None:
...
class BpmnAdapterError(ValueError):
def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None:
self.diagnostics = diagnostics
first = next(
(item for item in diagnostics if item.severity == "error"),
diagnostics[0] if diagnostics else None,
)
super().__init__(
first.message if first is not None else "BPMN adapter validation failed."
)
class BpmnExecutionAdapterRegistry:
def __init__(self) -> None:
self._adapters: dict[tuple[str, str], BpmnExecutionAdapter] = {}
def register(self, adapter: BpmnExecutionAdapter) -> None:
if not isinstance(adapter, BpmnExecutionAdapter):
raise TypeError("BPMN adapter does not implement BpmnExecutionAdapter.")
profile = adapter.profile
if not profile.id.strip() or not profile.version.strip():
raise ValueError("BPMN adapters require a stable id and version.")
key = (profile.id, profile.version)
if key in self._adapters:
raise ValueError(
f"Duplicate BPMN execution adapter {profile.id}@{profile.version}."
)
self._adapters[key] = adapter
def profiles(self) -> tuple[BpmnExecutionProfile, ...]:
return tuple(
adapter.profile
for _key, adapter in sorted(
self._adapters.items(),
key=lambda item: (
not item[1].profile.executable,
item[1].profile.label.casefold(),
item[1].profile.id,
item[1].profile.version,
),
)
)
def resolve(
self,
adapter_id: str,
version: str | None = None,
) -> BpmnExecutionAdapter | None:
if version is not None:
return self._adapters.get((adapter_id, version))
matches = [
adapter
for (candidate_id, _candidate_version), adapter in self._adapters.items()
if candidate_id == adapter_id
]
return max(matches, key=lambda item: item.profile.version) if matches else None
def require(
self,
adapter_id: str,
version: str | None = None,
) -> BpmnExecutionAdapter:
adapter = self.resolve(adapter_id, version)
if adapter is None:
suffix = f"@{version}" if version else ""
raise BpmnAdapterError(
(
BpmnDiagnostic(
severity="error",
code="adapter.unavailable",
message=(
f"BPMN execution adapter {adapter_id}{suffix} is not "
"installed."
),
),
)
)
return adapter
class InterchangeOnlyAdapter:
profile = BpmnExecutionProfile(
id=INTERCHANGE_ADAPTER_ID,
version="1.0.0",
label="Historical model-only revision",
description=(
"Read historical BPMN 2.0 revisions that were stored without a "
"native graph or executable semantics."
),
conformance="BPMN 2.0 interchange",
runtime_kind="model_only",
executable=False,
supported_elements=("*",),
)
def diagnostics(
self,
xml: str,
inspection: BpmnInspection,
*,
activation: bool,
) -> tuple[BpmnDiagnostic, ...]:
del xml, inspection
if not activation:
return ()
return (
BpmnDiagnostic(
severity="error",
code="adapter.model_only",
message=(
"This BPMN revision is model-only. Select an executable "
"conformance profile and save a new revision before activation."
),
),
)
def compile(
self,
xml: str,
inspection: BpmnInspection,
) -> WorkflowGraph | None:
del xml, inspection
return None
class NativeBpmnGraphAdapter:
profile = BpmnExecutionProfile(
id=NATIVE_BPMN_ADAPTER_ID,
version=NATIVE_BPMN_ADAPTER_VERSION,
label="GovOPlaN native BPMN",
description=(
"Use BPMN 2.0 as the canonical GovOPlaN graph language. The "
"native graph preserves standard notation while activation "
"fails closed for runtime semantics that are not implemented."
),
conformance="BPMN 2.0 native graph profile 1",
runtime_kind="native_graph",
executable=True,
supported_elements=tuple(
sorted(
{
"definitions",
"process",
"collaboration",
"choreography",
"documentation",
"extensionElements",
"incoming",
"outgoing",
"conditionExpression",
"laneSet",
"flowNodeRef",
*BPMN_NODE_LOCAL_NAMES,
*BPMN_EDGE_LOCAL_NAMES,
}
)
),
supported_event_definitions=(
"message",
"timer",
"conditional",
"signal",
"error",
"escalation",
"compensation",
"link",
"cancel",
"terminate",
),
requirements=(
"BPMN is stored as the canonical native graph",
"Imported XML is parsed with bounded, entity-safe inspection",
"Unsupported execution semantics remain editable but block activation",
),
)
def diagnostics(
self,
xml: str,
inspection: BpmnInspection,
*,
activation: bool,
) -> tuple[BpmnDiagnostic, ...]:
diagnostics = [
item for item in inspection.diagnostics if item.severity == "error"
]
try:
graph = import_bpmn_graph(xml)
except BpmnGraphError as exc:
diagnostics.extend(exc.diagnostics)
return _deduplicate_diagnostics(diagnostics)
if activation:
diagnostics.extend(runtime_diagnostics(graph))
return _deduplicate_diagnostics(diagnostics)
def compile(
self,
xml: str,
inspection: BpmnInspection,
) -> WorkflowGraph | None:
del inspection
return import_bpmn_graph(xml)
class NativeLinearAdapter:
_supported_elements = frozenset(
{
"definitions",
"process",
"documentation",
"extensionElements",
"incoming",
"outgoing",
"startEvent",
"endEvent",
"task",
"manualTask",
"userTask",
"sequenceFlow",
}
)
_node_types = frozenset(
{"startEvent", "endEvent", "task", "manualTask", "userTask"}
)
profile = BpmnExecutionProfile(
id=NATIVE_LINEAR_ADAPTER_ID,
version="1.0.0",
label="GovOPlaN native linear",
description=(
"Execute a single linear process containing a plain start event, "
"human tasks, and one or more plain end events."
),
conformance="GovOPlaN native linear BPMN profile 1",
runtime_kind="native_graph",
executable=True,
supported_elements=tuple(sorted(_supported_elements)),
requirements=(
"Exactly one executable process",
"Exactly one plain start event",
"No gateways, subprocesses, boundary events, or event definitions",
"Every activity has one incoming and one outgoing sequence flow",
),
)
def diagnostics(
self,
xml: str,
inspection: BpmnInspection,
*,
activation: bool,
) -> tuple[BpmnDiagnostic, ...]:
del activation
diagnostics = list(
item
for item in inspection.diagnostics
if item.severity == "error"
)
unsupported = [
item
for item in inspection.elements
if item.element_type not in self._supported_elements
]
for item in unsupported:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.unsupported_element",
message=(
f"{item.element_type} is not supported by the "
f"{self.profile.label} profile."
),
element_id=item.element_id,
)
)
if inspection.process_count != 1:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.process_count",
message="The native linear profile requires exactly one process.",
)
)
if inspection.executable_process_count != 1:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.executable_process",
message=(
"The native linear profile requires one process with "
"isExecutable=\"true\"."
),
)
)
if diagnostics:
return _deduplicate_diagnostics(diagnostics)
root = parse_bpmn_xml(xml)
process = next(
(
item
for item in root
if _qualified_name(item.tag)
== (BPMN_MODEL_NAMESPACE, "process")
),
None,
)
if process is None:
return (
BpmnDiagnostic(
severity="error",
code="adapter.process_missing",
message="The executable BPMN process is missing.",
),
)
node_ids = {
item.attrib.get("id", "")
for item in process
if _qualified_name(item.tag)[1] in self._node_types
and item.attrib.get("id")
}
starts = [
item
for item in process
if _qualified_name(item.tag)[1] == "startEvent"
]
if len(starts) != 1:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.start_count",
message="The native linear profile requires exactly one start event.",
)
)
incoming: Counter[str] = Counter()
outgoing: Counter[str] = Counter()
for item in process:
if _qualified_name(item.tag)[1] != "sequenceFlow":
continue
source = item.attrib.get("sourceRef", "")
target = item.attrib.get("targetRef", "")
if source in node_ids:
outgoing[source] += 1
if target in node_ids:
incoming[target] += 1
for item in process:
_namespace, element_type = _qualified_name(item.tag)
if element_type not in self._node_types:
continue
element_id = item.attrib.get("id")
if not element_id:
continue
if element_type != "startEvent" and incoming[element_id] != 1:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.incoming_count",
message=(
f"{element_type} requires exactly one incoming "
"sequence flow in the native linear profile."
),
element_id=element_id,
)
)
if element_type != "endEvent" and outgoing[element_id] != 1:
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.outgoing_count",
message=(
f"{element_type} requires exactly one outgoing "
"sequence flow in the native linear profile."
),
element_id=element_id,
)
)
return _deduplicate_diagnostics(diagnostics)
def compile(
self,
xml: str,
inspection: BpmnInspection,
) -> WorkflowGraph | None:
diagnostics = self.diagnostics(xml, inspection, activation=True)
if any(item.severity == "error" for item in diagnostics):
raise BpmnAdapterError(diagnostics)
root = parse_bpmn_xml(xml)
process = next(
item
for item in root
if _qualified_name(item.tag) == (BPMN_MODEL_NAMESPACE, "process")
)
positions = _diagram_positions(root)
raw_nodes = [
item
for item in process
if _qualified_name(item.tag)[1] in self._node_types
]
id_map = {
item.attrib["id"]: _graph_id(item.attrib["id"])
for item in raw_nodes
}
fallback_positions = {
item.attrib["id"]: WorkflowPosition(x=80 + index * 240, y=140)
for index, item in enumerate(_linear_node_order(process, raw_nodes))
}
nodes: list[WorkflowNode] = []
for item in raw_nodes:
element_type = _qualified_name(item.tag)[1]
bpmn_id = item.attrib["id"]
label = item.attrib.get("name", "").strip()
if element_type == "startEvent":
node_type = "workflow.start.manual"
config = {"input_schema_ref": ""}
label = label or "Start"
elif element_type == "endEvent":
node_type = "workflow.end.completed"
config = {"output_mapping": {}}
label = label or "Completed"
else:
node_type = "workflow.activity"
label = label or "Activity"
config = {
"title": label,
"instructions": "",
"assignee": "",
"due_after": "",
}
nodes.append(
WorkflowNode(
id=id_map[bpmn_id],
type=node_type,
label=label,
position=positions.get(bpmn_id, fallback_positions[bpmn_id]),
config=config,
)
)
edges = [
WorkflowEdge(
id=_graph_id(item.attrib["id"]),
source=id_map[item.attrib["sourceRef"]],
target=id_map[item.attrib["targetRef"]],
)
for item in process
if _qualified_name(item.tag)[1] == "sequenceFlow"
]
return WorkflowGraph(nodes=nodes, edges=edges)
def assess_bpmn_adapter(
xml: str,
*,
adapter_id: str,
adapter_version: str | None = None,
activation: bool = False,
registry: BpmnExecutionAdapterRegistry | None = None,
) -> tuple[
BpmnExecutionAdapter,
BpmnInspection,
tuple[BpmnDiagnostic, ...],
]:
active_registry = registry or bpmn_adapter_registry()
adapter = active_registry.require(adapter_id, adapter_version)
inspection = inspect_bpmn_xml(xml)
diagnostics = adapter.diagnostics(
xml,
inspection,
activation=activation,
)
return adapter, inspection, diagnostics
def compile_bpmn_to_graph(
xml: str,
*,
adapter_id: str,
adapter_version: str | None = None,
activation: bool = False,
registry: BpmnExecutionAdapterRegistry | None = None,
) -> tuple[BpmnExecutionAdapter, BpmnInspection, WorkflowGraph | None]:
adapter, inspection, diagnostics = assess_bpmn_adapter(
xml,
adapter_id=adapter_id,
adapter_version=adapter_version,
activation=activation,
registry=registry,
)
if any(item.severity == "error" for item in diagnostics):
raise BpmnAdapterError(diagnostics)
graph = adapter.compile(xml, inspection)
if adapter.profile.executable and graph is None:
raise BpmnAdapterError(
(
BpmnDiagnostic(
severity="error",
code="adapter.no_runtime_materialization",
message=(
f"{adapter.profile.label} did not produce executable "
"Workflow runtime materialization."
),
),
)
)
return adapter, inspection, graph
_registry: BpmnExecutionAdapterRegistry | None = None
_registry_lock = Lock()
def bpmn_adapter_registry() -> BpmnExecutionAdapterRegistry:
global _registry
if _registry is not None:
return _registry
with _registry_lock:
if _registry is not None:
return _registry
registry = BpmnExecutionAdapterRegistry()
registry.register(NativeBpmnGraphAdapter())
registry.register(NativeLinearAdapter())
registry.register(InterchangeOnlyAdapter())
for entry_point in entry_points(group=BPMN_ADAPTER_ENTRY_POINT_GROUP):
try:
loaded = entry_point.load()
candidate = (
loaded()
if callable(loaded) and not hasattr(loaded, "profile")
else loaded
)
registry.register(candidate)
except Exception:
logger.exception(
"Could not register BPMN execution adapter %s",
entry_point.name,
)
_registry = registry
return registry
def _diagram_positions(root: Element) -> dict[str, WorkflowPosition]:
positions: dict[str, WorkflowPosition] = {}
for item in root.iter():
if _qualified_name(item.tag) != (BPMN_DI_NAMESPACE, "BPMNShape"):
continue
element_id = item.attrib.get("bpmnElement")
if not element_id:
continue
bounds = next(
(
child
for child in item
if _qualified_name(child.tag) == (OMG_DC_NAMESPACE, "Bounds")
),
None,
)
if bounds is None:
continue
try:
positions[element_id] = WorkflowPosition(
x=float(bounds.attrib.get("x", "0")),
y=float(bounds.attrib.get("y", "0")),
)
except ValueError:
continue
return positions
def _linear_node_order(
process: Element,
nodes: list[Element],
) -> list[Element]:
by_id = {item.attrib["id"]: item for item in nodes}
target_by_source = {
item.attrib.get("sourceRef"): item.attrib.get("targetRef")
for item in process
if _qualified_name(item.tag)[1] == "sequenceFlow"
}
start = next(
(
item
for item in nodes
if _qualified_name(item.tag)[1] == "startEvent"
),
None,
)
ordered: list[Element] = []
seen: set[str] = set()
current = start
while current is not None:
current_id = current.attrib["id"]
if current_id in seen:
break
seen.add(current_id)
ordered.append(current)
current = by_id.get(target_by_source.get(current_id, ""))
ordered.extend(item for item in nodes if item.attrib["id"] not in seen)
return ordered
def _graph_id(value: str) -> str:
if len(value) <= 120:
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
return f"{value[:103]}-{digest}"
def _qualified_name(tag: str) -> tuple[str | None, str]:
if tag.startswith("{") and "}" in tag:
namespace, local_name = tag[1:].split("}", 1)
return namespace, local_name
return None, tag
def _deduplicate_diagnostics(
diagnostics: list[BpmnDiagnostic],
) -> tuple[BpmnDiagnostic, ...]:
seen: set[tuple[str, str | None, str]] = set()
result: list[BpmnDiagnostic] = []
for item in diagnostics:
key = (item.code, item.element_id, item.message)
if key in seen:
continue
seen.add(key)
result.append(item)
return tuple(result)
__all__ = [
"BPMN_ADAPTER_ENTRY_POINT_GROUP",
"INTERCHANGE_ADAPTER_ID",
"NATIVE_BPMN_ADAPTER_ID",
"NATIVE_LINEAR_ADAPTER_ID",
"BpmnAdapterError",
"BpmnExecutionAdapter",
"BpmnExecutionAdapterRegistry",
"BpmnExecutionProfile",
"RuntimeKind",
"assess_bpmn_adapter",
"bpmn_adapter_registry",
"compile_bpmn_to_graph",
]
File diff suppressed because it is too large Load Diff
@@ -184,6 +184,38 @@ class WorkflowDefinitionRevision(Base, TimestampMixin):
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
library_id: Mapped[str] = mapped_column(String(100), nullable=False)
library_version: Mapped[str] = mapped_column(String(40), nullable=False)
execution_mode: Mapped[str] = mapped_column(
String(20),
default="hybrid",
nullable=False,
)
view_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
view_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
)
bpmn_xml: Mapped[str | None] = mapped_column(Text, nullable=True)
bpmn_hash: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
index=True,
)
bpmn_adapter_id: Mapped[str | None] = mapped_column(
String(120),
nullable=True,
)
bpmn_adapter_version: Mapped[str | None] = mapped_column(
String(40),
nullable=True,
)
bpmn_runtime_kind: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
)
bpmn_executable: Mapped[bool | None] = mapped_column(
Boolean,
nullable=True,
)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
@@ -232,6 +264,12 @@ class WorkflowInstance(Base, TimestampMixin):
nullable=False,
index=True,
)
start_origin: Mapped[str] = mapped_column(
String(30),
default="user",
nullable=False,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(
String(255),
nullable=False,
File diff suppressed because it is too large Load Diff
+42
View File
@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
NavItem,
PermissionDefinition,
RoleTemplate,
ViewSurface,
)
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
@@ -169,6 +170,11 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"),
ModuleInterfaceProvider(
name="workflow.bpmn_execution_adapters",
version="1.0.0",
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -239,6 +245,15 @@ manifest = ModuleManifest(
order=74,
),
),
view_surfaces=(
ViewSurface(
id="workflow.widget.open-work",
module_id=MODULE_ID,
kind="section",
label="Open workflow work widget",
order=76,
),
),
),
route_factory=_router,
capability_factories={
@@ -291,6 +306,33 @@ manifest = ModuleManifest(
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
order=76,
),
DocumentationTopic(
id="workflow.bpmn-interchange",
title="BPMN modeling and execution profiles",
summary=(
"Lossless BPMN 2.0 revisions with explicit, fail-closed "
"execution conformance."
),
body=(
"BPMN 2.0 is Workflow's canonical native graph language. The "
"shared graph editor models BPMN nodes, flows, containment, and "
"diagram geometry directly without a separate browser-side "
"modeler. Imported XML is normalized into the graph and every "
"immutable revision pins a deterministic XML artifact. Activation "
"requires a pinned execution adapter and version whose declared "
"profile accepts every modeled runtime semantic. Unsupported "
"runtime constructs remain editable and exportable. "
"Adapter packages integrate through the "
"govoplan.workflow.bpmn_adapters entry-point group and must "
"materialize canonical Workflow runtime state; Workflow never "
"imports a concrete engine module."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "power_user", "product_owner"),
related_modules=("audit", "policy", "views"),
order=77,
),
),
)
@@ -0,0 +1,68 @@
"""v0.1.14 normalized BPMN revision artifacts
Revision ID: e9a4c6b8d2f1
Revises: d8f2a5c7e1b4
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9a4c6b8d2f1"
down_revision = "d8f2a5c7e1b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
batch_op.add_column(sa.Column("bpmn_xml", sa.Text(), nullable=True))
batch_op.add_column(
sa.Column("bpmn_hash", sa.String(length=64), nullable=True)
)
batch_op.add_column(
sa.Column(
"bpmn_adapter_id",
sa.String(length=120),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"bpmn_adapter_version",
sa.String(length=40),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"bpmn_runtime_kind",
sa.String(length=20),
nullable=True,
)
)
batch_op.add_column(
sa.Column("bpmn_executable", sa.Boolean(), nullable=True)
)
op.create_index(
op.f("ix_workflow_definition_revisions_bpmn_hash"),
"workflow_definition_revisions",
["bpmn_hash"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_workflow_definition_revisions_bpmn_hash"),
table_name="workflow_definition_revisions",
)
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
batch_op.drop_column("bpmn_executable")
batch_op.drop_column("bpmn_runtime_kind")
batch_op.drop_column("bpmn_adapter_version")
batch_op.drop_column("bpmn_adapter_id")
batch_op.drop_column("bpmn_hash")
batch_op.drop_column("bpmn_xml")
@@ -0,0 +1,62 @@
"""v0.1.14 workflow execution modes and focused Views
Revision ID: f1b7d3e5a9c2
Revises: e9a4c6b8d2f1
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f1b7d3e5a9c2"
down_revision = "e9a4c6b8d2f1"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
batch_op.add_column(
sa.Column(
"execution_mode",
sa.String(length=20),
nullable=False,
server_default="hybrid",
)
)
batch_op.add_column(
sa.Column("view_id", sa.String(length=36), nullable=True)
)
batch_op.add_column(
sa.Column(
"view_revision_id",
sa.String(length=36),
nullable=True,
)
)
with op.batch_alter_table("workflow_instances") as batch_op:
batch_op.add_column(
sa.Column(
"start_origin",
sa.String(length=30),
nullable=False,
server_default="user",
)
)
batch_op.create_index(
"ix_workflow_instances_start_origin",
["start_origin"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("workflow_instances") as batch_op:
batch_op.drop_index("ix_workflow_instances_start_origin")
batch_op.drop_column("start_origin")
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
batch_op.drop_column("view_revision_id")
batch_op.drop_column("view_id")
batch_op.drop_column("execution_mode")
+720 -22
View File
@@ -4,13 +4,18 @@ from govoplan_core.core.definition_graphs import (
DefinitionConfigField,
DefinitionGraphConstraints,
DefinitionGraphLibrary,
DefinitionNodeCountConstraint,
DefinitionNodeType,
DefinitionPort,
)
CATEGORY_LABELS = {
"bpmn_event": "Events",
"bpmn_activity": "Activities",
"bpmn_gateway": "Gateways",
"bpmn_data": "Data",
"bpmn_collaboration": "Collaboration",
"bpmn_artifact": "Artifacts",
"trigger": "Start",
"activity": "Activities",
"decision": "Decisions",
@@ -19,7 +24,17 @@ CATEGORY_LABELS = {
"outcome": "Outcomes",
}
WORKFLOW_NODE_TYPES = (
FOCUSED_VIEW_SURFACES_FIELD = DefinitionConfigField(
id="view_surface_ids",
label="Focused View surfaces",
kind="view_surfaces",
description=(
"Optionally narrow the workflow View while this step requires "
"attention. Administrator limits and protected surfaces still apply."
),
)
LEGACY_WORKFLOW_NODE_TYPES = (
DefinitionNodeType(
type="workflow.start.manual",
category="trigger",
@@ -154,12 +169,14 @@ WORKFLOW_NODE_TYPES = (
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"title": "",
"instructions": "",
"assignee": "",
"due_after": "",
"view_surface_ids": [],
},
),
DefinitionNodeType(
@@ -182,8 +199,14 @@ WORKFLOW_NODE_TYPES = (
label="Required evidence",
kind="string_list",
),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={"title": "", "reviewer": "", "required_evidence": []},
default_config={
"title": "",
"reviewer": "",
"required_evidence": [],
"view_surface_ids": [],
},
),
DefinitionNodeType(
type="workflow.decision",
@@ -231,8 +254,13 @@ WORKFLOW_NODE_TYPES = (
),
),
DefinitionConfigField(id="value", label="Value", kind="text"),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={"mode": "manual", "value": ""},
default_config={
"mode": "manual",
"value": "",
"view_surface_ids": [],
},
),
DefinitionNodeType(
type="workflow.capability",
@@ -282,13 +310,15 @@ WORKFLOW_NODE_TYPES = (
("continue", "Continue on failure"),
),
),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"capability": "",
"operation": "",
"input_mapping": {},
"idempotency_key": "",
"idempotency_key": "workflow-step",
"failure_policy": "manual",
"view_surface_ids": [],
},
),
DefinitionNodeType(
@@ -357,7 +387,18 @@ WORKFLOW_NODE_TYPES = (
("continue", "Continue"),
),
),
DefinitionConfigField(
id="failure_policy",
label="Failures",
kind="select",
options=(
("manual", "Require intervention"),
("fail", "Fail the workflow"),
("continue", "Follow failure path"),
),
),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"pipeline_ref": "",
@@ -366,7 +407,9 @@ WORKFLOW_NODE_TYPES = (
"row_limit": 500,
"publication_target_ref": "",
"warning_policy": "review",
"failure_policy": "manual",
"input_mapping": {},
"view_surface_ids": [],
},
),
DefinitionNodeType(
@@ -411,31 +454,684 @@ WORKFLOW_NODE_TYPES = (
),
)
_INCOMING = (
DefinitionPort(
id="incoming",
label="Incoming",
required=False,
multiple=True,
minimum_connections=0,
),
)
_OPTIONAL_INCOMING = (
DefinitionPort(
id="incoming",
label="Incoming",
required=False,
multiple=True,
minimum_connections=0,
),
)
_OUTGOING = (
DefinitionPort(
id="outgoing",
label="Outgoing",
required=False,
multiple=True,
minimum_connections=0,
),
)
_DOCUMENTATION_FIELD = DefinitionConfigField(
id="documentation",
label="Documentation",
kind="textarea",
)
_EVENT_DEFINITION_FIELD = DefinitionConfigField(
id="event_definition",
label="Event definition",
kind="select",
options=(
("none", "None"),
("message", "Message"),
("timer", "Timer"),
("conditional", "Conditional"),
("signal", "Signal"),
("error", "Error"),
("escalation", "Escalation"),
("compensation", "Compensation"),
("link", "Link"),
("cancel", "Cancel"),
("terminate", "Terminate"),
("multiple", "Multiple"),
("parallelMultiple", "Parallel multiple"),
),
)
def _bpmn_node(
*,
type_id: str,
category: str,
label: str,
description: str,
icon: str,
shape: str,
input_ports: tuple[DefinitionPort, ...] = _INCOMING,
output_ports: tuple[DefinitionPort, ...] = _OUTGOING,
config_fields: tuple[DefinitionConfigField, ...] = (),
default_config: dict[str, object] | None = None,
runtime_support: str = "model_only",
) -> DefinitionNodeType:
return DefinitionNodeType(
type=type_id,
category=category,
label=label,
description=description,
icon=icon,
input_ports=input_ports,
output_ports=output_ports,
config_fields=(*config_fields, _DOCUMENTATION_FIELD),
default_config={
**(default_config or {}),
"documentation": "",
},
metadata={
"notation": "bpmn-2.0",
"shape": shape,
"runtime_support": runtime_support,
},
)
_TASK_FIELDS = (
DefinitionConfigField(id="title", label="Task title", kind="text"),
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
)
_HUMAN_TASK_FIELDS = (
*_TASK_FIELDS,
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
FOCUSED_VIEW_SURFACES_FIELD,
)
_SERVICE_TASK_FIELDS = (
*_TASK_FIELDS,
DefinitionConfigField(
id="implementation",
label="Implementation",
kind="select",
options=(
("capability", "Module capability"),
("dataflow", "Dataflow"),
),
),
DefinitionConfigField(id="capability", label="Capability", kind="capability"),
DefinitionConfigField(id="operation", label="Operation", kind="text"),
DefinitionConfigField(id="pipeline_ref", label="Dataflow", kind="dataflow"),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
DefinitionConfigField(
id="idempotency_key",
label="Idempotency key",
kind="expression",
),
DefinitionConfigField(
id="failure_policy",
label="On failure",
kind="select",
options=(
("retry", "Retry"),
("manual", "Require intervention"),
("continue", "Continue"),
("fail", "Fail the workflow"),
),
),
FOCUSED_VIEW_SURFACES_FIELD,
)
BPMN_NODE_TYPES = (
_bpmn_node(
type_id="bpmn.startEvent",
category="bpmn_event",
label="Start event",
description="Start a BPMN process from a user, API, schedule, event, or parent flow.",
icon="circle-play",
shape="event-start",
input_ports=(),
config_fields=(
_EVENT_DEFINITION_FIELD,
DefinitionConfigField(
id="start_kind",
label="GovOPlaN start",
kind="select",
required=True,
options=(
("manual", "Manual"),
("api", "API"),
("schedule", "Schedule"),
("event", "Platform event"),
("parent_workflow", "Parent workflow"),
),
),
DefinitionConfigField(
id="input_schema_ref",
label="Input schema",
kind="text",
),
DefinitionConfigField(
id="authorization_policy_ref",
label="Authorization policy",
kind="text",
),
DefinitionConfigField(id="schedule", label="Schedule", kind="schedule"),
DefinitionConfigField(id="timezone", label="Time zone", kind="timezone"),
DefinitionConfigField(id="event_type", label="Event type", kind="text"),
DefinitionConfigField(id="filter", label="Event filter", kind="expression"),
),
default_config={
"event_definition": "none",
"start_kind": "manual",
"input_schema_ref": "",
"authorization_policy_ref": "",
"schedule": "",
"timezone": "Europe/Berlin",
"event_type": "",
"filter": "",
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.intermediateCatchEvent",
category="bpmn_event",
label="Intermediate catch event",
description="Wait until the configured BPMN event is caught.",
icon="circle-dot",
shape="event-intermediate-catch",
config_fields=(
_EVENT_DEFINITION_FIELD,
DefinitionConfigField(
id="wait_mode",
label="Wait for",
kind="select",
options=(
("duration", "Duration"),
("deadline", "Deadline"),
("event", "Event"),
("manual", "Manual resume"),
),
),
DefinitionConfigField(id="value", label="Value", kind="text"),
DefinitionConfigField(id="event_ref", label="Event reference", kind="text"),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"event_definition": "none",
"wait_mode": "manual",
"value": "",
"event_ref": "",
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.intermediateThrowEvent",
category="bpmn_event",
label="Intermediate throw event",
description="Emit an intermediate BPMN event.",
icon="circle-dot-dashed",
shape="event-intermediate-throw",
config_fields=(
_EVENT_DEFINITION_FIELD,
DefinitionConfigField(id="event_ref", label="Event reference", kind="text"),
),
default_config={"event_definition": "none", "event_ref": ""},
),
_bpmn_node(
type_id="bpmn.boundaryEvent",
category="bpmn_event",
label="Boundary event",
description="Catch an interrupting or non-interrupting event attached to an activity.",
icon="circle-dashed",
shape="event-boundary",
input_ports=(),
config_fields=(
_EVENT_DEFINITION_FIELD,
DefinitionConfigField(
id="attached_to_ref",
label="Attached activity",
kind="text",
required=True,
),
DefinitionConfigField(
id="cancel_activity",
label="Activity behavior",
kind="select",
options=(
("true", "Interrupt activity"),
("false", "Keep activity running"),
),
),
),
default_config={
"event_definition": "none",
"attached_to_ref": "",
"cancel_activity": "true",
},
),
_bpmn_node(
type_id="bpmn.endEvent",
category="bpmn_event",
label="End event",
description="End a BPMN process with an optional result event.",
icon="circle-stop",
shape="event-end",
output_ports=(),
config_fields=(
_EVENT_DEFINITION_FIELD,
DefinitionConfigField(
id="outcome",
label="GovOPlaN outcome",
kind="select",
options=(
("completed", "Completed"),
("cancelled", "Cancelled"),
),
),
DefinitionConfigField(
id="output_mapping",
label="Output mapping",
kind="mapping",
),
),
default_config={
"event_definition": "none",
"outcome": "completed",
"output_mapping": {},
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.task",
category="bpmn_activity",
label="Task",
description="A generic BPMN task completed as a governed human activity.",
icon="square",
shape="activity",
config_fields=_HUMAN_TASK_FIELDS,
default_config={
"title": "",
"instructions": "",
"assignee": "",
"due_after": "",
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.userTask",
category="bpmn_activity",
label="User task",
description="A task requiring a user action, review, or evidence.",
icon="user-round-check",
shape="activity",
config_fields=(
*_HUMAN_TASK_FIELDS,
DefinitionConfigField(
id="task_mode",
label="Task mode",
kind="select",
options=(
("activity", "Activity"),
("review", "Review"),
),
),
DefinitionConfigField(
id="required_evidence",
label="Required evidence",
kind="string_list",
),
),
default_config={
"title": "",
"instructions": "",
"assignee": "",
"due_after": "",
"task_mode": "activity",
"required_evidence": [],
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.manualTask",
category="bpmn_activity",
label="Manual task",
description="A task performed outside the automated runtime.",
icon="hand",
shape="activity",
config_fields=_HUMAN_TASK_FIELDS,
default_config={
"title": "",
"instructions": "",
"assignee": "",
"due_after": "",
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.serviceTask",
category="bpmn_activity",
label="Service task",
description="Invoke a versioned module capability or published Dataflow.",
icon="cog",
shape="activity",
config_fields=_SERVICE_TASK_FIELDS,
default_config={
"title": "",
"instructions": "",
"implementation": "capability",
"capability": "",
"operation": "",
"pipeline_ref": "",
"input_mapping": {},
"idempotency_key": "workflow-step",
"failure_policy": "manual",
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.sendTask",
category="bpmn_activity",
label="Send task",
description="Send a message through a configured implementation.",
icon="send",
shape="activity",
config_fields=_SERVICE_TASK_FIELDS,
default_config={
"title": "",
"instructions": "",
"implementation": "capability",
"capability": "",
"operation": "",
"pipeline_ref": "",
"input_mapping": {},
"idempotency_key": "workflow-step",
"failure_policy": "manual",
"view_surface_ids": [],
},
runtime_support="native",
),
_bpmn_node(
type_id="bpmn.receiveTask",
category="bpmn_activity",
label="Receive task",
description="Wait until a matching message is received.",
icon="inbox",
shape="activity",
config_fields=(
*_TASK_FIELDS,
DefinitionConfigField(id="message_ref", label="Message reference", kind="text"),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"title": "",
"instructions": "",
"message_ref": "",
"view_surface_ids": [],
},
runtime_support="native",
),
*(
_bpmn_node(
type_id=f"bpmn.{type_name}",
category="bpmn_activity",
label=label,
description=description,
icon=icon,
shape="activity",
config_fields=fields,
default_config=defaults,
)
for type_name, label, description, icon, fields, defaults in (
(
"scriptTask",
"Script task",
"A BPMN script task retained as notation; arbitrary scripts are not executed.",
"file-code-2",
(*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")),
{"title": "", "instructions": "", "script_format": "", "script": ""},
),
(
"businessRuleTask",
"Business rule task",
"Evaluate a governed business-rule implementation.",
"scale",
(*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")),
{"title": "", "instructions": "", "implementation_ref": ""},
),
(
"callActivity",
"Call activity",
"Call another reusable BPMN process or GovOPlaN workflow.",
"external-link",
(*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)),
{"title": "", "instructions": "", "called_element": ""},
),
(
"subProcess",
"Sub-process",
"Contain a nested BPMN process.",
"box-select",
_TASK_FIELDS,
{"title": "", "instructions": ""},
),
(
"transaction",
"Transaction",
"Contain transaction-scoped BPMN activity semantics.",
"badge-dollar-sign",
_TASK_FIELDS,
{"title": "", "instructions": ""},
),
(
"adHocSubProcess",
"Ad-hoc sub-process",
"Contain activities whose order is governed at runtime.",
"shuffle",
_TASK_FIELDS,
{"title": "", "instructions": ""},
),
)
),
*(
_bpmn_node(
type_id=f"bpmn.{type_name}",
category="bpmn_gateway",
label=label,
description=description,
icon=icon,
shape="gateway",
config_fields=(),
default_config={},
runtime_support=runtime_support,
)
for type_name, label, description, icon, runtime_support in (
("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"),
("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"),
("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"),
("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"),
("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"),
)
),
_bpmn_node(
type_id="bpmn.dataObjectReference",
category="bpmn_data",
label="Data object",
description="Reference data produced or consumed by an activity.",
icon="file",
shape="data-object",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"),
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"),
),
default_config={"data_object_ref": "", "item_subject_ref": ""},
),
_bpmn_node(
type_id="bpmn.dataStoreReference",
category="bpmn_data",
label="Data store",
description="Reference persistent data used by the process.",
icon="database",
shape="data-store",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"),
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"),
),
default_config={"data_store_ref": "", "item_subject_ref": ""},
),
_bpmn_node(
type_id="bpmn.participant",
category="bpmn_collaboration",
label="Participant / pool",
description="Represent a BPMN collaboration participant and its process.",
icon="rectangle-horizontal",
shape="participant",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="process_ref", label="Process reference", kind="text"),
),
default_config={"process_ref": ""},
),
_bpmn_node(
type_id="bpmn.lane",
category="bpmn_collaboration",
label="Lane",
description="Group flow nodes by role or responsibility.",
icon="rows-3",
shape="lane",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"),
),
default_config={"flow_node_refs": []},
),
_bpmn_node(
type_id="bpmn.textAnnotation",
category="bpmn_artifact",
label="Text annotation",
description="Attach explanatory text without changing process execution.",
icon="text-quote",
shape="text-annotation",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="text", label="Text", kind="textarea"),
DefinitionConfigField(id="text_format", label="Text format", kind="text"),
),
default_config={"text": "", "text_format": "text/plain"},
),
_bpmn_node(
type_id="bpmn.group",
category="bpmn_artifact",
label="Group",
description="Visually group BPMN elements without changing execution.",
icon="box-select",
shape="group",
input_ports=_OPTIONAL_INCOMING,
config_fields=(
DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"),
),
default_config={"category_value_ref": ""},
),
*(
_bpmn_node(
type_id=f"bpmn.{type_name}",
category="bpmn_collaboration",
label=label,
description=description,
icon=icon,
shape=shape,
input_ports=ports,
config_fields=(
DefinitionConfigField(
id="participant_refs",
label="Participants",
kind="string_list",
),
DefinitionConfigField(
id="called_element",
label="Called element",
kind="text",
),
),
default_config={"participant_refs": [], "called_element": ""},
)
for type_name, label, description, icon, shape, ports in (
(
"choreographyTask",
"Choreography task",
"Model a message exchange between two or more participants.",
"messages-square",
"choreography",
_INCOMING,
),
(
"callChoreography",
"Call choreography",
"Call a reusable choreography.",
"external-link",
"choreography",
_INCOMING,
),
(
"subChoreography",
"Sub-choreography",
"Contain a nested choreography.",
"box-select",
"choreography",
_INCOMING,
),
(
"conversation",
"Conversation",
"Group logically related message exchanges.",
"messages-square",
"conversation",
_OPTIONAL_INCOMING,
),
(
"callConversation",
"Call conversation",
"Call a reusable global conversation.",
"external-link",
"conversation",
_OPTIONAL_INCOMING,
),
(
"subConversation",
"Sub-conversation",
"Contain a nested conversation.",
"box-select",
"conversation",
_OPTIONAL_INCOMING,
),
)
),
)
WORKFLOW_NODE_TYPES = (*BPMN_NODE_TYPES, *LEGACY_WORKFLOW_NODE_TYPES)
WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary(
id="workflow",
version="0.1.0",
version="1.0.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",),
),
),
require_connected=False,
),
)
@@ -445,7 +1141,9 @@ WORKFLOW_NODE_TYPES_BY_ID = {
__all__ = [
"BPMN_NODE_TYPES",
"CATEGORY_LABELS",
"LEGACY_WORKFLOW_NODE_TYPES",
"WORKFLOW_GRAPH_LIBRARY",
"WORKFLOW_NODE_TYPES",
"WORKFLOW_NODE_TYPES_BY_ID",
+303 -44
View File
@@ -28,19 +28,41 @@ from govoplan_workflow.backend.manifest import (
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnDiagnostic,
BpmnInspectionError,
NATIVE_EXECUTION_ELEMENTS,
NATIVE_MAPPING_ELEMENTS,
inspect_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.backend.bpmn_graph import (
BpmnGraphError,
NATIVE_BPMN_ADAPTER_ID,
NATIVE_BPMN_ADAPTER_VERSION,
canonical_bpmn_graph,
export_bpmn_graph,
)
from govoplan_workflow.backend.schemas import (
BpmnAdapterProfileResponse,
BpmnCompileRequest,
BpmnCompileResponse,
BpmnDiagnosticResponse,
BpmnElementSupportResponse,
BpmnInspectionRequest,
BpmnInspectionResponse,
BpmnRevisionDocumentResponse,
BpmnRenderRequest,
BpmnRenderResponse,
BpmnSupportProfileResponse,
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
@@ -74,6 +96,7 @@ from govoplan_workflow.backend.instance_service import (
)
from govoplan_workflow.backend.runtime import get_registry
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
@@ -89,6 +112,7 @@ from govoplan_workflow.backend.service import (
list_definition_revisions,
list_definitions,
revision_response,
revision_bpmn_summary,
update_definition,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -111,6 +135,22 @@ def _http_error(exc: WorkflowError) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, WorkflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, WorkflowBpmnValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in exc.diagnostics
],
},
)
if isinstance(exc, WorkflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
@@ -216,52 +256,51 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None:
)
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
def api_bpmn_support_profile(
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnSupportProfileResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return BpmnSupportProfileResponse(
specification="BPMN 2.0.2",
model_namespace=BPMN_MODEL_NAMESPACE,
interchange=(
"Secure XML inventory and diagnostics are available. Visual "
"round-trip modeling requires the planned bpmn-js adapter."
),
native_runtime=(
"Only the explicitly listed executable subset maps to current "
"native runtime semantics; all other elements are interchange-only."
),
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
native_mapping_elements=sorted(
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
),
def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse:
return BpmnAdapterProfileResponse(
id=profile.id,
version=profile.version,
label=profile.label,
description=profile.description,
conformance=profile.conformance,
runtime_kind=profile.runtime_kind,
executable=profile.executable,
supported_elements=list(profile.supported_elements),
supported_event_definitions=list(profile.supported_event_definitions),
requirements=list(profile.requirements),
)
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
def api_inspect_bpmn(
payload: BpmnInspectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
def _bpmn_inspection_response(
xml: str,
*,
adapter_id: str,
adapter_version: str | None = None,
activation: bool,
) -> BpmnInspectionResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
result = inspect_bpmn_xml(xml)
adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version)
adapter_diagnostics = (
adapter.diagnostics(xml, result, activation=activation)
if adapter is not None
else ()
)
try:
result = inspect_bpmn_xml(payload.xml)
except BpmnInspectionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
diagnostics = list(result.diagnostics)
diagnostics.extend(adapter_diagnostics)
if adapter is None:
suffix = f"@{adapter_version}" if adapter_version else ""
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.unavailable",
message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.",
)
)
deduplicated = {
(item.code, item.element_id, item.message): item
for item in diagnostics
}.values()
diagnostic_items = list(deduplicated)
return BpmnInspectionResponse(
valid_xml=result.valid_xml,
definitions_id=result.definitions_id,
@@ -290,11 +329,167 @@ def api_inspect_bpmn(
message=item.message,
element_id=item.element_id,
)
for item in result.diagnostics
for item in diagnostic_items
],
adapter_id=adapter.profile.id if adapter else adapter_id,
adapter_version=(
adapter.profile.version if adapter else adapter_version
),
runtime_kind=adapter.profile.runtime_kind if adapter else None,
executable=bool(adapter and adapter.profile.executable),
activatable=bool(
activation
and adapter
and adapter.profile.executable
and not any(item.severity == "error" for item in diagnostic_items)
),
)
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
def api_bpmn_support_profile(
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnSupportProfileResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return BpmnSupportProfileResponse(
specification="BPMN 2.0.2",
model_namespace=BPMN_MODEL_NAMESPACE,
interchange=(
"BPMN 2.0 is the native Workflow graph language. XML import and "
"export preserve standard notation and diagram geometry without "
"a browser-side BPMN modeler."
),
native_runtime=(
"Activation is fail-closed and requires a pinned adapter whose "
"declared conformance profile covers the complete document."
),
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
native_mapping_elements=sorted(
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
),
adapters=[
_adapter_profile_response(profile)
for profile in bpmn_adapter_registry().profiles()
],
)
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
def api_inspect_bpmn(
payload: BpmnInspectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnInspectionResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
return _bpmn_inspection_response(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
activation=payload.activation,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/bpmn/compile", response_model=BpmnCompileResponse)
def api_compile_bpmn(
payload: BpmnCompileRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnCompileResponse:
_require_any_scope(
principal,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
adapter, _inspection, graph = compile_bpmn_to_graph(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
)
if graph is None:
raise BpmnAdapterError(
(
BpmnDiagnostic(
severity="error",
code="adapter.no_runtime_materialization",
message="The selected adapter does not compile to a native graph.",
),
)
)
inspection_response = _bpmn_inspection_response(
payload.xml,
adapter_id=adapter.profile.id,
adapter_version=adapter.profile.version,
activation=False,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
diagnostics = getattr(exc, "diagnostics", ())
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in diagnostics
],
},
) from exc
return BpmnCompileResponse(
adapter=_adapter_profile_response(adapter.profile),
graph=graph,
inspection=inspection_response,
)
@router.post("/bpmn/render", response_model=BpmnRenderResponse)
def api_render_bpmn(
payload: BpmnRenderRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRenderResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
xml = export_bpmn_graph(
canonical_bpmn_graph(payload.graph),
name=payload.name,
)
inspection = _bpmn_inspection_response(
xml,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
adapter_version=NATIVE_BPMN_ADAPTER_VERSION,
activation=False,
)
except (BpmnGraphError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRenderResponse(xml=xml, inspection=inspection)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -349,8 +544,9 @@ def api_node_types(
for field in definition.config_fields
],
default_config=dict(definition.default_config),
metadata=dict(definition.metadata),
)
for definition in WORKFLOW_GRAPH_LIBRARY.node_types
for definition in BPMN_NODE_TYPES
],
)
@@ -515,6 +711,9 @@ def api_start_instance(
principal=principal,
registry=get_registry(),
payload=payload,
start_origin=(
"user" if principal.auth_method == "session" else "api"
),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
@@ -531,6 +730,7 @@ def api_start_instance(
"definition_id": instance.definition_id,
"definition_revision_id": instance.definition_revision_id,
"idempotency_key": instance.idempotency_key,
"start_origin": instance.start_origin,
},
)
response = instance_response(session, instance, replayed=replayed)
@@ -989,6 +1189,65 @@ def api_get_definition_revision(
return revision_response(item)
@router.get(
"/definitions/{definition_id}/revisions/{revision}/bpmn",
response_model=BpmnRevisionDocumentResponse,
)
def api_get_definition_revision_bpmn(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRevisionDocumentResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
summary = revision_bpmn_summary(item)
if summary is None or not item.bpmn_xml:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="This Workflow revision has no BPMN document.",
)
try:
inspection = _bpmn_inspection_response(
item.bpmn_xml,
adapter_id=summary.adapter_id,
adapter_version=summary.adapter_version,
activation=True,
)
except BpmnInspectionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRevisionDocumentResponse(
**summary.model_dump(),
definition_id=definition.id,
revision=item.revision,
xml=item.bpmn_xml,
inspection=inspection,
)
@router.post(
"/definitions/{definition_id}/activate",
response_model=WorkflowDefinitionResponse,
+182 -1
View File
@@ -4,12 +4,25 @@ import math
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
BpmnRuntimeKind = Literal["model_only", "native_graph", "external"]
WorkflowExecutionMode = Literal["guided", "automated", "hybrid"]
WorkflowStartOrigin = Literal[
"user",
"api",
"schedule",
"event",
"parent_workflow",
"dependency",
"retry",
"replay",
"backfill",
]
BpmnSupportLevel = Literal[
"interchange_only",
"native_mapping",
@@ -29,26 +42,58 @@ class WorkflowPosition(BaseModel):
return value
class WorkflowSize(BaseModel):
width: float = Field(default=100, gt=0, le=10_000)
height: float = Field(default=80, gt=0, le=10_000)
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)
size: WorkflowSize | None = None
parent_id: str | None = Field(default=None, max_length=120)
process_id: str | None = Field(default=None, max_length=120)
config: dict[str, Any] = Field(default_factory=dict)
class WorkflowWaypoint(BaseModel):
x: float
y: float
@field_validator("x", "y")
@classmethod
def finite_coordinate(cls, value: float) -> float:
if not math.isfinite(value):
raise ValueError("Edge coordinates must be finite.")
return value
class WorkflowEdge(BaseModel):
id: str = Field(min_length=1, max_length=120)
type: Literal[
"bpmn.sequenceFlow",
"bpmn.messageFlow",
"bpmn.association",
"bpmn.dataInputAssociation",
"bpmn.dataOutputAssociation",
"bpmn.conversationLink",
] = "bpmn.sequenceFlow"
label: str = Field(default="", max_length=300)
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)
config: dict[str, Any] = Field(default_factory=dict)
waypoints: list[WorkflowWaypoint] = Field(default_factory=list, max_length=500)
class WorkflowGraph(BaseModel):
schema_version: Literal[1] = 1
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
metadata: dict[str, Any] = Field(default_factory=dict)
class WorkflowGraphValidationRequest(BaseModel):
@@ -96,6 +141,7 @@ class WorkflowNodeTypeResponse(BaseModel):
output_ports: list[WorkflowPortResponse]
config_fields: list[WorkflowConfigFieldResponse]
default_config: dict[str, Any]
metadata: dict[str, Any] = Field(default_factory=dict)
class WorkflowNodeLibraryResponse(BaseModel):
@@ -107,6 +153,13 @@ class WorkflowNodeLibraryResponse(BaseModel):
class BpmnInspectionRequest(BaseModel):
xml: str = Field(min_length=1, max_length=1_048_576)
adapter_id: str = Field(
default="govoplan.native.bpmn",
min_length=1,
max_length=120,
)
adapter_version: str | None = Field(default=None, max_length=40)
activation: bool = False
class BpmnElementSupportResponse(BaseModel):
@@ -137,6 +190,24 @@ class BpmnInspectionResponse(BaseModel):
support_counts: dict[str, int]
elements: list[BpmnElementSupportResponse]
diagnostics: list[BpmnDiagnosticResponse]
adapter_id: str | None = None
adapter_version: str | None = None
runtime_kind: BpmnRuntimeKind | None = None
executable: bool = False
activatable: bool = False
class BpmnAdapterProfileResponse(BaseModel):
id: str
version: str
label: str
description: str
conformance: str
runtime_kind: BpmnRuntimeKind
executable: bool
supported_elements: list[str]
supported_event_definitions: list[str]
requirements: list[str]
class BpmnSupportProfileResponse(BaseModel):
@@ -146,6 +217,60 @@ class BpmnSupportProfileResponse(BaseModel):
native_runtime: str
native_execution_elements: list[str]
native_mapping_elements: list[str]
adapters: list[BpmnAdapterProfileResponse] = Field(default_factory=list)
class BpmnRevisionInput(BaseModel):
xml: str = Field(min_length=1, max_length=1_048_576)
adapter_id: str = Field(
default="govoplan.native.bpmn",
min_length=1,
max_length=120,
)
adapter_version: str | None = Field(default=None, max_length=40)
class BpmnRevisionSummaryResponse(BaseModel):
format: Literal["bpmn-2.0"] = "bpmn-2.0"
content_hash: str
adapter_id: str
adapter_version: str
runtime_kind: BpmnRuntimeKind
executable: bool
adapter_available: bool
class BpmnRevisionDocumentResponse(BpmnRevisionSummaryResponse):
definition_id: str
revision: int
xml: str
inspection: BpmnInspectionResponse
class BpmnCompileRequest(BaseModel):
xml: str = Field(min_length=1, max_length=1_048_576)
adapter_id: str = Field(
default="govoplan.native.bpmn",
min_length=1,
max_length=120,
)
adapter_version: str | None = Field(default=None, max_length=40)
class BpmnCompileResponse(BaseModel):
adapter: BpmnAdapterProfileResponse
graph: WorkflowGraph
inspection: BpmnInspectionResponse
class BpmnRenderRequest(BaseModel):
graph: WorkflowGraph
name: str = Field(default="", max_length=300)
class BpmnRenderResponse(BaseModel):
xml: str
inspection: BpmnInspectionResponse
class WorkflowDefinitionRevisionResponse(BaseModel):
@@ -156,6 +281,10 @@ class WorkflowDefinitionRevisionResponse(BaseModel):
content_hash: str
library_id: str
library_version: str
execution_mode: WorkflowExecutionMode
view_id: str | None = None
view_revision_id: str | None = None
bpmn: BpmnRevisionSummaryResponse | None = None
created_by: str | None
created_at: datetime
@@ -221,6 +350,7 @@ class WorkflowDefinitionCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
bpmn: BpmnRevisionInput | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
scope_type: DefinitionScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
@@ -229,12 +359,26 @@ class WorkflowDefinitionCreateRequest(BaseModel):
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
execution_mode: WorkflowExecutionMode = "hybrid"
view_id: str | None = Field(default=None, min_length=1, max_length=36)
view_revision_id: str | None = Field(
default=None,
min_length=1,
max_length=36,
)
@model_validator(mode="after")
def validate_view_pin(self):
if self.view_revision_id and not self.view_id:
raise ValueError("A pinned View revision requires a View")
return self
class WorkflowDefinitionUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
bpmn: BpmnRevisionInput | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
expected_revision: int = Field(ge=1)
scope_type: DefinitionScopeType = "tenant"
@@ -244,6 +388,19 @@ class WorkflowDefinitionUpdateRequest(BaseModel):
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
execution_mode: WorkflowExecutionMode = "hybrid"
view_id: str | None = Field(default=None, min_length=1, max_length=36)
view_revision_id: str | None = Field(
default=None,
min_length=1,
max_length=36,
)
@model_validator(mode="after")
def validate_view_pin(self):
if self.view_revision_id and not self.view_id:
raise ValueError("A pinned View revision requires a View")
return self
class WorkflowDefinitionDeriveRequest(BaseModel):
@@ -264,6 +421,19 @@ class WorkflowDefinitionDeriveRequest(BaseModel):
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
execution_mode: WorkflowExecutionMode | None = None
view_id: str | None = Field(default=None, min_length=1, max_length=36)
view_revision_id: str | None = Field(
default=None,
min_length=1,
max_length=36,
)
@model_validator(mode="after")
def validate_view_pin(self):
if self.view_revision_id and not self.view_id:
raise ValueError("A pinned View revision requires a View")
return self
class WorkflowDefinitionActivateRequest(BaseModel):
@@ -332,6 +502,14 @@ class WorkflowInstanceStepResponse(BaseModel):
updated_at: datetime
class WorkflowViewContextResponse(BaseModel):
view_id: str
revision_id: str | None = None
visible_surface_ids: list[str] = Field(default_factory=list)
step_id: str | None = None
node_id: str | None = None
class WorkflowInstanceEventResponse(BaseModel):
id: str
sequence: int
@@ -348,6 +526,9 @@ class WorkflowInstanceResponse(BaseModel):
definition_name: str
definition_revision: int
definition_hash: str
execution_mode: WorkflowExecutionMode
start_origin: WorkflowStartOrigin
view_context: WorkflowViewContextResponse | None = None
status: WorkflowInstanceStatus
idempotency_key: str
correlation_id: str | None
+326 -14
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import hashlib
import json
import re
@@ -16,11 +17,31 @@ from govoplan_workflow.backend.db.models import (
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.bpmn import (
BpmnDiagnostic,
BpmnInspectionError,
)
from govoplan_workflow.backend.bpmn_adapters import (
RuntimeKind,
bpmn_adapter_registry,
)
from govoplan_workflow.backend.bpmn_graph import (
BpmnGraphError,
NATIVE_BPMN_ADAPTER_ID,
NATIVE_BPMN_ADAPTER_VERSION,
canonical_bpmn_graph,
export_bpmn_graph,
import_bpmn_graph,
materialize_runtime_graph,
runtime_diagnostics,
)
from govoplan_workflow.backend.governance import (
definition_governance_payload,
require_definition_action,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
BpmnRevisionSummaryResponse,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionResponse,
@@ -52,6 +73,28 @@ class WorkflowValidationError(WorkflowError):
self.diagnostics = diagnostics
class WorkflowBpmnValidationError(WorkflowError):
def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None:
first = next(
(item for item in diagnostics if item.severity == "error"),
diagnostics[0] if diagnostics else None,
)
super().__init__(
first.message if first is not None else "BPMN validation failed."
)
self.diagnostics = diagnostics
@dataclass(frozen=True, slots=True)
class _BpmnArtifact:
xml: str
content_hash: str
adapter_id: str
adapter_version: str
runtime_kind: RuntimeKind
executable: bool
def list_definitions(
session: Session,
*,
@@ -139,7 +182,10 @@ def create_definition(
actor_id: str | None,
payload: WorkflowDefinitionCreateRequest,
) -> WorkflowDefinition:
graph = _validated_graph(payload.graph)
graph, bpmn = _prepare_revision_content(
graph=payload.graph,
bpmn=payload.bpmn,
)
stored_tenant_id = (
None if payload.scope_type == "system" else tenant_id
)
@@ -185,6 +231,10 @@ def create_definition(
tenant_id=stored_tenant_id,
revision=1,
graph=graph,
bpmn=bpmn,
execution_mode=payload.execution_mode,
view_id=payload.view_id,
view_revision_id=payload.view_revision_id,
actor_id=actor_id,
)
)
@@ -227,9 +277,11 @@ def update_definition(
raise WorkflowConflictError(
"Definition kind is immutable; derive a flow or template instead."
)
graph = _validated_graph(payload.graph)
current = get_definition_revision(session, definition=definition)
graph_hash = _content_hash(graph)
graph, bpmn = _prepare_revision_content(
graph=payload.graph,
bpmn=payload.bpmn,
)
definition.name = payload.name.strip()
definition.description = _clean_optional(payload.description)
definition.metadata_ = dict(payload.metadata)
@@ -250,13 +302,24 @@ def update_definition(
payload.allow_automation and ancestor_limits["allow_automation"]
)
definition.updated_by = actor_id
if current.content_hash != graph_hash:
if not _revision_matches(
current,
graph=graph,
bpmn=bpmn,
execution_mode=payload.execution_mode,
view_id=payload.view_id,
view_revision_id=payload.view_revision_id,
):
definition.current_revision += 1
definition.revisions.append(
_new_revision(
tenant_id=definition.tenant_id,
revision=definition.current_revision,
graph=graph,
bpmn=bpmn,
execution_mode=payload.execution_mode,
view_id=payload.view_id,
view_revision_id=payload.view_revision_id,
actor_id=actor_id,
)
)
@@ -341,6 +404,30 @@ def derive_definition(
"derived_by": actor_id,
"derived_at": utcnow().isoformat(),
}
execution_mode = (
payload.execution_mode
if "execution_mode" in payload.model_fields_set
else source_revision.execution_mode
)
view_id = (
payload.view_id
if "view_id" in payload.model_fields_set
else source_revision.view_id
)
view_revision_id = (
payload.view_revision_id
if (
"view_revision_id" in payload.model_fields_set
or "view_id" in payload.model_fields_set
)
else source_revision.view_revision_id
)
if view_id is None:
view_revision_id = None
source_graph, source_bpmn = _prepare_revision_content(
graph=WorkflowGraph.model_validate(source_revision.graph),
bpmn=None,
)
definition = WorkflowDefinition(
tenant_id=stored_tenant_id,
scope_type=payload.scope_type,
@@ -375,10 +462,25 @@ def derive_definition(
tenant_id=stored_tenant_id,
revision=1,
schema_version=source_revision.schema_version,
graph=dict(source_revision.graph),
content_hash=source_revision.content_hash,
graph=_canonical_graph(source_graph),
content_hash=_content_hash(
source_graph,
bpmn=source_bpmn,
execution_mode=execution_mode,
view_id=view_id,
view_revision_id=view_revision_id,
),
library_id=source_revision.library_id,
library_version=source_revision.library_version,
execution_mode=execution_mode,
view_id=view_id,
view_revision_id=view_revision_id,
bpmn_xml=source_bpmn.xml,
bpmn_hash=source_bpmn.content_hash,
bpmn_adapter_id=source_bpmn.adapter_id,
bpmn_adapter_version=source_bpmn.adapter_version,
bpmn_runtime_kind=source_bpmn.runtime_kind,
bpmn_executable=source_bpmn.executable,
created_by=actor_id,
)
)
@@ -410,6 +512,8 @@ def activate_definition(
"Workflow templates cannot be activated or started."
)
_validated_graph(WorkflowGraph.model_validate(selected.graph))
_validate_bpmn_activation(selected)
_validate_execution_mode_activation(selected)
definition.active_revision = selected.revision
definition.status = "active"
definition.updated_by = actor_id
@@ -496,10 +600,16 @@ def revision_response(
id=revision.id,
revision=revision.revision,
schema_version=revision.schema_version,
graph=WorkflowGraph.model_validate(revision.graph),
graph=canonical_bpmn_graph(
WorkflowGraph.model_validate(revision.graph)
),
content_hash=revision.content_hash,
library_id=revision.library_id,
library_version=revision.library_version,
execution_mode=revision.execution_mode,
view_id=revision.view_id,
view_revision_id=revision.view_revision_id,
bpmn=revision_bpmn_summary(revision),
created_by=revision.created_by,
created_at=revision.created_at,
)
@@ -510,6 +620,10 @@ def _new_revision(
tenant_id: str | None,
revision: int,
graph: WorkflowGraph,
bpmn: _BpmnArtifact | None,
execution_mode: str,
view_id: str | None,
view_revision_id: str | None,
actor_id: str | None,
) -> WorkflowDefinitionRevision:
return WorkflowDefinitionRevision(
@@ -517,14 +631,132 @@ def _new_revision(
revision=revision,
schema_version=graph.schema_version,
graph=_canonical_graph(graph),
content_hash=_content_hash(graph),
content_hash=_content_hash(
graph,
bpmn=bpmn,
execution_mode=execution_mode,
view_id=view_id,
view_revision_id=view_revision_id,
),
library_id=WORKFLOW_GRAPH_LIBRARY.id,
library_version=WORKFLOW_GRAPH_LIBRARY.version,
execution_mode=execution_mode,
view_id=view_id,
view_revision_id=view_revision_id,
bpmn_xml=bpmn.xml if bpmn else None,
bpmn_hash=bpmn.content_hash if bpmn else None,
bpmn_adapter_id=bpmn.adapter_id if bpmn else None,
bpmn_adapter_version=bpmn.adapter_version if bpmn else None,
bpmn_runtime_kind=bpmn.runtime_kind if bpmn else None,
bpmn_executable=bpmn.executable if bpmn else None,
created_by=actor_id,
)
def revision_bpmn_summary(
revision: WorkflowDefinitionRevision,
) -> BpmnRevisionSummaryResponse | None:
if not revision.bpmn_xml:
return None
adapter_id = revision.bpmn_adapter_id or "bpmn.interchange"
adapter_version = revision.bpmn_adapter_version or "1.0.0"
adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version)
runtime_kind: RuntimeKind = "model_only"
if revision.bpmn_runtime_kind in {"model_only", "native_graph", "external"}:
runtime_kind = revision.bpmn_runtime_kind
return BpmnRevisionSummaryResponse(
content_hash=revision.bpmn_hash
or hashlib.sha256(revision.bpmn_xml.encode("utf-8")).hexdigest(),
adapter_id=adapter_id,
adapter_version=adapter_version,
runtime_kind=runtime_kind,
executable=bool(revision.bpmn_executable),
adapter_available=adapter is not None,
)
def _prepare_revision_content(
*,
graph: WorkflowGraph,
bpmn: BpmnRevisionInput | None,
) -> tuple[WorkflowGraph, _BpmnArtifact | None]:
try:
effective_graph = (
import_bpmn_graph(bpmn.xml)
if bpmn is not None
else canonical_bpmn_graph(graph)
)
except WorkflowBpmnValidationError:
raise
except (BpmnGraphError, BpmnInspectionError) as exc:
diagnostics = getattr(exc, "diagnostics", None)
raise WorkflowBpmnValidationError(
tuple(diagnostics)
if diagnostics
else (
BpmnDiagnostic(
severity="error",
code="bpmn.invalid",
message=str(exc),
),
)
) from exc
effective_graph = _validated_graph(effective_graph)
xml = export_bpmn_graph(effective_graph)
executable = not any(
item.severity == "error"
for item in runtime_diagnostics(effective_graph)
)
if executable:
try:
runtime_graph = materialize_runtime_graph(effective_graph)
except BpmnGraphError:
executable = False
else:
executable = not any(
item.severity == "error"
for item in validate_workflow_graph(runtime_graph)
)
return effective_graph, _BpmnArtifact(
xml=xml,
content_hash=hashlib.sha256(xml.encode("utf-8")).hexdigest(),
adapter_id=NATIVE_BPMN_ADAPTER_ID,
adapter_version=NATIVE_BPMN_ADAPTER_VERSION,
runtime_kind="native_graph",
executable=executable,
)
def _validate_bpmn_activation(
revision: WorkflowDefinitionRevision,
) -> None:
try:
runtime_graph = materialize_runtime_graph(
WorkflowGraph.model_validate(revision.graph)
)
except BpmnGraphError as exc:
diagnostics = getattr(exc, "diagnostics", None)
raise WorkflowBpmnValidationError(
tuple(diagnostics)
if diagnostics
else (
BpmnDiagnostic(
severity="error",
code="bpmn.activation_invalid",
message=str(exc),
),
)
) from exc
runtime_validation = validate_workflow_graph(runtime_graph)
if any(item.severity == "error" for item in runtime_validation):
raise WorkflowValidationError(runtime_validation)
def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph:
try:
graph = canonical_bpmn_graph(graph)
except BpmnGraphError as exc:
raise WorkflowBpmnValidationError(exc.diagnostics) from exc
diagnostics = validate_workflow_graph(graph)
if any(item.severity == "error" for item in diagnostics):
raise WorkflowValidationError(diagnostics)
@@ -535,15 +767,93 @@ def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]:
return graph.model_dump(mode="json")
def _content_hash(graph: WorkflowGraph) -> str:
encoded = json.dumps(
_canonical_graph(graph),
sort_keys=True,
separators=(",", ":"),
)
def _content_hash(
graph: WorkflowGraph,
*,
bpmn: _BpmnArtifact | None = None,
execution_mode: str = "hybrid",
view_id: str | None = None,
view_revision_id: str | None = None,
) -> str:
content: dict[str, object] = {
"graph": _canonical_graph(graph),
"execution": {
"mode": execution_mode,
"view_id": view_id,
"view_revision_id": view_revision_id,
},
}
if bpmn is not None:
content["bpmn"] = {
"xml": bpmn.xml,
"adapter_id": bpmn.adapter_id,
"adapter_version": bpmn.adapter_version,
}
encoded = json.dumps(content, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _revision_matches(
revision: WorkflowDefinitionRevision,
*,
graph: WorkflowGraph,
bpmn: _BpmnArtifact | None,
execution_mode: str,
view_id: str | None,
view_revision_id: str | None,
) -> bool:
return (
revision.graph == _canonical_graph(graph)
and revision.execution_mode == execution_mode
and revision.view_id == view_id
and revision.view_revision_id == view_revision_id
and revision.bpmn_xml == (bpmn.xml if bpmn else None)
and revision.bpmn_adapter_id
== (bpmn.adapter_id if bpmn else None)
and revision.bpmn_adapter_version
== (bpmn.adapter_version if bpmn else None)
)
def _validate_execution_mode_activation(
revision: WorkflowDefinitionRevision,
) -> None:
if revision.execution_mode != "automated":
return
try:
graph = materialize_runtime_graph(
WorkflowGraph.model_validate(revision.graph)
)
except BpmnGraphError as exc:
raise WorkflowBpmnValidationError(exc.diagnostics) from exc
human_nodes = [
node.id
for node in graph.nodes
if node.type in {"workflow.activity", "workflow.review"}
or (
node.type == "workflow.wait"
and str(node.config.get("mode") or "manual") == "manual"
)
or (
node.type == "workflow.capability"
and str(node.config.get("failure_policy") or "manual") == "manual"
)
or (
node.type == "workflow.dataflow"
and (
str(node.config.get("warning_policy") or "review") == "review"
or str(node.config.get("failure_policy") or "manual")
== "manual"
)
)
]
if human_nodes:
raise WorkflowConflictError(
"Automated workflows cannot activate with human handoff paths: "
+ ", ".join(sorted(human_nodes))
)
def _available_key(
session: Session,
*,
@@ -639,6 +949,7 @@ def _effective_governance_limits(
__all__ = [
"WorkflowBpmnValidationError",
"WorkflowConflictError",
"WorkflowError",
"WorkflowNotFoundError",
@@ -654,5 +965,6 @@ __all__ = [
"list_definition_revisions",
"list_definitions",
"revision_response",
"revision_bpmn_summary",
"update_definition",
]
+182 -2
View File
@@ -32,13 +32,15 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
)
outgoing = {node.id: 0 for node in graph.nodes}
for edge in graph.edges:
if edge.source in outgoing:
if edge.type == "bpmn.sequenceFlow" and 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 node.type.startswith("bpmn."):
continue
if field.required and _empty(node.config.get(field.id)):
diagnostics.append(
DefinitionDiagnostic(
@@ -49,7 +51,7 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
field=field.id,
)
)
if definition.output_ports and outgoing[node.id] == 0:
if _requires_outgoing(node.type) and outgoing[node.id] == 0:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
@@ -58,9 +60,187 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
node_id=node.id,
)
)
diagnostics.extend(_bpmn_semantic_diagnostics(graph))
diagnostics.extend(_legacy_semantic_diagnostics(graph))
return _deduplicate(diagnostics)
def _requires_outgoing(node_type: str) -> bool:
if not node_type.startswith("bpmn."):
return bool(
WORKFLOW_NODE_TYPES_BY_ID.get(node_type)
and WORKFLOW_NODE_TYPES_BY_ID[node_type].output_ports
)
return False
def _bpmn_semantic_diagnostics(
graph: WorkflowGraph,
) -> list[DefinitionDiagnostic]:
if not graph.nodes or not any(
node.type.startswith("bpmn.") for node in graph.nodes
):
return []
diagnostics: list[DefinitionDiagnostic] = []
if any(not node.type.startswith("bpmn.") for node in graph.nodes):
diagnostics.append(
DefinitionDiagnostic(
severity="warning",
code="graph.mixed_notation",
message=(
"A Workflow revision cannot mix canonical BPMN and "
"legacy Workflow nodes."
),
)
)
return diagnostics
flow_nodes = {
node.id
for node in graph.nodes
if node.type
not in {
"bpmn.participant",
"bpmn.lane",
"bpmn.dataObjectReference",
"bpmn.dataStoreReference",
"bpmn.textAnnotation",
"bpmn.group",
"bpmn.conversation",
"bpmn.callConversation",
"bpmn.subConversation",
}
}
starts = [
node for node in graph.nodes if node.type == "bpmn.startEvent"
]
ends = [node for node in graph.nodes if node.type == "bpmn.endEvent"]
if not starts:
diagnostics.append(
DefinitionDiagnostic(
severity="warning",
code="bpmn.start_event_missing",
message="A Workflow process needs at least one BPMN start event.",
)
)
if not ends:
diagnostics.append(
DefinitionDiagnostic(
severity="warning",
code="bpmn.end_event_missing",
message="A Workflow process needs at least one BPMN end event.",
)
)
node_by_id = {node.id: node for node in graph.nodes}
default_flows_by_source: dict[str, list[str]] = {}
for edge in graph.edges:
source = node_by_id.get(edge.source)
target = node_by_id.get(edge.target)
if source is None or target is None:
continue
if edge.type == "bpmn.sequenceFlow" and (
edge.source not in flow_nodes or edge.target not in flow_nodes
):
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="bpmn.sequence_flow_endpoint",
message=(
"BPMN sequence flows may only connect process flow "
"nodes. Use an association or data association here."
),
node_id=edge.source,
)
)
if edge.type == "bpmn.messageFlow":
same_process = (
source.process_id
and target.process_id
and source.process_id == target.process_id
)
if same_process:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="bpmn.message_flow_same_process",
message=(
"BPMN message flows connect different participants; "
"use a sequence flow inside one process."
),
node_id=edge.source,
)
)
if edge.config.get("default") is True:
if edge.type != "bpmn.sequenceFlow":
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="bpmn.default_flow_type",
message="Only a BPMN sequence flow can be a default flow.",
node_id=edge.source,
)
)
else:
default_flows_by_source.setdefault(edge.source, []).append(edge.id)
for source_id, edge_ids in default_flows_by_source.items():
if len(edge_ids) > 1:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="bpmn.multiple_default_flows",
message="A BPMN flow node can have at most one default flow.",
node_id=source_id,
)
)
for node in graph.nodes:
if node.type != "bpmn.boundaryEvent":
continue
attached_to = str(node.config.get("attached_to_ref") or "")
if attached_to not in node_by_id:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="bpmn.boundary_attachment",
message="A boundary event must reference an activity in this graph.",
node_id=node.id,
field="attached_to_ref",
)
)
return diagnostics
def _legacy_semantic_diagnostics(
graph: WorkflowGraph,
) -> list[DefinitionDiagnostic]:
if not graph.nodes or any(
node.type.startswith("bpmn.") for node in graph.nodes
):
return []
diagnostics: list[DefinitionDiagnostic] = []
starts = [
node for node in graph.nodes if node.type.startswith("workflow.start.")
]
outcomes = [
node for node in graph.nodes if node.type.startswith("workflow.end.")
]
if len(starts) != 1:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="graph.trigger_count",
message="A definition requires exactly one start node.",
)
)
if not outcomes:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="graph.outcome_count",
message="A definition requires at least one outcome node.",
)
)
return diagnostics
def _empty(value: object) -> bool:
return value is None or value == "" or value == [] or value == {}
+1
View File
@@ -0,0 +1 @@
"""Workflow test package for both discovery and targeted module execution."""
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Choreography"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Approval" name="Approval" />
<bpmn:collaboration id="Collaboration_Choreography">
<bpmn:participant id="Participant_Applicant" name="Applicant" />
<bpmn:participant id="Participant_Authority" name="Authority" />
</bpmn:collaboration>
<bpmn:choreography id="Choreography_1" name="Permit decision">
<bpmn:startEvent id="Choreography_Start" />
<bpmn:choreographyTask
id="Choreography_Task"
initiatingParticipantRef="Participant_Authority">
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
</bpmn:choreographyTask>
<bpmn:endEvent id="Choreography_End" />
<bpmn:sequenceFlow
id="Choreography_Flow_1"
sourceRef="Choreography_Start"
targetRef="Choreography_Task" />
<bpmn:sequenceFlow
id="Choreography_Flow_2"
sourceRef="Choreography_Task"
targetRef="Choreography_End" />
</bpmn:choreography>
<bpmn:messageFlow
id="MessageFlow_Approval"
sourceRef="Participant_Authority"
targetRef="Participant_Applicant"
messageRef="Message_Approval" />
</bpmn:definitions>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Collaboration"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Request" name="Request" />
<bpmn:process id="Process_Requester">
<bpmn:startEvent id="Requester_Start" />
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
</bpmn:process>
<bpmn:process id="Process_Reviewer">
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
<bpmn:endEvent id="Reviewer_End" />
</bpmn:process>
<bpmn:collaboration id="Collaboration_1">
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
<bpmn:messageFlow
id="MessageFlow_1"
sourceRef="Send_Request"
targetRef="Receive_Request"
messageRef="Message_Request" />
</bpmn:collaboration>
</bpmn:definitions>
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Control_Flow"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:signal id="Signal_Escalation" name="Escalation" />
<bpmn:process id="Called_Process" isExecutable="true">
<bpmn:startEvent id="Called_Start" />
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
<bpmn:endEvent id="Called_End" />
<bpmn:sequenceFlow
id="Called_Flow_1"
sourceRef="Called_Start"
targetRef="Called_Human_Task" />
<bpmn:sequenceFlow
id="Called_Flow_2"
sourceRef="Called_Human_Task"
targetRef="Called_End" />
</bpmn:process>
<bpmn:process id="Control_Process" isExecutable="true">
<bpmn:startEvent id="Control_Start" />
<bpmn:exclusiveGateway id="Control_Decision" />
<bpmn:subProcess id="Review_Subprocess" name="Review">
<bpmn:startEvent id="Subprocess_Start" />
<bpmn:userTask id="Subprocess_Review" name="Review request" />
<bpmn:endEvent id="Subprocess_End" />
<bpmn:sequenceFlow
id="Subprocess_Flow_1"
sourceRef="Subprocess_Start"
targetRef="Subprocess_Review" />
<bpmn:sequenceFlow
id="Subprocess_Flow_2"
sourceRef="Subprocess_Review"
targetRef="Subprocess_End" />
</bpmn:subProcess>
<bpmn:boundaryEvent
id="Review_Escalation"
attachedToRef="Review_Subprocess"
cancelActivity="false">
<bpmn:signalEventDefinition
id="Review_Escalation_Definition"
signalRef="Signal_Escalation" />
</bpmn:boundaryEvent>
<bpmn:parallelGateway id="Control_Join" />
<bpmn:callActivity
id="Call_Confirmation"
name="Confirm"
calledElement="Called_Process" />
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
<bpmn:signalEventDefinition
id="Escalation_Thrown_Definition"
signalRef="Signal_Escalation" />
</bpmn:intermediateThrowEvent>
<bpmn:task
id="Compensation_Handler"
name="Undo review"
isForCompensation="true" />
<bpmn:boundaryEvent
id="Review_Compensation"
attachedToRef="Review_Subprocess">
<bpmn:compensateEventDefinition
id="Review_Compensation_Definition"
activityRef="Compensation_Handler" />
</bpmn:boundaryEvent>
<bpmn:endEvent id="Control_End" />
<bpmn:sequenceFlow
id="Control_Flow_1"
sourceRef="Control_Start"
targetRef="Control_Decision" />
<bpmn:sequenceFlow
id="Control_Flow_2"
sourceRef="Control_Decision"
targetRef="Review_Subprocess" />
<bpmn:sequenceFlow
id="Control_Flow_3"
sourceRef="Review_Subprocess"
targetRef="Control_Join" />
<bpmn:sequenceFlow
id="Control_Flow_4"
sourceRef="Control_Join"
targetRef="Call_Confirmation" />
<bpmn:sequenceFlow
id="Control_Flow_5"
sourceRef="Call_Confirmation"
targetRef="Escalation_Thrown" />
<bpmn:sequenceFlow
id="Control_Flow_6"
sourceRef="Escalation_Thrown"
targetRef="Control_End" />
</bpmn:process>
</bpmn:definitions>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Data"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
<bpmn:process id="Process_Data">
<bpmn:dataObject id="DataObject_Request" name="Request" />
<bpmn:dataObjectReference
id="DataObjectReference_Request"
dataObjectRef="DataObject_Request" />
<bpmn:dataStoreReference
id="DataStoreReference_Archive"
dataStoreRef="DataStore_Archive" />
<bpmn:scriptTask id="Transform_Data" name="Transform data">
<bpmn:script>result = input</bpmn:script>
<bpmn:dataInputAssociation id="InputAssociation_1">
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
</bpmn:dataInputAssociation>
<bpmn:dataOutputAssociation id="OutputAssociation_1">
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
</bpmn:dataOutputAssociation>
</bpmn:scriptTask>
</bpmn:process>
</bpmn:definitions>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Events"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Continue" name="Continue" />
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
<bpmn:process id="Process_Transaction" isExecutable="true">
<bpmn:startEvent id="Start_Timer">
<bpmn:timerEventDefinition id="Timer_Start_Definition">
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
</bpmn:timerEventDefinition>
</bpmn:startEvent>
<bpmn:transaction id="Transaction_1">
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
<bpmn:boundaryEvent
id="Charge_Error"
attachedToRef="Charge_Account">
<bpmn:errorEventDefinition
id="Charge_Error_Definition"
errorRef="Error_Processing" />
</bpmn:boundaryEvent>
<bpmn:task
id="Undo_Charge"
name="Undo charge"
isForCompensation="true" />
<bpmn:association
id="Compensation_Association"
sourceRef="Charge_Error"
targetRef="Undo_Charge"
associationDirection="One" />
</bpmn:transaction>
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
<bpmn:messageEventDefinition
id="Wait_Message_Definition"
messageRef="Message_Continue" />
</bpmn:intermediateCatchEvent>
<bpmn:endEvent id="End_Transaction">
<bpmn:terminateEventDefinition id="Terminate_Definition" />
</bpmn:endEvent>
</bpmn:process>
</bpmn:definitions>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
id="Definitions_Process"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:process id="Process_Linear" isExecutable="true">
<bpmn:extensionElements>
<govoplan:fixture revision="1">
<govoplan:note>Preserve this extension exactly.</govoplan:note>
</govoplan:fixture>
</bpmn:extensionElements>
<bpmn:startEvent id="Start_1">
<bpmn:outgoing>Flow_1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="Task_1" name="Review">
<bpmn:incoming>Flow_1</bpmn:incoming>
<bpmn:outgoing>Flow_2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="End_1">
<bpmn:incoming>Flow_2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_1">
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
<dc:Bounds x="80" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
<dc:Bounds x="430" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
<di:waypoint x="116" y="130" />
<di:waypoint x="220" y="130" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
<di:waypoint x="320" y="130" />
<di:waypoint x="430" y="130" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
+271 -1
View File
@@ -1,10 +1,26 @@
from __future__ import annotations
from collections import Counter
import unittest
from pathlib import Path
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnInspectionError,
inspect_bpmn_xml,
parse_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.backend.bpmn_graph import (
NATIVE_BPMN_ADAPTER_ID,
export_bpmn_graph,
import_bpmn_graph,
)
@@ -28,8 +44,91 @@ BPMN = """<?xml version="1.0" encoding="UTF-8"?>
</bpmn:definitions>
"""
NATIVE_BPMN = """<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
id="Definitions_native"
targetNamespace="https://govoplan.example.test/workflow/native">
<bpmn:process id="Process_native" isExecutable="true">
<bpmn:startEvent id="Start_native" />
<bpmn:userTask id="Review_native" name="Review request" />
<bpmn:endEvent id="End_native" />
<bpmn:sequenceFlow id="Flow_start_review" sourceRef="Start_native" targetRef="Review_native" />
<bpmn:sequenceFlow id="Flow_review_end" sourceRef="Review_native" targetRef="End_native" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_native">
<bpmndi:BPMNPlane id="Plane_native" bpmnElement="Process_native">
<bpmndi:BPMNShape id="Shape_start" bpmnElement="Start_native">
<dc:Bounds x="40" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_review" bpmnElement="Review_native">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_end" bpmnElement="End_native">
<dc:Bounds x="460" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
"""
class BpmnInspectionTests(unittest.TestCase):
def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
results = {
path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8"))
for path in sorted(fixture_directory.glob("*.bpmn"))
}
self.assertEqual(
{
"choreography",
"collaboration",
"control-flow",
"data",
"events-transaction-compensation",
"process",
},
set(results),
)
self.assertEqual(1, results["choreography"].choreography_count)
self.assertEqual(1, results["collaboration"].collaboration_count)
self.assertEqual(
1,
results["events-transaction-compensation"].element_counts[
"transaction"
],
)
self.assertEqual(
1,
results["data"].element_counts["dataStoreReference"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["exclusiveGateway"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["subProcess"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["callActivity"],
)
self.assertEqual(
1,
results["control-flow"].element_counts[
"compensateEventDefinition"
],
)
self.assertEqual(
2,
results["control-flow"].element_counts["signalEventDefinition"],
)
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
result = inspect_bpmn_xml(BPMN)
@@ -47,7 +146,7 @@ class BpmnInspectionTests(unittest.TestCase):
if item.element_id == "Collaboration_1"
)
self.assertEqual("native_execution", review.support_level)
self.assertEqual("interchange_only", collaboration.support_level)
self.assertEqual("native_mapping", collaboration.support_level)
def test_dangling_references_are_reported(self) -> None:
result = inspect_bpmn_xml(
@@ -80,6 +179,177 @@ class BpmnInspectionTests(unittest.TestCase):
):
inspect_bpmn_xml("<definitions />")
def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None:
profiles = {
item.id: item for item in bpmn_adapter_registry().profiles()
}
self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable)
adapter, inspection, graph = compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
self.assertEqual("1.0.0", adapter.profile.version)
self.assertTrue(inspection.valid_xml)
self.assertIsNotNone(graph)
assert graph is not None
self.assertEqual(
[
"workflow.start.manual",
"workflow.activity",
"workflow.end.completed",
],
[item.type for item in graph.nodes],
)
self.assertEqual(220, graph.nodes[1].position.x)
self.assertEqual(
["Flow_start_review", "Flow_review_end"],
[item.id for item in graph.edges],
)
def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None:
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusiveGateway is not supported",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
def test_model_only_profile_remains_read_compatible(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
)
self.assertIsNone(graph)
def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None:
graph = import_bpmn_graph(BPMN)
self.assertEqual(
[
"bpmn.startEvent",
"bpmn.userTask",
"bpmn.exclusiveGateway",
"bpmn.endEvent",
"bpmn.participant",
],
[node.type for node in graph.nodes],
)
self.assertTrue(
all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges)
)
rendered = export_bpmn_graph(graph, name="Round trip")
imported = import_bpmn_graph(rendered)
self.assertEqual(
[(node.id, node.type) for node in graph.nodes],
[(node.id, node.type) for node in imported.nodes],
)
self.assertEqual(
[(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges],
[
(edge.id, edge.type, edge.source, edge.target)
for edge in imported.edges
],
)
def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
for path in sorted(fixture_directory.glob("*.bpmn")):
with self.subTest(path=path.name):
graph = import_bpmn_graph(path.read_text(encoding="utf-8"))
imported = import_bpmn_graph(
export_bpmn_graph(graph, name=path.stem)
)
self.assertEqual(
Counter(node.type for node in graph.nodes),
Counter(node.type for node in imported.nodes),
)
self.assertEqual(
Counter(edge.type for edge in graph.edges),
Counter(edge.type for edge in imported.edges),
)
self.assertEqual(len(graph.nodes), len(imported.nodes))
self.assertEqual(len(graph.edges), len(imported.edges))
def test_nested_flows_remain_in_their_bpmn_container(self) -> None:
fixture = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "events-transaction-compensation.bpmn"
)
rendered = export_bpmn_graph(
import_bpmn_graph(fixture.read_text(encoding="utf-8"))
)
root = parse_bpmn_xml(rendered)
transaction = next(
item
for item in root.iter()
if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction"
)
self.assertTrue(
any(
child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association"
and child.attrib.get("id") == "Compensation_Association"
for child in transaction
)
)
def test_default_flow_is_an_editable_edge_property(self) -> None:
source = BPMN.replace(
'<bpmn:exclusiveGateway id="Decision_1" />',
'<bpmn:exclusiveGateway id="Decision_1" default="Flow_3" />',
)
graph = import_bpmn_graph(source)
default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3")
self.assertIs(default_edge.config.get("default"), True)
rendered = export_bpmn_graph(graph)
self.assertIn('default="Flow_3"', rendered)
graph.edges = [
edge.model_copy(update={"config": {**edge.config, "default": False}})
if edge.id == "Flow_3"
else edge
for edge in graph.edges
]
rendered_without_default = export_bpmn_graph(graph)
self.assertNotIn('default="Flow_3"', rendered_without_default)
def test_native_graph_import_is_separate_from_runtime_support(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
)
self.assertIsNotNone(graph)
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusive gateway",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
activation=True,
)
def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None:
with self.assertRaisesRegex(BpmnAdapterError, "is not installed"):
compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="9.0.0",
)
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -27,7 +27,12 @@ from govoplan_workflow.backend.service import (
derive_definition,
update_definition,
)
from test_service import sample_graph
try:
from test_service import sample_graph
except ModuleNotFoundError as exc:
if exc.name != "test_service":
raise
from tests.test_service import sample_graph
POLICY_CAPABILITY = "policy.definitionGovernance"
+506 -1
View File
@@ -11,7 +11,15 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
PrincipalRef,
)
from govoplan_core.core.automation import AutomationPrincipalResolution
from govoplan_core.core.automation import (
ActionDefinition,
ActionExecutionResult,
ActionPreview,
AutomationPrincipalResolution,
EffectDefinition,
EffectPreview,
ObservedEffect,
)
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
DataflowRunDescriptor,
@@ -33,6 +41,7 @@ from govoplan_workflow.backend.instance_service import (
start_instance,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowEdge,
WorkflowGraph,
@@ -40,11 +49,18 @@ from govoplan_workflow.backend.schemas import (
WorkflowNode,
WorkflowStepActionRequest,
)
from govoplan_workflow.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID
from govoplan_workflow.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
)
try:
from test_bpmn import NATIVE_BPMN
except ModuleNotFoundError as exc:
if exc.name != "test_bpmn":
raise
from tests.test_bpmn import NATIVE_BPMN
def principal() -> ApiPrincipal:
@@ -89,6 +105,10 @@ def runtime_graph() -> WorkflowGraph:
"publication_target_ref": "",
"warning_policy": "review",
"input_mapping": {},
"view_surface_ids": [
"dataflow.module",
"dataflow.route.pipelines",
],
},
),
WorkflowNode(
@@ -138,6 +158,58 @@ def runtime_graph() -> WorkflowGraph:
)
def action_graph() -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
config={"input_schema_ref": ""},
),
WorkflowNode(
id="action",
type="workflow.capability",
config={
"capability": "test.actions",
"operation": "test.case.record",
"input_mapping": {"case_id": "$input.case_id"},
"idempotency_key": "$input.case_id",
"failure_policy": "manual",
},
),
WorkflowNode(
id="complete",
type="workflow.end.completed",
config={"output_mapping": {}},
),
WorkflowNode(
id="failed",
type="workflow.end.cancelled",
config={"reason": "Action rejected"},
),
],
edges=[
WorkflowEdge(
id="start-action",
source="start",
target="action",
),
WorkflowEdge(
id="action-complete",
source="action",
source_port="success",
target="complete",
),
WorkflowEdge(
id="action-failed",
source="action",
source_port="failure",
target="failed",
),
],
)
class FakeDataflowLifecycle:
def __init__(self) -> None:
self.runs: dict[str, DataflowRunDescriptor] = {}
@@ -208,19 +280,87 @@ class FakeAutomationProvider:
)
class FakeActionProvider:
action = ActionDefinition(
action_key="test.case.record",
owner_module="test",
description="Record a test case.",
input_schema_ref="schema:test.case.record@1",
expected_effect_keys=("test.case.recorded",),
)
effect = EffectDefinition(
effect_key="test.case.recorded",
owner_module="test",
operation="created",
description="A test case was recorded.",
)
def __init__(self, *states: str) -> None:
self.states = list(states or ("completed",))
self.requests = []
def action_definitions(self):
return (self.action,)
def effect_definitions(self):
return (self.effect,)
def preview_action(self, _session, _principal, *, request):
return ActionPreview(
action_key=request.action_key,
allowed=True,
summary="Record one case.",
risk_level=self.action.risk_level,
reversibility=self.action.reversibility,
effects=(
EffectPreview(
effect_key=self.effect.effect_key,
summary="Record case.",
),
),
preview_ref="preview:test",
)
def execute_action(self, _session, _principal, *, request):
self.requests.append(request)
state = self.states.pop(0) if self.states else "completed"
if state != "completed":
return ActionExecutionResult(
state=state,
error="Temporary action failure.",
)
return ActionExecutionResult(
state="completed",
output={"case_ref": "case:1"},
observed_effects=(
ObservedEffect(
effect_key=self.effect.effect_key,
operation="created",
resource_ref="case:1",
),
),
audit_event_refs=("audit:1",),
)
class Registry:
def __init__(
self,
dataflow: FakeDataflowLifecycle,
automation: FakeAutomationProvider | None = None,
action: FakeActionProvider | None = None,
) -> None:
self.dataflow = dataflow
self.automation = automation
self.action = action
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
and self.automation is not None
) or (
name == "test.actions"
and self.action is not None
)
def capability(self, name: str):
@@ -231,6 +371,8 @@ class Registry:
and self.automation is not None
):
return self.automation
if name == "test.actions" and self.action is not None:
return self.action
raise KeyError(name)
@@ -258,6 +400,9 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
payload=WorkflowDefinitionCreateRequest(
name="Monthly governed processing",
graph=runtime_graph(),
execution_mode="hybrid",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
activate_definition(
@@ -319,8 +464,19 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
self.assertTrue(was_replayed)
self.assertEqual(instance.id, replayed.id)
self.assertEqual("waiting", instance.status)
self.assertEqual("user", instance.start_origin)
self.assertEqual(1, len(self.dataflow.requests))
response = instance_response(self.session, instance)
self.assertEqual("hybrid", response.execution_mode)
self.assertEqual("user", response.start_origin)
self.assertIsNotNone(response.view_context)
self.assertEqual(
[
"dataflow.module",
"dataflow.route.pipelines",
],
response.view_context.visible_surface_ids,
)
self.assertEqual([1, 2], [step.sequence for step in response.steps])
self.assertEqual("run:1", response.steps[-1].external_ref)
self.assertGreaterEqual(len(response.events), 4)
@@ -340,6 +496,306 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
),
)
def test_guided_workflow_rejects_automated_start_origin(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided review",
graph=WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.api",
config={
"input_schema_ref": "schema:input",
"authorization_policy_ref": "policy:start",
},
),
WorkflowNode(
id="activity",
type="workflow.activity",
config={"title": "Review"},
),
WorkflowNode(
id="done",
type="workflow.end.completed",
),
],
edges=[
WorkflowEdge(
id="start-activity",
source="start",
target="activity",
),
WorkflowEdge(
id="activity-done",
source="activity",
target="done",
),
],
),
execution_mode="guided",
allow_automation=True,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
with self.assertRaisesRegex(
WorkflowConflictError,
"Guided workflows must be started by a user",
):
start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="automated-guided",
),
start_origin="api",
)
def test_module_action_records_effects_and_completes_idempotently(
self,
) -> None:
action = FakeActionProvider()
registry = Registry(self.dataflow, action=action)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Action workflow",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="action-instance",
input={"case_id": "case-1"},
),
)
response = instance_response(self.session, instance)
self.assertFalse(replayed)
self.assertEqual("completed", response.status)
self.assertEqual(1, len(action.requests))
self.assertEqual(
"test.actions:test.case.record:case-1",
action.requests[0].idempotency_key,
)
self.assertEqual(
"case:1",
response.steps[1].output["execution"]["observed_effects"][0][
"resource_ref"
],
)
self.assertIn(
"workflow.action.completed",
{event.kind for event in response.events},
)
def test_retryable_module_action_reuses_the_idempotency_key(self) -> None:
action = FakeActionProvider("retryable", "completed")
registry = Registry(self.dataflow, action=action)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Retry action",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="retry-action-instance",
input={"case_id": "case-2"},
),
)
waiting = instance_response(self.session, instance)
current = waiting.steps[-1]
self.assertEqual("retryable", current.handoff["state"])
resolved = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=current.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowStepActionRequest(action="retry"),
)
self.assertEqual("completed", resolved.status)
self.assertEqual(
action.requests[0].idempotency_key,
action.requests[1].idempotency_key,
)
def test_automated_dataflow_failure_policy_fails_without_handoff(
self,
) -> None:
graph = runtime_graph()
flow = next(node for node in graph.nodes if node.id == "flow")
flow.config["warning_policy"] = "continue"
flow.config["failure_policy"] = "fail"
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Automated Dataflow",
graph=graph,
execution_mode="automated",
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="automated-dataflow",
),
)
self.dataflow.fail("run:1")
changed = reconcile_instance(
self.session,
instance=instance,
principal=principal(),
registry=self.registry,
)
self.assertTrue(changed)
self.assertEqual("failed", instance.status)
self.assertIsNone(instance.current_step_id)
self.assertFalse(
any(
step.handoff.get("kind") == "dataflow_failure"
for step in instance.steps
)
)
def test_native_bpmn_profile_runs_through_canonical_instance_state(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="BPMN governed review",
graph=runtime_graph(),
bpmn=BpmnRevisionInput(
xml=NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="bpmn-request-1",
input={"case_id": "case-bpmn"},
),
)
self.assertFalse(replayed)
self.assertEqual("waiting", instance.status)
waiting = instance_response(self.session, instance).steps[-1]
self.assertEqual("workflow.activity", waiting.node_type)
self.assertEqual("Review request", waiting.handoff["title"])
replay, was_replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="bpmn-request-1",
input={"case_id": "case-bpmn"},
),
)
self.assertTrue(was_replayed)
self.assertEqual(instance.id, replay.id)
resolved = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=waiting.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(
action="complete",
comment="Reviewed.",
evidence=["case:case-bpmn"],
),
)
response = instance_response(self.session, resolved)
self.assertEqual("completed", response.status)
self.assertEqual(
"workflow.instance.completed",
response.events[-1].kind,
)
def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None:
instance = self._start()
self.dataflow.finish("run:1")
@@ -477,6 +933,55 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
instance.authorization_["last_resolution"]["status"],
)
def test_worker_reconciles_pending_module_action(self) -> None:
action = FakeActionProvider("pending", "completed")
automation = FakeAutomationProvider()
registry = Registry(
self.dataflow,
automation=automation,
action=action,
)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Asynchronous action",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="pending-action",
input={"case_id": "case-3"},
),
)
self.session.commit()
worker = SqlWorkflowRuntimeWorker(registry=registry)
summary = worker.reconcile_pending(self.session)
self.assertEqual(1, summary["advanced"])
self.assertEqual("completed", instance.status)
self.assertEqual(2, len(action.requests))
self.assertEqual(
action.requests[0].idempotency_key,
action.requests[1].idempotency_key,
)
self.assertEqual(1, len(automation.requests))
if __name__ == "__main__":
unittest.main()
+27 -1
View File
@@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"d8f2a5c7e1b4",
"f1b7d3e5a9c2",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
@@ -43,6 +43,32 @@ class WorkflowMigrationTests(unittest.TestCase):
if name.startswith("workflow_")
},
)
revision_columns = {
item["name"]
for item in inspect(connection).get_columns(
"workflow_definition_revisions"
)
}
self.assertTrue(
{
"bpmn_xml",
"bpmn_hash",
"bpmn_adapter_id",
"bpmn_adapter_version",
"bpmn_runtime_kind",
"bpmn_executable",
"execution_mode",
"view_id",
"view_revision_id",
}.issubset(revision_columns)
)
instance_columns = {
item["name"]
for item in inspect(connection).get_columns(
"workflow_instances"
)
}
self.assertIn("start_origin", instance_columns)
finally:
engine.dispose()
+50 -1
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
import unittest
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -91,6 +94,52 @@ class WorkflowNodeLibraryTests(unittest.TestCase):
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")
self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version)
activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity")
self.assertIn(
"view_surface_ids",
{field.id for field in activity.config_fields},
)
def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None:
node_types = {item.type for item in BPMN_NODE_TYPES}
self.assertIn("bpmn.startEvent", node_types)
self.assertIn("bpmn.userTask", node_types)
self.assertIn("bpmn.exclusiveGateway", node_types)
self.assertIn("bpmn.participant", node_types)
self.assertIn("bpmn.textAnnotation", node_types)
self.assertTrue(all(item.startswith("bpmn.") for item in node_types))
def test_bpmn_rejects_multiple_default_flows(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="bpmn.startEvent"),
WorkflowNode(id="choice", type="bpmn.exclusiveGateway"),
WorkflowNode(id="end-a", type="bpmn.endEvent"),
WorkflowNode(id="end-b", type="bpmn.endEvent"),
],
edges=[
WorkflowEdge(id="to-choice", source="start", target="choice"),
WorkflowEdge(
id="default-a",
source="choice",
target="end-a",
config={"default": True},
),
WorkflowEdge(
id="default-b",
source="choice",
target="end-b",
config={"default": True},
),
],
)
self.assertIn(
"bpmn.multiple_default_flows",
{item.code for item in validate_workflow_graph(graph)},
)
if __name__ == "__main__":
+202
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from pathlib import Path
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
@@ -11,6 +12,7 @@ from govoplan_workflow.backend.db.models import (
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
@@ -19,6 +21,7 @@ from govoplan_workflow.backend.schemas import (
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
@@ -29,6 +32,18 @@ from govoplan_workflow.backend.service import (
list_definitions,
update_definition,
)
from govoplan_workflow.backend.bpmn_adapters import (
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
)
from govoplan_workflow.backend.bpmn import inspect_bpmn_xml
from govoplan_workflow.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID
try:
from test_bpmn import BPMN, NATIVE_BPMN
except ModuleNotFoundError as exc:
if exc.name != "test_bpmn":
raise
from tests.test_bpmn import BPMN, NATIVE_BPMN
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
@@ -174,6 +189,70 @@ class WorkflowServiceTests(unittest.TestCase):
),
)
def test_execution_mode_and_view_pin_are_immutable_revision_content(
self,
) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided review",
graph=sample_graph(),
execution_mode="guided",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Guided review",
graph=sample_graph(),
expected_revision=1,
execution_mode="hybrid",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual("hybrid", revisions[0].execution_mode)
self.assertEqual("guided", revisions[1].execution_mode)
self.assertEqual("view-revision-1", revisions[1].view_revision_id)
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_automated_mode_rejects_human_handoff_paths(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Invalid automation",
graph=sample_graph(),
execution_mode="automated",
allow_automation=True,
),
)
with self.assertRaisesRegex(
WorkflowConflictError,
"human handoff paths",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
definition = self._create()
with self.assertRaises(WorkflowConflictError):
@@ -220,6 +299,129 @@ class WorkflowServiceTests(unittest.TestCase):
),
)
def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
self.session.commit()
first = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml)
self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id)
self.assertEqual("1.0.0", first.bpmn_adapter_version)
self.assertEqual("native_graph", first.bpmn_runtime_kind)
self.assertEqual(
"Review request",
first.graph["nodes"][1]["config"]["title"],
)
changed_xml = NATIVE_BPMN.replace(
"Review request",
"Review corrected request",
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=changed_xml,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
),
expected_revision=1,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
revision=2,
)
self.session.commit()
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual(2, updated.active_revision)
self.assertIn("Review corrected request", revisions[0].bpmn_xml or "")
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
),
),
)
self.session.commit()
with self.assertRaisesRegex(
WorkflowBpmnValidationError,
"exclusive gateway",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_interchange_revision_preserves_extension_xml_exactly(self) -> None:
xml = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "process.bpmn"
).read_text(encoding="utf-8")
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Extended interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=xml,
adapter_id=INTERCHANGE_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
revision = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml)
self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "")
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -19,9 +19,9 @@
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
+189 -2
View File
@@ -6,6 +6,7 @@ import {
} from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphEdge,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
@@ -13,10 +14,39 @@ import type {
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
export type WorkflowExecutionMode = "guided" | "automated" | "hybrid";
export type WorkflowStartOrigin =
| "user"
| "api"
| "schedule"
| "event"
| "parent_workflow"
| "dependency"
| "retry"
| "replay"
| "backfill";
export type WorkflowGraphNode = DefinitionGraphNode & {
size?: { width: number; height: number } | null;
parent_id?: string | null;
process_id?: string | null;
};
export type WorkflowGraphEdge = DefinitionGraphEdge & {
type:
| "bpmn.sequenceFlow"
| "bpmn.messageFlow"
| "bpmn.association"
| "bpmn.dataInputAssociation"
| "bpmn.dataOutputAssociation"
| "bpmn.conversationLink";
label: string;
config: Record<string, unknown>;
waypoints: Array<{ x: number; y: number }>;
};
export type WorkflowGraph = Omit<DefinitionGraph, "nodes" | "edges"> & {
schema_version: 1;
nodes: WorkflowGraphNode[];
edges: WorkflowGraphEdge[];
metadata: Record<string, unknown>;
};
export type WorkflowDiagnostic = {
@@ -29,6 +59,71 @@ export type WorkflowDiagnostic = {
export type WorkflowNodeType = DefinitionGraphNodeType;
export type BpmnRuntimeKind = "model_only" | "native_graph" | "external";
export type BpmnDiagnostic = {
severity: "error" | "warning" | "info";
code: string;
message: string;
element_id?: string | null;
};
export type BpmnAdapterProfile = {
id: string;
version: string;
label: string;
description: string;
conformance: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
supported_elements: string[];
supported_event_definitions: string[];
requirements: string[];
};
export type BpmnInspection = {
valid_xml: boolean;
definitions_id?: string | null;
target_namespace?: string | null;
process_count: number;
executable_process_count: number;
collaboration_count: number;
choreography_count: number;
element_counts: Record<string, number>;
support_counts: Record<string, number>;
elements: Array<{
element_type: string;
element_id?: string | null;
name?: string | null;
parent_type?: string | null;
parent_id?: string | null;
support_level: "interchange_only" | "native_mapping" | "native_execution";
}>;
diagnostics: BpmnDiagnostic[];
adapter_id?: string | null;
adapter_version?: string | null;
runtime_kind?: BpmnRuntimeKind | null;
executable: boolean;
activatable: boolean;
};
export type BpmnRevisionSummary = {
format: "bpmn-2.0";
content_hash: string;
adapter_id: string;
adapter_version: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
adapter_available: boolean;
};
export type BpmnRevisionDocument = BpmnRevisionSummary & {
definition_id: string;
revision: number;
xml: string;
inspection: BpmnInspection;
};
export type WorkflowRevision = {
id: string;
revision: number;
@@ -37,6 +132,10 @@ export type WorkflowRevision = {
content_hash: string;
library_id: string;
library_version: string;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
bpmn?: BpmnRevisionSummary | null;
created_by?: string | null;
created_at: string;
};
@@ -134,6 +233,15 @@ export type WorkflowInstance = {
definition_name: string;
definition_revision: number;
definition_hash: string;
execution_mode: WorkflowExecutionMode;
start_origin: WorkflowStartOrigin;
view_context?: {
view_id: string;
revision_id?: string | null;
visible_surface_ids: string[];
step_id?: string | null;
node_id?: string | null;
} | null;
status: WorkflowInstanceStatus;
idempotency_key: string;
correlation_id?: string | null;
@@ -157,6 +265,11 @@ export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
bpmn?: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
} | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
@@ -165,8 +278,68 @@ export type WorkflowDefinitionPayload = {
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
};
export async function getBpmnSupportProfile(
settings: ApiSettings
): Promise<{
specification: string;
model_namespace: string;
interchange: string;
native_runtime: string;
native_execution_elements: string[];
native_mapping_elements: string[];
adapters: BpmnAdapterProfile[];
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/profile");
}
export function inspectWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
activation?: boolean;
}
): Promise<BpmnInspection> {
return apiFetch(settings, "/api/v1/workflow/bpmn/inspect", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function compileWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
}
): Promise<{
adapter: BpmnAdapterProfile;
graph: WorkflowGraph;
inspection: BpmnInspection;
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/compile", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function renderWorkflowBpmn(
settings: ApiSettings,
payload: { graph: WorkflowGraph; name?: string }
): Promise<{ xml: string; inspection: BpmnInspection }> {
return apiFetch(settings, "/api/v1/workflow/bpmn/render", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
@@ -224,6 +397,9 @@ export function deriveWorkflowDefinition(
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode?: WorkflowExecutionMode | null;
view_id?: string | null;
view_revision_id?: string | null;
}
): Promise<WorkflowDefinition> {
return apiFetch(
@@ -244,6 +420,17 @@ export async function listWorkflowRevisions(
return response.revisions;
}
export function getWorkflowRevisionBpmn(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<BpmnRevisionDocument> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}/bpmn`
);
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
+162 -14
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type DragEvent } from "react";
import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
@@ -7,8 +7,10 @@ import {
BackgroundVariant,
ConnectionLineType,
Controls,
MarkerType,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
@@ -17,6 +19,7 @@ import { definitionConnectionError } from "@govoplan/core-webui/definition-graph
import type {
WorkflowDiagnostic,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
@@ -30,23 +33,29 @@ export default function WorkflowCanvas({
diagnostics,
nodeLibrary,
selectedNodeId,
selectedEdgeId,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode
onSelectNode,
onSelectEdge
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
selectedEdgeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
onSelectEdge: (edgeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | null
>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const definitions = useMemo(
() => new Map(nodeLibrary.map((item) => [item.type, item])),
[nodeLibrary]
@@ -67,8 +76,8 @@ export default function WorkflowCanvas({
id: node.id,
type: "workflow" as const,
position: node.position,
initialWidth: 190,
initialHeight: 56,
initialWidth: canvasNodeSize(node, definition).width,
initialHeight: canvasNodeSize(node, definition).height,
selected: node.id === selectedNodeId,
data: {
label: node.label,
@@ -88,13 +97,34 @@ export default function WorkflowCanvas({
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
className: "workflow-edge"
label: edge.label || undefined,
className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`,
selected: edge.id === selectedEdgeId,
animated: edge.type === "bpmn.messageFlow",
markerEnd: edgeMarkerEnd(edge),
style: edge.type === "bpmn.association"
? { strokeDasharray: "4 4" }
: edge.type === "bpmn.messageFlow"
? { strokeDasharray: "8 5" }
: undefined
})),
[graph.edges]
[graph.edges, selectedEdgeId]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
const movedIds = new Set(
nextNodes
.filter((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
return current
&& (
current.position.x !== flowNode.position.x
|| current.position.y !== flowNode.position.y
);
})
.map((node) => node.id)
);
onGraphChange({
...graph,
nodes: nextNodes.map((flowNode) => {
@@ -104,6 +134,10 @@ export default function WorkflowCanvas({
}),
edges: graph.edges.filter(
(edge) => ids.has(edge.source) && ids.has(edge.target)
).map((edge) =>
movedIds.has(edge.source) || movedIds.has(edge.target)
? { ...edge, waypoints: [] }
: edge
)
});
};
@@ -111,20 +145,43 @@ export default function WorkflowCanvas({
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => ({
edges: nextEdges.map((edge) => {
const current = graph.edges.find((item) => item.id === edge.id);
const endpointsChanged = Boolean(
current
&& (
current.source !== edge.source
|| current.target !== edge.target
)
);
return {
id: edge.id,
type: current?.type ?? "bpmn.sequenceFlow",
label: current?.label ?? "",
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "output",
target_port: edge.targetHandle ?? "input"
}))
source_port: edge.sourceHandle ?? "outgoing",
target_port: edge.targetHandle ?? "incoming",
config: structuredClone(current?.config ?? {}),
waypoints: endpointsChanged
? []
: structuredClone(current?.waypoints ?? [])
} satisfies WorkflowGraphEdge;
})
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
reconnectingEdgeId.current
? {
...graph,
edges: graph.edges.filter(
(edge) => edge.id !== reconnectingEdgeId.current
)
}
: graph,
nodeLibrary,
{
source: connection.source,
@@ -182,7 +239,19 @@ export default function WorkflowCanvas({
}
}}
onEdgesChange={(changes) => {
if (!readOnly) updateEdges(applyEdgeChanges(changes, edges));
if (readOnly) return;
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
onSelectEdge(selectedChange.id);
}
const graphChanges = changes.filter(
(change) => change.type !== "select"
);
if (graphChanges.length) {
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
@@ -192,11 +261,44 @@ export default function WorkflowCanvas({
type: "smoothstep"
}, edges));
}}
onReconnect={(oldEdge, connection) => {
if (readOnly || !isValidConnection(connection)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
}}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
onSelectEdge(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
onNodeClick={(_event, node) => {
onSelectEdge(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
onSelectNode(null);
onSelectEdge(edge.id);
}}
onPaneClick={() => {
onSelectNode(null);
onSelectEdge(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
@@ -224,6 +326,52 @@ export default function WorkflowCanvas({
);
}
function edgeMarkerEnd(edge: WorkflowGraphEdge) {
if (edge.type === "bpmn.sequenceFlow") {
return {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
if (
edge.type === "bpmn.messageFlow"
|| edge.type === "bpmn.dataInputAssociation"
|| edge.type === "bpmn.dataOutputAssociation"
) {
return {
type: MarkerType.Arrow,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
return undefined;
}
function canvasNodeSize(
node: WorkflowGraphNode,
definition: WorkflowNodeType
): { width: number; height: number } {
const shape = String(definition.metadata?.shape ?? "activity");
if (shape.startsWith("event")) return { width: 124, height: 70 };
if (shape === "gateway") return { width: 124, height: 82 };
if (shape === "participant" || shape === "lane") {
return {
width: Math.max(240, Math.min(node.size?.width ?? 360, 720)),
height: Math.max(100, Math.min(node.size?.height ?? 160, 360))
};
}
if (shape === "group") {
return {
width: Math.max(220, node.size?.width ?? 300),
height: Math.max(120, node.size?.height ?? 180)
};
}
return { width: 190, height: 64 };
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
@@ -1,30 +1,64 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DismissibleAlert,
FormField
FormField,
ReferenceMultiSelect,
useViewSurfaces,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
edge,
nodeLibrary,
readOnly,
onChange,
onDelete
onDelete,
onEdgeChange,
onEdgeDelete
}: {
node: WorkflowGraphNode | null;
edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void;
onEdgeDelete: (edgeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
const viewSurfaces = useViewSurfaces();
const viewSurfaceProvider = useMemo<ReferenceOptionProvider>(() => {
const options = viewSurfaces.map((surface) => ({
value: surface.id,
label: surface.label,
description: `${surface.moduleId} · ${surface.kind}`,
searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}`
}));
const byId = new Map(options.map((option) => [option.value, option]));
return {
search: async (query, context) => {
const normalized = query.trim().toLowerCase();
return options
.filter((option) => (
!normalized
|| option.searchText.toLowerCase().includes(normalized)
))
.slice(0, context.limit);
},
resolve: async (values) => values
.map((value) => byId.get(value))
.filter((option): option is (typeof options)[number] => Boolean(option))
};
}, [viewSurfaces]);
useEffect(() => {
if (!node) {
@@ -44,6 +78,109 @@ export default function WorkflowInspector({
setError("");
}, [node?.id, nodeLibrary]);
if (edge) {
const updateEdgeConfig = (field: string, value: unknown) => {
onEdgeChange({
...edge,
config: { ...edge.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Flow inspector">
<div className="workflow-panel-heading">
<span>
<strong>Flow</strong>
<small>{edge.type.replace(/^bpmn\./, "")}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onEdgeDelete(edge.id)}
disabled={readOnly}
aria-label="Delete flow"
title="Delete flow"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
<FormField label="Flow type">
<select
value={edge.type}
onChange={(event) => onEdgeChange({
...edge,
type: event.target.value as WorkflowGraphEdge["type"]
})}
disabled={readOnly}
>
<option value="bpmn.sequenceFlow">Sequence flow</option>
<option value="bpmn.messageFlow">Message flow</option>
<option value="bpmn.association">Association</option>
<option value="bpmn.dataInputAssociation">
Data input association
</option>
<option value="bpmn.dataOutputAssociation">
Data output association
</option>
<option value="bpmn.conversationLink">
Conversation link
</option>
</select>
</FormField>
<FormField label="Name">
<input
value={edge.label}
onChange={(event) => onEdgeChange({
...edge,
label: event.target.value
})}
disabled={readOnly}
/>
</FormField>
{edge.type === "bpmn.sequenceFlow" ? (
<>
<FormField
label="Condition"
help="A constrained expression evaluated when this flow is reached."
>
<textarea
value={textValue(edge.config.condition)}
onChange={(event) =>
updateEdgeConfig("condition", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<FormField
label="Runtime outcome"
help="Optional GovOPlaN task outcome mapped to this sequence flow."
>
<input
value={textValue(edge.config.outcome)}
onChange={(event) =>
updateEdgeConfig("outcome", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<label className="workflow-inspector-checkbox">
<input
type="checkbox"
checked={edge.config.default === true}
onChange={(event) =>
updateEdgeConfig("default", event.target.checked)
}
disabled={readOnly}
/>
Default flow
</label>
</>
) : null}
</div>
</aside>
);
}
if (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
@@ -158,6 +295,16 @@ export default function WorkflowInspector({
)}
disabled={readOnly}
/>
) : field.kind === "view_surfaces" ? (
<ReferenceMultiSelect
values={stringList(node.config[field.id])}
onChange={(values) => updateConfig(field.id, values)}
provider={viewSurfaceProvider}
aria-label={field.label}
placeholder="Add a visible surface"
searchPlaceholder="Search modules and interface surfaces"
disabled={readOnly}
/>
) : (
<input
value={textValue(node.config[field.id])}
+61 -1
View File
@@ -1,4 +1,33 @@
import {
Asterisk,
BadgeDollarSign,
BoxSelect,
Braces,
CircleDashed,
CircleDot,
CircleDotDashed,
CirclePlus,
CircleStop,
Cog,
Database,
Diamond,
ExternalLink,
File,
FileCode2,
GitBranch,
Hand,
Inbox,
MessagesSquare,
Plus,
RadioTower,
RectangleHorizontal,
Rows3,
Scale,
Send,
Shuffle,
Square,
TextQuote,
UserRoundCheck,
CalendarClock,
CheckCircle2,
CirclePlay,
@@ -41,7 +70,36 @@ const iconByName: Record<string, LucideIcon> = {
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints
waypoints: Waypoints,
asterisk: Asterisk,
"badge-dollar-sign": BadgeDollarSign,
"box-select": BoxSelect,
braces: Braces,
"circle-dashed": CircleDashed,
"circle-dot": CircleDot,
"circle-dot-dashed": CircleDotDashed,
"circle-plus": CirclePlus,
"circle-stop": CircleStop,
cog: Cog,
database: Database,
diamond: Diamond,
"external-link": ExternalLink,
file: File,
"file-code-2": FileCode2,
"git-branch": GitBranch,
hand: Hand,
inbox: Inbox,
"messages-square": MessagesSquare,
plus: Plus,
"radio-tower": RadioTower,
"rectangle-horizontal": RectangleHorizontal,
"rows-3": Rows3,
scale: Scale,
send: Send,
shuffle: Shuffle,
square: Square,
"text-quote": TextQuote,
"user-round-check": UserRoundCheck
};
export default function WorkflowNode({
@@ -50,6 +108,7 @@ export default function WorkflowNode({
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const shape = String(data.definition.metadata?.shape ?? "activity");
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
@@ -57,6 +116,7 @@ export default function WorkflowNode({
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
`workflow-node-shape-${shape}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { ListChecks } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listWorkflowInstances,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
export default function WorkflowOpenWorkWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 6, 1, 20);
const includeRunning = configuration.includeRunning !== false;
const load = useCallback(async () => {
const instances = await listWorkflowInstances(settings);
return instances
.filter((instance) =>
instance.status === "waiting"
|| (includeRunning && instance.status === "running")
)
.sort(compareOpenWork)
.slice(0, maxItems);
}, [includeRunning, maxItems, settings]);
const { data: instances, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading open workflow work">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No workflow work is currently open."
items={(instances ?? []).map((instance) => {
const step = currentStep(instance);
return {
id: instance.id,
title: instance.definition_name,
detail: handoffTitle(step),
meta: updatedLabel(instance.updated_at),
leading: <ListChecks size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={instance.status}
label={instance.status === "waiting" ? "Waiting" : "Running"}
/>
),
to: workflowRunUrl(instance)
};
})}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/workflow">
Open Workflow
</Link>
</div>
</LoadingFrame>
);
}
function currentStep(
instance: WorkflowInstance
): WorkflowInstanceStep | null {
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffTitle(step: WorkflowInstanceStep | null): string {
const title = step?.handoff.title;
if (typeof title === "string" && title.trim()) return title;
if (!step) return "Preparing next step";
return step.node_type
.replace(/^workflow\./, "")
.split(".")
.join(" ");
}
function workflowRunUrl(instance: WorkflowInstance): string {
const query = new URLSearchParams({
definition: instance.definition_id,
run: instance.id
});
return `/workflow?${query.toString()}`;
}
function compareOpenWork(
left: WorkflowInstance,
right: WorkflowInstance
): number {
if (left.status !== right.status) {
return left.status === "waiting" ? -1 : 1;
}
return (
new Date(right.updated_at).getTime()
- new Date(left.updated_at).getTime()
);
}
function updatedLabel(value: string): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
+249 -36
View File
@@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent
} from "react";
@@ -9,6 +10,7 @@ import {
Archive,
CheckCircle2,
CopyPlus,
Download,
GitFork,
ListChecks,
Plus,
@@ -16,9 +18,11 @@ import {
RotateCcw,
Save,
Settings2,
Trash2
Trash2,
Upload
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "react-router";
import {
Button,
ConfirmDialog,
@@ -34,23 +38,27 @@ import {
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
useEffectiveView,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
activateWorkflowDefinition,
archiveWorkflowDefinition,
compileWorkflowBpmn,
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
renderWorkflowBpmn,
updateWorkflowDefinition,
validateWorkflowDefinition,
workflowScopeReferenceProvider,
type WorkflowDefinition,
type WorkflowDiagnostic,
type WorkflowGraphEdge,
type WorkflowNodeType,
type WorkflowRevision
} from "../../api/workflow";
@@ -69,12 +77,12 @@ import {
} from "./model";
const CATEGORY_ORDER = [
"trigger",
"activity",
"decision",
"wait",
"integration",
"outcome"
"bpmn_event",
"bpmn_activity",
"bpmn_gateway",
"bpmn_data",
"bpmn_collaboration",
"bpmn_artifact"
];
export default function WorkflowPage({
@@ -85,6 +93,9 @@ export default function WorkflowPage({
auth: AuthInfo;
}) {
const { requestNavigation } = useUnsavedChanges();
const [searchParams, setSearchParams] = useSearchParams();
const requestedDefinitionId = searchParams.get("definition");
const requestedRunId = searchParams.get("run");
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
const [savedDraft, setSavedDraft] = useState<WorkflowDraft | null>(null);
@@ -96,6 +107,7 @@ export default function WorkflowPage({
);
const [allowsCycles, setAllowsCycles] = useState(true);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
@@ -106,6 +118,7 @@ export default function WorkflowPage({
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
@@ -133,10 +146,15 @@ export default function WorkflowPage({
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canEdit || historicalRevision !== null;
const graphReadOnly = readOnly;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
);
const selectedEdge = useMemo(
() => displayedGraph?.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
[displayedGraph, selectedEdgeId]
);
const visibleDefinitions = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return definitions;
@@ -162,6 +180,7 @@ export default function WorkflowPage({
setSavedDraft(structuredClone(next));
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
}, []);
@@ -189,7 +208,7 @@ export default function WorkflowPage({
}, [applyDefinition, settings]);
useEffect(() => {
void reload();
void reload(requestedDefinitionId);
let cancelled = false;
void listWorkflowNodeTypes(settings)
.then((library) => {
@@ -203,7 +222,17 @@ export default function WorkflowPage({
return () => {
cancelled = true;
};
}, [reload, settings]);
}, [reload, requestedDefinitionId, settings]);
useEffect(() => {
if (
requestedRunId
&& draft?.id
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
) {
setRunsOpen(true);
}
}, [draft?.id, requestedDefinitionId, requestedRunId]);
useEffect(() => {
if (!draft?.id) {
@@ -232,6 +261,7 @@ export default function WorkflowPage({
setDraft(next);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
@@ -305,6 +335,7 @@ export default function WorkflowPage({
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setError("");
setSuccess("");
@@ -315,10 +346,11 @@ export default function WorkflowPage({
if (!displayedGraph) return;
setWorking(true);
setError("");
setSuccess("");
try {
const result = await validateWorkflowDefinition(settings, displayedGraph);
setDiagnostics(result.diagnostics);
setSuccess(result.valid ? "Workflow definition is valid." : "");
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
} catch (validationError) {
setError(apiErrorMessage(validationError));
} finally {
@@ -326,6 +358,73 @@ export default function WorkflowPage({
}
};
const importBpmnFile = async (file: File | null) => {
if (!file || readOnly) return;
setWorking(true);
setError("");
setSuccess("");
try {
const xml = await file.text();
const imported = await compileWorkflowBpmn(settings, {
xml,
adapter_id: "govoplan.native.bpmn",
adapter_version: "1.0.0"
});
setDraft((current) => current ? {
...current,
graph: imported.graph
} : current);
setSelectedNodeId(imported.graph.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
setDiagnostics([]);
setSuccess("Imported BPMN XML into the native graph.");
} catch (fileError) {
setError(apiErrorMessage(fileError));
} finally {
setWorking(false);
}
};
const exportBpmnFile = async () => {
if (!displayedGraph) return;
setWorking(true);
setError("");
try {
const rendered = await renderWorkflowBpmn(settings, {
graph: displayedGraph,
name: draft?.name ?? "Workflow"
});
const url = URL.createObjectURL(
new Blob([rendered.xml], { type: "application/xml" })
);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName(draft?.name || "workflow")}.bpmn`;
link.click();
URL.revokeObjectURL(url);
} catch (exportError) {
setError(apiErrorMessage(exportError));
} finally {
setWorking(false);
}
};
const selectRevision = (revisionNumber: number) => {
if (!draft?.id) return;
setDiagnostics([]);
setSelectedEdgeId(null);
if (revisionNumber === draft.currentRevision) {
setHistoricalRevision(null);
setSelectedNodeId(draft.graph.nodes[0]?.id ?? null);
return;
}
const historical = revisions.find(
(item) => item.revision === revisionNumber
) ?? null;
setHistoricalRevision(historical);
setSelectedNodeId(historical?.graph.nodes[0]?.id ?? null);
};
const activate = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
@@ -382,14 +481,14 @@ export default function WorkflowPage({
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (readOnly) return;
if (graphReadOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
setDiagnostics([]);
setSuccess("");
};
const removeNode = (nodeId: string) => {
if (!draft || readOnly) return;
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
@@ -400,6 +499,25 @@ export default function WorkflowPage({
setSelectedNodeId(null);
};
const updateEdge = (updatedEdge: WorkflowGraphEdge) => {
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
edges: draft.graph.edges.map((edge) =>
edge.id === updatedEdge.id ? updatedEdge : edge
)
});
};
const removeEdge = (edgeId: string) => {
if (!draft || graphReadOnly) return;
updateGraph({
...draft.graph,
edges: draft.graph.edges.filter((edge) => edge.id !== edgeId)
});
setSelectedEdgeId(null);
};
return (
<main className="workflow-page">
<div className="workflow-shell">
@@ -501,21 +619,9 @@ export default function WorkflowPage({
?? draft.currentRevision
?? 1
}
onChange={(event) => {
const revision = Number(event.target.value);
const historical = revisions.find(
(item) => item.revision === revision
) ?? null;
setHistoricalRevision(
revision === draft.currentRevision
? null
: historical
);
setSelectedNodeId(
(historical?.graph ?? draft.graph).nodes[0]?.id ?? null
);
setDiagnostics([]);
}}
onChange={(event) => selectRevision(
Number(event.target.value)
)}
aria-label="Workflow revision"
>
{revisions.map((revision) => (
@@ -533,7 +639,11 @@ export default function WorkflowPage({
onClick={() => {
setDraft({
...draft,
graph: structuredClone(historicalRevision.graph)
graph: structuredClone(historicalRevision.graph),
executionMode: historicalRevision.execution_mode,
viewId: historicalRevision.view_id ?? "",
viewRevisionId:
historicalRevision.view_revision_id ?? ""
});
setHistoricalRevision(null);
setDiagnostics([]);
@@ -543,6 +653,30 @@ export default function WorkflowPage({
<RotateCcw size={16} /> Restore
</Button>
) : null}
<IconButton
label="Import BPMN XML"
icon={<Upload size={16} />}
variant="ghost"
onClick={() => bpmnFileInputRef.current?.click()}
disabled={readOnly || working}
/>
<input
ref={bpmnFileInputRef}
className="workflow-bpmn-file-input"
type="file"
accept=".bpmn,.xml,application/xml,text/xml"
onChange={(event) => {
void importBpmnFile(event.target.files?.[0] ?? null);
event.target.value = "";
}}
/>
<IconButton
label="Export BPMN XML"
icon={<Download size={16} />}
variant="ghost"
onClick={() => void exportBpmnFile()}
disabled={!displayedGraph || working}
/>
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
@@ -600,7 +734,9 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canEdit || !dirty || working || readOnly}
disabled={
!canEdit || !dirty || working || readOnly
}
>
<Save size={16} /> Save
</Button>
@@ -643,8 +779,8 @@ export default function WorkflowPage({
<button
key={nodeType.type}
type="button"
draggable={!readOnly}
disabled={readOnly}
draggable={!graphReadOnly}
disabled={graphReadOnly}
onDragStart={(event) =>
startPaletteDrag(event, nodeType.type)
}
@@ -666,23 +802,28 @@ export default function WorkflowPage({
diagnostics={diagnostics}
nodeLibrary={nodeLibrary}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
selectedEdgeId={selectedEdgeId}
readOnly={graphReadOnly}
allowsCycles={allowsCycles}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
onSelectEdge={setSelectedEdgeId}
/>
</ReactFlowProvider>
</div>
<div className="workflow-inspector-column">
<WorkflowInspector
node={selectedNode}
edge={selectedEdge}
nodeLibrary={nodeLibrary}
readOnly={readOnly}
readOnly={graphReadOnly}
onChange={(node) => {
if (!draft || readOnly) return;
if (!draft || graphReadOnly) return;
updateGraph(updateWorkflowGraphNode(draft.graph, node));
}}
onDelete={removeNode}
onEdgeChange={updateEdge}
onEdgeDelete={removeEdge}
/>
{diagnostics.length ? (
<div className="workflow-diagnostics">
@@ -775,9 +916,17 @@ export default function WorkflowPage({
open={runsOpen}
settings={settings}
definition={selectedDefinition}
initialInstanceId={requestedRunId}
canStart={canStart}
canTransition={canTransition}
onClose={() => setRunsOpen(false)}
onClose={() => {
setRunsOpen(false);
if (requestedRunId) {
const next = new URLSearchParams(searchParams);
next.delete("run");
setSearchParams(next, { replace: true });
}
}}
/>
</main>
);
@@ -799,6 +948,7 @@ function WorkflowDefinitionSettingsDialog({
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
const effectiveView = useEffectiveView();
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
const scopeProvider = useMemo(
() => workflowScopeReferenceProvider(settings, referenceScopeType),
@@ -881,6 +1031,61 @@ function WorkflowDefinitionSettingsDialog({
<option value="template">Template</option>
</select>
</FormField>
<FormField
label="Execution mode"
help="Guided flows require a user; automated flows cannot contain human handoffs; hybrid flows can combine both."
>
<select
value={draft.executionMode}
disabled={!editable}
onChange={(event) => onChange({
executionMode:
event.target.value as WorkflowDraft["executionMode"],
allowAutomation:
event.target.value === "guided"
? false
: draft.allowAutomation
})}
>
<option value="guided">Guided UI workflow</option>
<option value="automated">Automated workflow</option>
<option value="hybrid">Hybrid workflow</option>
</select>
</FormField>
<FormField
label="Workflow View"
help="The selected immutable View revision is applied for active runs. Individual steps may narrow it further."
>
<select
value={draft.viewId}
disabled={!editable || !effectiveView}
onChange={(event) => {
const viewId = event.target.value;
const option = effectiveView?.availableViews.find(
(item) => item.id === viewId
);
onChange({
viewId,
viewRevisionId: option?.revisionId ?? ""
});
}}
>
<option value="">Use the current interface</option>
{draft.viewId
&& !effectiveView?.availableViews.some(
(item) => item.id === draft.viewId
) ? (
<option value={draft.viewId}>
Stored View (currently unavailable)
</option>
) : null}
{(effectiveView?.availableViews ?? []).map((view) => (
<option key={view.id} value={view.id}>
{view.name}
</option>
))}
</select>
</FormField>
<div className="workflow-definition-toggles">
<ToggleSwitch
label="Visible to lower scopes"
@@ -903,7 +1108,7 @@ function WorkflowDefinitionSettingsDialog({
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable}
disabled={!editable || draft.executionMode === "guided"}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
@@ -1094,6 +1299,14 @@ function startPaletteDrag(
event.dataTransfer.effectAllowed = "copy";
}
function fileName(value: string): string {
return value
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "workflow";
}
function apiErrorMessage(error: unknown): string {
if (!isApiError(error)) {
return error instanceof Error ? error.message : "The request failed.";
@@ -23,8 +23,13 @@ import {
FormField,
IconButton,
LoadingFrame,
StageRail,
StatusBadge,
type ApiSettings
dispatchWorkflowViewChanged,
usePlatformUiCapability,
type StageRailTone,
type ApiSettings,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
@@ -50,6 +55,7 @@ export default function WorkflowRunsDialog({
open,
settings,
definition,
initialInstanceId,
canStart,
canTransition,
onClose
@@ -57,6 +63,7 @@ export default function WorkflowRunsDialog({
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
initialInstanceId?: string | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
@@ -69,6 +76,9 @@ export default function WorkflowRunsDialog({
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
"views.runtime"
);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
@@ -99,7 +109,10 @@ export default function WorkflowRunsDialog({
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
items.some((item) => item.id === current)
initialInstanceId
&& items.some((item) => item.id === initialInstanceId)
? initialInstanceId
: items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
@@ -108,7 +121,7 @@ export default function WorkflowRunsDialog({
} finally {
setLoading(false);
}
}, [definition?.id, open, settings]);
}, [definition?.id, initialInstanceId, open, settings]);
useEffect(() => {
if (!open) return;
@@ -117,6 +130,39 @@ export default function WorkflowRunsDialog({
void load();
}, [load, open]);
useEffect(() => {
if (!open || !selected) return;
const context = selected.view_context;
if (!context || !viewsRuntime) {
dispatchWorkflowViewChanged(null);
return;
}
let cancelled = false;
void viewsRuntime.resolveWorkflowView(settings, {
viewId: context.view_id,
revisionId: context.revision_id,
visibleSurfaceIds: context.visible_surface_ids
}).then((projection) => {
if (!cancelled) {
dispatchWorkflowViewChanged(projection, selected.id);
}
}).catch((viewError) => {
if (!cancelled) {
dispatchWorkflowViewChanged(null);
setError(errorMessage(viewError));
}
});
return () => {
cancelled = true;
};
}, [
open,
selected?.id,
selected?.updated_at,
settings,
viewsRuntime
]);
useEffect(() => {
if (
!open
@@ -236,6 +282,10 @@ export default function WorkflowRunsDialog({
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
const close = () => {
dispatchWorkflowViewChanged(null);
onClose();
};
return (
<>
@@ -244,8 +294,8 @@ export default function WorkflowRunsDialog({
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
onClose={close}
footer={<Button onClick={close}>Close</Button>}
>
<div className="workflow-runs-toolbar">
<span>
@@ -419,18 +469,16 @@ export default function WorkflowRunsDialog({
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<div>
{selected.steps.map((step) => (
<div
key={step.id}
className="workflow-run-stage"
data-state={step.status}
data-current={
step.id === selected.current_step_id || undefined
}
>
<span className="workflow-run-stage-marker">
{step.status === "completed" ? (
<StageRail
ariaLabel="Workflow instance progress"
items={selected.steps.map((step) => ({
id: step.id,
label: step.node_id,
detail: `${step.node_type} · attempt ${step.attempt}`,
statusLabel: step.status,
current: step.id === selected.current_step_id,
tone: stepTone(step.status),
icon: step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
@@ -438,21 +486,9 @@ export default function WorkflowRunsDialog({
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)}
</span>
<span className="workflow-run-stage-copy">
<strong>{step.node_id}</strong>
<small>
{step.node_type} · attempt {step.attempt}
</small>
<StatusBadge
status={step.status}
label={step.status}
)
}))}
/>
</span>
</div>
))}
</div>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
@@ -530,6 +566,15 @@ function actionLabel(action: WorkflowAction): string {
}[action];
}
function stepTone(
status: WorkflowInstanceStep["status"]
): StageRailTone {
if (status === "completed") return "success";
if (status === "running" || status === "waiting") return "active";
if (status === "failed" || status === "cancelled") return "danger";
return "neutral";
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
+131 -42
View File
@@ -7,6 +7,7 @@ import type {
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowExecutionMode,
WorkflowNodeType,
WorkflowStatus
} from "../../api/workflow";
@@ -27,44 +28,53 @@ export type WorkflowDraft = {
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
executionMode: WorkflowExecutionMode;
viewId: string;
viewRevisionId: string;
governance: WorkflowGovernance | null;
};
const inputPort = [{
id: "input",
label: "Input",
id: "incoming",
label: "Incoming",
required: true,
multiple: false,
multiple: true,
minimum_connections: 1
}];
const outputPort = [{
id: "output",
label: "Output",
required: true,
multiple: false,
minimum_connections: 1
id: "outgoing",
label: "Outgoing",
required: false,
multiple: true,
minimum_connections: 0
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "workflow.start.manual",
category: "trigger",
category_label: "Start",
label: "Manual start",
description: "Start through an explicit action.",
type: "bpmn.startEvent",
category: "bpmn_event",
category_label: "Events",
label: "Start event",
description: "Start a BPMN process.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: { input_schema_ref: "" }
default_config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-start" }
},
{
type: "workflow.activity",
category: "activity",
type: "bpmn.userTask",
category: "bpmn_activity",
category_label: "Activities",
label: "Activity",
description: "A governed unit of work.",
icon: "square-check-big",
label: "User task",
description: "A governed user task.",
icon: "user-round-check",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
@@ -80,20 +90,29 @@ export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
title: "",
instructions: "",
assignee: "",
due_after: ""
}
due_after: "",
task_mode: "activity",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "activity" }
},
{
type: "workflow.end.completed",
category: "outcome",
category_label: "Outcomes",
label: "Completed",
description: "Complete successfully.",
icon: "circle-check-big",
type: "bpmn.endEvent",
category: "bpmn_event",
category_label: "Events",
label: "End event",
description: "End the BPMN process.",
icon: "circle-stop",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: { output_mapping: {} }
default_config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-end" }
}
];
@@ -110,53 +129,98 @@ export function sampleWorkflowDraft(): WorkflowDraft {
allowStart: true,
allowReuse: false,
allowAutomation: false,
executionMode: "hybrid",
viewId: "",
viewRevisionId: "",
governance: null,
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "workflow.start.manual",
type: "bpmn.startEvent",
label: "Start",
position: { x: 60, y: 150 },
config: { input_schema_ref: "" }
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
}
},
{
id: "activity",
type: "workflow.activity",
type: "bpmn.userTask",
label: "Activity",
position: { x: 320, y: 150 },
size: { width: 120, height: 80 },
process_id: "Process_1",
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: ""
due_after: "",
task_mode: "activity",
documentation: ""
}
},
{
id: "complete",
type: "workflow.end.completed",
type: "bpmn.endEvent",
label: "Completed",
position: { x: 580, y: 150 },
config: { output_mapping: {} }
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
}
}
],
edges: [
{
id: "start-activity",
type: "bpmn.sequenceFlow",
label: "",
source: "start",
target: "activity",
source_port: "output",
target_port: "input"
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
},
{
id: "activity-complete",
type: "bpmn.sequenceFlow",
label: "",
source: "activity",
target: "complete",
source_port: "output",
target_port: "input"
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
}
],
metadata: {
notation: "bpmn-2.0",
bpmn: {
definitions_id: "Definitions_1",
target_namespace: "urn:govoplan:workflow",
processes: [{
id: "Process_1",
name: "",
is_executable: true,
attributes: {}
}],
collaborations: [],
choreographies: [],
root_elements_xml: []
}
}
]
}
};
}
@@ -180,6 +244,9 @@ export function draftFromDefinition(
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
executionMode: definition.revision.execution_mode,
viewId: definition.revision.view_id ?? "",
viewRevisionId: definition.revision.view_revision_id ?? "",
governance: definition.governance
};
}
@@ -198,7 +265,10 @@ export function workflowPayload(
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation
allow_automation: draft.allowAutomation,
execution_mode: draft.executionMode,
view_id: draft.viewId || null,
view_revision_id: draft.viewRevisionId || null
};
}
@@ -217,7 +287,10 @@ export function workflowFingerprint(
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation
allowAutomation: draft.allowAutomation,
executionMode: draft.executionMode,
viewId: draft.viewId,
viewRevisionId: draft.viewRevisionId
});
}
@@ -226,9 +299,25 @@ export function newWorkflowNode(
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
return createDefinitionGraphNode<WorkflowGraphNode>(
const node = createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
const shape = library.find((item) => item.type === type)?.metadata?.shape;
return {
...node,
process_id: "Process_1",
size: defaultNodeSize(String(shape ?? "activity"))
};
}
function defaultNodeSize(shape: string): { width: number; height: number } {
if (shape.startsWith("event")) return { width: 36, height: 36 };
if (shape === "gateway") return { width: 50, height: 50 };
if (shape === "participant") return { width: 600, height: 180 };
if (shape === "lane") return { width: 560, height: 140 };
if (shape === "data-object") return { width: 36, height: 50 };
if (shape === "data-store") return { width: 50, height: 50 };
return { width: 120, height: 80 };
}
+68 -2
View File
@@ -1,13 +1,67 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "@xyflow/react/dist/style.css";
import "./styles/workflow.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const WorkflowOpenWorkWidget = lazy(
() => import("./features/workflow/WorkflowOpenWorkWidget")
);
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
const instanceReadScopes = [
"workflow:instance:read",
"workflow:instance:admin"
];
const workflowDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "workflow.open-work",
surfaceId: "workflow.widget.open-work",
title: "Open workflow work",
description: "Running workflows and steps that require attention.",
moduleId: "workflow",
category: "Work",
order: 42,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: instanceReadScopes,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 6,
includeRunning: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum items",
kind: "number",
min: 1,
max: 20,
step: 1,
required: true
},
{
id: "includeRunning",
label: "Include running steps",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(WorkflowOpenWorkWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const workflowModule: PlatformWebModule = {
id: "workflow",
@@ -22,6 +76,15 @@ export const workflowModule: PlatformWebModule = {
"policy",
"tasks"
],
viewSurfaces: [
{
id: "workflow.widget.open-work",
moduleId: "workflow",
kind: "section",
label: "Open workflow work widget",
order: 76
}
],
navItems: [
{
to: "/workflow",
@@ -39,7 +102,10 @@ export const workflowModule: PlatformWebModule = {
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
]
],
uiCapabilities: {
"dashboard.widgets": workflowDashboardWidgets
}
};
export default workflowModule;
+119 -105
View File
@@ -274,6 +274,10 @@
overflow: hidden;
}
.workflow-bpmn-file-input {
display: none;
}
.workflow-palette,
.workflow-inspector,
.workflow-inspector-column {
@@ -457,38 +461,101 @@
display: flex;
align-items: center;
gap: 9px;
width: 190px;
width: 100%;
height: 100%;
min-height: 56px;
box-sizing: border-box;
border: 1px solid var(--line-dark);
border-left: 4px solid #3d6f9e;
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow-xs);
padding: 8px 10px;
}
.workflow-node-trigger {
border-left-color: #2f7d6d;
.workflow-node-bpmn_activity {
border-width: 2px;
}
.workflow-node-activity {
border-left-color: #3d6f9e;
.workflow-node-bpmn_collaboration {
border-width: 2px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
}
.workflow-node-decision {
border-left-color: #b7791f;
.workflow-node-shape-participant,
.workflow-node-shape-lane {
align-items: flex-start;
padding: 12px;
}
.workflow-node-wait {
border-left-color: #8a6d3b;
.workflow-node-shape-lane {
border-width: 1px;
}
.workflow-node-integration {
border-left-color: #76569b;
.workflow-node-shape-group {
align-items: flex-start;
border: 2px dashed var(--line-dark);
background: transparent;
box-shadow: none;
}
.workflow-node-outcome {
border-left-color: #9d4e63;
.workflow-node-shape-text-annotation {
border: 0;
border-left: 2px solid var(--line-dark);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"],
.workflow-node-shape-gateway {
justify-content: flex-start;
border: 0;
background: transparent;
box-shadow: none;
padding: 4px;
}
.workflow-node[class*="workflow-node-shape-event"] .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: 50%;
background: var(--panel);
}
.workflow-node-shape-event-intermediate-catch .workflow-node-icon,
.workflow-node-shape-event-intermediate-throw .workflow-node-icon {
box-shadow: inset 0 0 0 3px var(--panel), inset 0 0 0 4px var(--text-strong);
}
.workflow-node-shape-event-boundary .workflow-node-icon {
border-style: dashed;
}
.workflow-node-shape-event-end .workflow-node-icon {
border-width: 4px;
}
.workflow-node-shape-gateway .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: 2px;
background: var(--panel);
transform: rotate(45deg);
}
.workflow-node-shape-gateway .workflow-node-icon svg {
transform: rotate(-45deg);
}
.workflow-node-shape-data-object,
.workflow-node-shape-data-store,
.workflow-node-shape-conversation,
.workflow-node-shape-choreography {
border-width: 2px;
}
.workflow-node.is-selected {
@@ -498,10 +565,26 @@
var(--shadow-xs);
}
.workflow-node[class*="workflow-node-shape-event"].is-selected,
.workflow-node-shape-gateway.is-selected {
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"].is-selected .workflow-node-icon,
.workflow-node-shape-gateway.is-selected .workflow-node-icon {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
.workflow-node.has-error {
border-color: var(--danger-text);
}
.workflow-node[class*="workflow-node-shape-event"].has-error .workflow-node-icon,
.workflow-node-shape-gateway.has-error .workflow-node-icon {
border-color: var(--danger-text);
}
.workflow-node-icon {
display: grid;
width: 28px;
@@ -552,6 +635,25 @@
background: var(--accent);
}
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
.workflow-edge-bpmn-association .react-flow__edge-path,
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
stroke-width: 1.5;
}
.workflow-inspector-checkbox {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 12px;
}
.workflow-inspector-checkbox input {
width: auto;
margin: 0;
}
.workflow-inspector-fields {
display: grid;
grid-auto-rows: max-content;
@@ -899,100 +1001,10 @@
padding: 6px 2px;
}
.workflow-run-history > div {
grid-template-columns: repeat(
auto-fit,
minmax(min(150px, 100%), 1fr)
);
overflow-x: auto;
.workflow-run-history .stage-rail {
padding: 2px 0 4px;
}
.workflow-run-stage {
--workflow-stage-color: var(--line-dark);
position: relative;
display: grid;
min-width: 140px;
grid-template-columns: 32px minmax(0, 1fr);
gap: 7px;
padding: 4px 12px 4px 0;
}
.workflow-run-stage[data-state="completed"] {
--workflow-stage-color: var(--green);
}
.workflow-run-stage[data-state="running"],
.workflow-run-stage[data-state="waiting"] {
--workflow-stage-color: var(--blue);
}
.workflow-run-stage[data-state="failed"],
.workflow-run-stage[data-state="cancelled"] {
--workflow-stage-color: var(--red);
}
.workflow-run-stage[data-state="superseded"] {
--workflow-stage-color: var(--muted);
}
.workflow-run-stage:not(:last-child)::after {
position: absolute;
z-index: 0;
top: 18px;
left: 27px;
width: calc(100% - 17px);
height: 2px;
background: linear-gradient(
90deg,
var(--workflow-stage-color),
var(--line-dark)
);
content: "";
}
.workflow-run-stage-marker {
z-index: 1;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--workflow-stage-color);
border-radius: 50%;
background: var(--panel);
color: var(--workflow-stage-color);
}
.workflow-run-stage[data-current="true"] .workflow-run-stage-marker {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-stage-color) 18%, transparent);
}
.workflow-run-stage-copy {
z-index: 1;
display: grid;
min-width: 0;
align-content: start;
gap: 3px;
padding-top: 2px;
}
.workflow-run-stage-copy strong,
.workflow-run-stage-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-stage-copy strong {
color: var(--text-strong);
font-size: 11px;
}
.workflow-run-stage-copy .status-badge {
width: fit-content;
margin-top: 2px;
}
.workflow-run-history small,
.workflow-run-events small {
color: var(--muted);
@@ -1025,6 +1037,7 @@
.workflow-editor {
grid-template-columns: 180px minmax(0, 1fr) 270px;
}
}
@media (max-width: 900px) {
@@ -1042,6 +1055,7 @@
border-top: var(--border-line);
border-left: 0;
}
}
@media (max-width: 680px) {
+2 -1
View File
@@ -26,7 +26,8 @@
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/production/index.d.ts"]
}
},
"include": ["src"]