Implement native BPMN workflows and guided modes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+68
@@ -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")
|
||||
+62
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow test package for both discovery and targeted module execution."""
|
||||
+35
@@ -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
@@ -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
@@ -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>
|
||||
Vendored
+27
@@ -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>
|
||||
Vendored
+50
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
source_port: edge.sourceHandle ?? "output",
|
||||
target_port: edge.targetHandle ?? "input"
|
||||
}))
|
||||
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 ?? "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,21 +239,66 @@ 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;
|
||||
updateEdges(addEdge({
|
||||
...connection,
|
||||
id: `edge-${crypto.randomUUID()}`,
|
||||
type: "smoothstep"
|
||||
}, edges));
|
||||
id: `edge-${crypto.randomUUID()}`,
|
||||
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])}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -628,93 +764,98 @@ export default function WorkflowPage({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="workflow-editor">
|
||||
<aside className="workflow-palette">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Node library</strong>
|
||||
<small>Drag nodes onto the canvas</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-palette-items">
|
||||
{paletteGroups.map((group) => (
|
||||
<section key={group.category} className="workflow-palette-group">
|
||||
<h3>{group.label}</h3>
|
||||
{group.nodes.map((nodeType) => (
|
||||
<button
|
||||
key={nodeType.type}
|
||||
type="button"
|
||||
draggable={!readOnly}
|
||||
disabled={readOnly}
|
||||
onDragStart={(event) =>
|
||||
startPaletteDrag(event, nodeType.type)
|
||||
}
|
||||
title={nodeType.description}
|
||||
>
|
||||
<GitFork size={16} />
|
||||
<span>{nodeType.label}</span>
|
||||
<Plus size={13} className="workflow-palette-add" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workflow-editor-surface">
|
||||
<ReactFlowProvider>
|
||||
<WorkflowCanvas
|
||||
graph={displayedGraph}
|
||||
diagnostics={diagnostics}
|
||||
nodeLibrary={nodeLibrary}
|
||||
selectedNodeId={selectedNodeId}
|
||||
readOnly={readOnly}
|
||||
allowsCycles={allowsCycles}
|
||||
onGraphChange={updateGraph}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<div className="workflow-inspector-column">
|
||||
<WorkflowInspector
|
||||
node={selectedNode}
|
||||
nodeLibrary={nodeLibrary}
|
||||
readOnly={readOnly}
|
||||
onChange={(node) => {
|
||||
if (!draft || readOnly) return;
|
||||
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
||||
}}
|
||||
onDelete={removeNode}
|
||||
/>
|
||||
{diagnostics.length ? (
|
||||
<div className="workflow-diagnostics">
|
||||
<div className="workflow-panel-heading">
|
||||
<strong>Diagnostics</strong>
|
||||
<StatusBadge
|
||||
status={
|
||||
diagnostics.some((item) => item.severity === "error")
|
||||
? "error"
|
||||
: "warning"
|
||||
}
|
||||
label={String(diagnostics.length)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{diagnostics.map((item, index) => (
|
||||
<button
|
||||
key={`${item.code}-${item.node_id ?? "graph"}-${index}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.node_id) setSelectedNodeId(item.node_id);
|
||||
}}
|
||||
>
|
||||
<strong>{item.message}</strong>
|
||||
<small>{item.code}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<aside className="workflow-palette">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Node library</strong>
|
||||
<small>Drag nodes onto the canvas</small>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="workflow-palette-items">
|
||||
{paletteGroups.map((group) => (
|
||||
<section key={group.category} className="workflow-palette-group">
|
||||
<h3>{group.label}</h3>
|
||||
{group.nodes.map((nodeType) => (
|
||||
<button
|
||||
key={nodeType.type}
|
||||
type="button"
|
||||
draggable={!graphReadOnly}
|
||||
disabled={graphReadOnly}
|
||||
onDragStart={(event) =>
|
||||
startPaletteDrag(event, nodeType.type)
|
||||
}
|
||||
title={nodeType.description}
|
||||
>
|
||||
<GitFork size={16} />
|
||||
<span>{nodeType.label}</span>
|
||||
<Plus size={13} className="workflow-palette-add" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workflow-editor-surface">
|
||||
<ReactFlowProvider>
|
||||
<WorkflowCanvas
|
||||
graph={displayedGraph}
|
||||
diagnostics={diagnostics}
|
||||
nodeLibrary={nodeLibrary}
|
||||
selectedNodeId={selectedNodeId}
|
||||
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={graphReadOnly}
|
||||
onChange={(node) => {
|
||||
if (!draft || graphReadOnly) return;
|
||||
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
||||
}}
|
||||
onDelete={removeNode}
|
||||
onEdgeChange={updateEdge}
|
||||
onEdgeDelete={removeEdge}
|
||||
/>
|
||||
{diagnostics.length ? (
|
||||
<div className="workflow-diagnostics">
|
||||
<div className="workflow-panel-heading">
|
||||
<strong>Diagnostics</strong>
|
||||
<StatusBadge
|
||||
status={
|
||||
diagnostics.some((item) => item.severity === "error")
|
||||
? "error"
|
||||
: "warning"
|
||||
}
|
||||
label={String(diagnostics.length)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{diagnostics.map((item, index) => (
|
||||
<button
|
||||
key={`${item.code}-${item.node_id ?? "graph"}-${index}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.node_id) setSelectedNodeId(item.node_id);
|
||||
}}
|
||||
>
|
||||
<strong>{item.message}</strong>
|
||||
<small>{item.code}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{working ? (
|
||||
<div className="workflow-working-indicator" role="status">
|
||||
Working...
|
||||
@@ -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,40 +469,26 @@ 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" ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
) : step.status === "failed" ? (
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
) : ["running", "waiting"].includes(step.status) ? (
|
||||
<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>
|
||||
<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" />
|
||||
) : ["running", "waiting"].includes(step.status) ? (
|
||||
<Clock3 size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" />
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user