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
|
and records its hash, node-library version, source scope, actor, Policy
|
||||||
decision, and effective ancestor limits.
|
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
|
The start-node library distinguishes explicit user, API, scheduled, event, and
|
||||||
parent-workflow starts. Manual starts and Dataflow/human handoffs are
|
parent-workflow starts. Manual starts and Dataflow/human handoffs are
|
||||||
operational. The other trigger and generic capability nodes remain explicit
|
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.
|
operation providers are implemented.
|
||||||
|
|
||||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
|
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
|
||||||
|
See [docs/BPMN_INTEROPERABILITY.md](docs/BPMN_INTEROPERABILITY.md) for the
|
||||||
There is a BPMN component playing a major role here. Maybe this needs to
|
notation, conformance, and adapter boundary.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,24 +1,51 @@
|
|||||||
# BPMN Interoperability
|
# Native BPMN Graph
|
||||||
|
|
||||||
GovOPlaN distinguishes BPMN notation and XML interchange from executable
|
GovOPlaN uses BPMN 2.0 as Workflow's canonical graph language while keeping
|
||||||
workflow semantics.
|
notation support distinct from executable runtime support.
|
||||||
|
|
||||||
## Current Contract
|
## Current Contract
|
||||||
|
|
||||||
- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support
|
- The native Workflow graph stores BPMN element and flow types, process
|
||||||
profile.
|
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,
|
- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML,
|
||||||
inventories every BPMN model element, detects duplicate IDs and selected
|
inventories every BPMN model element, detects duplicate IDs and selected
|
||||||
dangling references, and classifies elements as interchange-only, natively
|
dangling references, and classifies elements as interchange-only, natively
|
||||||
mappable, or natively executable.
|
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
|
- XML entities, DTD-based expansion, oversized documents, and malformed roots
|
||||||
are rejected.
|
are rejected.
|
||||||
- The native GovOPlaN graph remains the authoritative executable definition.
|
|
||||||
|
|
||||||
Inspection is not XML Schema validation and does not claim that every BPMN
|
Inspection is not XML Schema validation and does not claim that every editable
|
||||||
semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter
|
BPMN construct can be executed. Notation and interchange remain available when
|
||||||
can provide complete visual notation and XML round-tripping without forcing
|
the native runtime cannot activate the document.
|
||||||
unsupported elements into the native runner.
|
|
||||||
|
## 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
|
## Execution Boundary
|
||||||
|
|
||||||
@@ -32,6 +59,26 @@ behavior. Each executable mapping therefore needs:
|
|||||||
4. migration and round-trip fixtures;
|
4. migration and round-trip fixtures;
|
||||||
5. a declared fallback when the installed runtime cannot execute it.
|
5. a declared fallback when the installed runtime cannot execute it.
|
||||||
|
|
||||||
Unsupported constructs remain visible and preserved by the future interchange
|
Unsupported execution constructs remain visible in the native graph, but
|
||||||
adapter, but activation must remain blocked until an execution adapter declares
|
activation remains blocked until an execution adapter declares support.
|
||||||
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 collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from xml.etree.ElementTree import ParseError
|
from xml.etree.ElementTree import Element, ParseError
|
||||||
|
|
||||||
from defusedxml.ElementTree import fromstring
|
from defusedxml.ElementTree import fromstring
|
||||||
from defusedxml.common import DefusedXmlException
|
from defusedxml.common import DefusedXmlException
|
||||||
@@ -27,33 +27,45 @@ NATIVE_EXECUTION_ELEMENTS = frozenset(
|
|||||||
{
|
{
|
||||||
"startEvent",
|
"startEvent",
|
||||||
"endEvent",
|
"endEvent",
|
||||||
|
"task",
|
||||||
"manualTask",
|
"manualTask",
|
||||||
"userTask",
|
"userTask",
|
||||||
"serviceTask",
|
"serviceTask",
|
||||||
"businessRuleTask",
|
|
||||||
"receiveTask",
|
|
||||||
"sendTask",
|
"sendTask",
|
||||||
"exclusiveGateway",
|
"receiveTask",
|
||||||
"parallelGateway",
|
|
||||||
"sequenceFlow",
|
|
||||||
"intermediateCatchEvent",
|
"intermediateCatchEvent",
|
||||||
"intermediateThrowEvent",
|
"sequenceFlow",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
|
NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
|
||||||
{
|
{
|
||||||
"task",
|
"definitions",
|
||||||
|
"process",
|
||||||
|
"collaboration",
|
||||||
|
"documentation",
|
||||||
|
"extensionElements",
|
||||||
|
"incoming",
|
||||||
|
"outgoing",
|
||||||
|
"conditionExpression",
|
||||||
"scriptTask",
|
"scriptTask",
|
||||||
|
"serviceTask",
|
||||||
|
"businessRuleTask",
|
||||||
|
"receiveTask",
|
||||||
|
"sendTask",
|
||||||
"callActivity",
|
"callActivity",
|
||||||
"subProcess",
|
"subProcess",
|
||||||
"transaction",
|
"transaction",
|
||||||
"adHocSubProcess",
|
"adHocSubProcess",
|
||||||
|
"exclusiveGateway",
|
||||||
|
"parallelGateway",
|
||||||
"inclusiveGateway",
|
"inclusiveGateway",
|
||||||
"eventBasedGateway",
|
"eventBasedGateway",
|
||||||
"complexGateway",
|
"complexGateway",
|
||||||
"boundaryEvent",
|
"boundaryEvent",
|
||||||
"eventSubProcess",
|
"eventSubProcess",
|
||||||
|
"intermediateCatchEvent",
|
||||||
|
"intermediateThrowEvent",
|
||||||
"dataObject",
|
"dataObject",
|
||||||
"dataObjectReference",
|
"dataObjectReference",
|
||||||
"dataStoreReference",
|
"dataStoreReference",
|
||||||
@@ -64,6 +76,16 @@ NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
|
|||||||
"textAnnotation",
|
"textAnnotation",
|
||||||
"association",
|
"association",
|
||||||
"group",
|
"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"
|
return "interchange_only"
|
||||||
|
|
||||||
|
|
||||||
def inspect_bpmn_xml(xml: str) -> BpmnInspection:
|
def parse_bpmn_xml(xml: str) -> Element:
|
||||||
encoded = xml.encode("utf-8")
|
encoded = xml.encode("utf-8")
|
||||||
if not encoded:
|
if not encoded:
|
||||||
raise BpmnInspectionError("BPMN XML is empty")
|
raise BpmnInspectionError("BPMN XML is empty")
|
||||||
@@ -134,6 +156,15 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection:
|
|||||||
raise BpmnInspectionError(
|
raise BpmnInspectionError(
|
||||||
"BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace"
|
"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] = []
|
diagnostics: list[BpmnDiagnostic] = []
|
||||||
elements: list[BpmnElementInventoryItem] = []
|
elements: list[BpmnElementInventoryItem] = []
|
||||||
@@ -149,10 +180,6 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection:
|
|||||||
while stack:
|
while stack:
|
||||||
element, parent_type, parent_id = stack.pop()
|
element, parent_type, parent_id = stack.pop()
|
||||||
visited += 1
|
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_namespace, element_type = _qualified_name(element.tag)
|
||||||
element_id = _bounded_attribute(element.attrib.get("id"), 255)
|
element_id = _bounded_attribute(element.attrib.get("id"), 255)
|
||||||
if element_namespace == BPMN_MODEL_NAMESPACE:
|
if element_namespace == BPMN_MODEL_NAMESPACE:
|
||||||
@@ -268,17 +295,24 @@ def _collect_references(
|
|||||||
attributes: dict[str, str],
|
attributes: dict[str, str],
|
||||||
references: list[tuple[str, str | None, str]],
|
references: list[tuple[str, str | None, str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
fields: tuple[str, ...]
|
fields_by_element = {
|
||||||
if element_type == "sequenceFlow":
|
"sequenceFlow": ("sourceRef", "targetRef"),
|
||||||
fields = ("sourceRef", "targetRef")
|
"messageFlow": ("sourceRef", "targetRef", "messageRef"),
|
||||||
elif element_type == "messageFlow":
|
"association": ("sourceRef", "targetRef"),
|
||||||
fields = ("sourceRef", "targetRef", "messageRef")
|
"participant": ("processRef",),
|
||||||
elif element_type == "participant":
|
"lane": ("partitionElementRef",),
|
||||||
fields = ("processRef",)
|
"boundaryEvent": ("attachedToRef",),
|
||||||
elif element_type == "lane":
|
"dataInputAssociation": ("sourceRef", "targetRef"),
|
||||||
fields = ("partitionElementRef",)
|
"dataOutputAssociation": ("sourceRef", "targetRef"),
|
||||||
else:
|
"dataObjectReference": ("dataObjectRef",),
|
||||||
fields = ()
|
"dataStoreReference": ("dataStoreRef",),
|
||||||
|
"messageEventDefinition": ("messageRef", "operationRef"),
|
||||||
|
"signalEventDefinition": ("signalRef",),
|
||||||
|
"errorEventDefinition": ("errorRef",),
|
||||||
|
"escalationEventDefinition": ("escalationRef",),
|
||||||
|
"compensateEventDefinition": ("activityRef",),
|
||||||
|
}
|
||||||
|
fields = fields_by_element.get(element_type, ())
|
||||||
for field in fields:
|
for field in fields:
|
||||||
value = attributes.get(field)
|
value = attributes.get(field)
|
||||||
if value:
|
if value:
|
||||||
@@ -306,4 +340,5 @@ __all__ = [
|
|||||||
"NATIVE_MAPPING_ELEMENTS",
|
"NATIVE_MAPPING_ELEMENTS",
|
||||||
"bpmn_support_level",
|
"bpmn_support_level",
|
||||||
"inspect_bpmn_xml",
|
"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)
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
library_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
library_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
library_version: Mapped[str] = mapped_column(String(40), 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(
|
created_by: Mapped[str | None] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -232,6 +264,12 @@ class WorkflowInstance(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
start_origin: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="user",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
idempotency_key: Mapped[str] = mapped_column(
|
idempotency_key: Mapped[str] = mapped_column(
|
||||||
String(255),
|
String(255),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
|
|||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
|
ViewSurface,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.policy import (
|
from govoplan_core.core.policy import (
|
||||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||||
@@ -169,6 +170,11 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
|
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
|
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
|
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=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -239,6 +245,15 @@ manifest = ModuleManifest(
|
|||||||
order=74,
|
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,
|
route_factory=_router,
|
||||||
capability_factories={
|
capability_factories={
|
||||||
@@ -291,6 +306,33 @@ manifest = ModuleManifest(
|
|||||||
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
|
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
|
||||||
order=76,
|
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,
|
DefinitionConfigField,
|
||||||
DefinitionGraphConstraints,
|
DefinitionGraphConstraints,
|
||||||
DefinitionGraphLibrary,
|
DefinitionGraphLibrary,
|
||||||
DefinitionNodeCountConstraint,
|
|
||||||
DefinitionNodeType,
|
DefinitionNodeType,
|
||||||
DefinitionPort,
|
DefinitionPort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CATEGORY_LABELS = {
|
CATEGORY_LABELS = {
|
||||||
|
"bpmn_event": "Events",
|
||||||
|
"bpmn_activity": "Activities",
|
||||||
|
"bpmn_gateway": "Gateways",
|
||||||
|
"bpmn_data": "Data",
|
||||||
|
"bpmn_collaboration": "Collaboration",
|
||||||
|
"bpmn_artifact": "Artifacts",
|
||||||
"trigger": "Start",
|
"trigger": "Start",
|
||||||
"activity": "Activities",
|
"activity": "Activities",
|
||||||
"decision": "Decisions",
|
"decision": "Decisions",
|
||||||
@@ -19,7 +24,17 @@ CATEGORY_LABELS = {
|
|||||||
"outcome": "Outcomes",
|
"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(
|
DefinitionNodeType(
|
||||||
type="workflow.start.manual",
|
type="workflow.start.manual",
|
||||||
category="trigger",
|
category="trigger",
|
||||||
@@ -154,12 +169,14 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
|
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
|
||||||
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
|
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
|
||||||
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
||||||
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={
|
default_config={
|
||||||
"title": "",
|
"title": "",
|
||||||
"instructions": "",
|
"instructions": "",
|
||||||
"assignee": "",
|
"assignee": "",
|
||||||
"due_after": "",
|
"due_after": "",
|
||||||
|
"view_surface_ids": [],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DefinitionNodeType(
|
DefinitionNodeType(
|
||||||
@@ -182,8 +199,14 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
label="Required evidence",
|
label="Required evidence",
|
||||||
kind="string_list",
|
kind="string_list",
|
||||||
),
|
),
|
||||||
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={"title": "", "reviewer": "", "required_evidence": []},
|
default_config={
|
||||||
|
"title": "",
|
||||||
|
"reviewer": "",
|
||||||
|
"required_evidence": [],
|
||||||
|
"view_surface_ids": [],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DefinitionNodeType(
|
DefinitionNodeType(
|
||||||
type="workflow.decision",
|
type="workflow.decision",
|
||||||
@@ -231,8 +254,13 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
DefinitionConfigField(id="value", label="Value", kind="text"),
|
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(
|
DefinitionNodeType(
|
||||||
type="workflow.capability",
|
type="workflow.capability",
|
||||||
@@ -282,13 +310,15 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
("continue", "Continue on failure"),
|
("continue", "Continue on failure"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={
|
default_config={
|
||||||
"capability": "",
|
"capability": "",
|
||||||
"operation": "",
|
"operation": "",
|
||||||
"input_mapping": {},
|
"input_mapping": {},
|
||||||
"idempotency_key": "",
|
"idempotency_key": "workflow-step",
|
||||||
"failure_policy": "manual",
|
"failure_policy": "manual",
|
||||||
|
"view_surface_ids": [],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DefinitionNodeType(
|
DefinitionNodeType(
|
||||||
@@ -357,7 +387,18 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
("continue", "Continue"),
|
("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"),
|
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
||||||
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={
|
default_config={
|
||||||
"pipeline_ref": "",
|
"pipeline_ref": "",
|
||||||
@@ -366,7 +407,9 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
"row_limit": 500,
|
"row_limit": 500,
|
||||||
"publication_target_ref": "",
|
"publication_target_ref": "",
|
||||||
"warning_policy": "review",
|
"warning_policy": "review",
|
||||||
|
"failure_policy": "manual",
|
||||||
"input_mapping": {},
|
"input_mapping": {},
|
||||||
|
"view_surface_ids": [],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DefinitionNodeType(
|
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(
|
WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary(
|
||||||
id="workflow",
|
id="workflow",
|
||||||
version="0.1.0",
|
version="1.0.0",
|
||||||
category_labels=CATEGORY_LABELS,
|
category_labels=CATEGORY_LABELS,
|
||||||
node_types=WORKFLOW_NODE_TYPES,
|
node_types=WORKFLOW_NODE_TYPES,
|
||||||
constraints=DefinitionGraphConstraints(
|
constraints=DefinitionGraphConstraints(
|
||||||
max_nodes=150,
|
max_nodes=150,
|
||||||
max_edges=300,
|
max_edges=300,
|
||||||
allow_cycles=True,
|
allow_cycles=True,
|
||||||
require_connected=True,
|
require_connected=False,
|
||||||
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",),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -445,7 +1141,9 @@ WORKFLOW_NODE_TYPES_BY_ID = {
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BPMN_NODE_TYPES",
|
||||||
"CATEGORY_LABELS",
|
"CATEGORY_LABELS",
|
||||||
|
"LEGACY_WORKFLOW_NODE_TYPES",
|
||||||
"WORKFLOW_GRAPH_LIBRARY",
|
"WORKFLOW_GRAPH_LIBRARY",
|
||||||
"WORKFLOW_NODE_TYPES",
|
"WORKFLOW_NODE_TYPES",
|
||||||
"WORKFLOW_NODE_TYPES_BY_ID",
|
"WORKFLOW_NODE_TYPES_BY_ID",
|
||||||
|
|||||||
@@ -28,19 +28,41 @@ from govoplan_workflow.backend.manifest import (
|
|||||||
INSTANCE_START_SCOPE,
|
INSTANCE_START_SCOPE,
|
||||||
INSTANCE_TRANSITION_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 (
|
from govoplan_workflow.backend.bpmn import (
|
||||||
BPMN_MODEL_NAMESPACE,
|
BPMN_MODEL_NAMESPACE,
|
||||||
|
BpmnDiagnostic,
|
||||||
BpmnInspectionError,
|
BpmnInspectionError,
|
||||||
NATIVE_EXECUTION_ELEMENTS,
|
NATIVE_EXECUTION_ELEMENTS,
|
||||||
NATIVE_MAPPING_ELEMENTS,
|
NATIVE_MAPPING_ELEMENTS,
|
||||||
inspect_bpmn_xml,
|
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 (
|
from govoplan_workflow.backend.schemas import (
|
||||||
|
BpmnAdapterProfileResponse,
|
||||||
|
BpmnCompileRequest,
|
||||||
|
BpmnCompileResponse,
|
||||||
BpmnDiagnosticResponse,
|
BpmnDiagnosticResponse,
|
||||||
BpmnElementSupportResponse,
|
BpmnElementSupportResponse,
|
||||||
BpmnInspectionRequest,
|
BpmnInspectionRequest,
|
||||||
BpmnInspectionResponse,
|
BpmnInspectionResponse,
|
||||||
|
BpmnRevisionDocumentResponse,
|
||||||
|
BpmnRenderRequest,
|
||||||
|
BpmnRenderResponse,
|
||||||
BpmnSupportProfileResponse,
|
BpmnSupportProfileResponse,
|
||||||
WorkflowConfigFieldResponse,
|
WorkflowConfigFieldResponse,
|
||||||
WorkflowDefinitionActivateRequest,
|
WorkflowDefinitionActivateRequest,
|
||||||
@@ -74,6 +96,7 @@ from govoplan_workflow.backend.instance_service import (
|
|||||||
)
|
)
|
||||||
from govoplan_workflow.backend.runtime import get_registry
|
from govoplan_workflow.backend.runtime import get_registry
|
||||||
from govoplan_workflow.backend.service import (
|
from govoplan_workflow.backend.service import (
|
||||||
|
WorkflowBpmnValidationError,
|
||||||
WorkflowConflictError,
|
WorkflowConflictError,
|
||||||
WorkflowError,
|
WorkflowError,
|
||||||
WorkflowNotFoundError,
|
WorkflowNotFoundError,
|
||||||
@@ -89,6 +112,7 @@ from govoplan_workflow.backend.service import (
|
|||||||
list_definition_revisions,
|
list_definition_revisions,
|
||||||
list_definitions,
|
list_definitions,
|
||||||
revision_response,
|
revision_response,
|
||||||
|
revision_bpmn_summary,
|
||||||
update_definition,
|
update_definition,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.validation import validate_workflow_graph
|
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))
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
if isinstance(exc, WorkflowConflictError):
|
if isinstance(exc, WorkflowConflictError):
|
||||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
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):
|
if isinstance(exc, WorkflowValidationError):
|
||||||
return HTTPException(
|
return HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
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 _adapter_profile_response(profile) -> BpmnAdapterProfileResponse:
|
||||||
def api_bpmn_support_profile(
|
return BpmnAdapterProfileResponse(
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
id=profile.id,
|
||||||
) -> BpmnSupportProfileResponse:
|
version=profile.version,
|
||||||
_require_any_scope(
|
label=profile.label,
|
||||||
principal,
|
description=profile.description,
|
||||||
DEFINITION_READ_SCOPE,
|
conformance=profile.conformance,
|
||||||
DEFINITION_WRITE_SCOPE,
|
runtime_kind=profile.runtime_kind,
|
||||||
ADMIN_SCOPE,
|
executable=profile.executable,
|
||||||
)
|
supported_elements=list(profile.supported_elements),
|
||||||
return BpmnSupportProfileResponse(
|
supported_event_definitions=list(profile.supported_event_definitions),
|
||||||
specification="BPMN 2.0.2",
|
requirements=list(profile.requirements),
|
||||||
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
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
|
def _bpmn_inspection_response(
|
||||||
def api_inspect_bpmn(
|
xml: str,
|
||||||
payload: BpmnInspectionRequest,
|
*,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
adapter_id: str,
|
||||||
|
adapter_version: str | None = None,
|
||||||
|
activation: bool,
|
||||||
) -> BpmnInspectionResponse:
|
) -> BpmnInspectionResponse:
|
||||||
_require_any_scope(
|
result = inspect_bpmn_xml(xml)
|
||||||
principal,
|
adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version)
|
||||||
DEFINITION_READ_SCOPE,
|
adapter_diagnostics = (
|
||||||
DEFINITION_WRITE_SCOPE,
|
adapter.diagnostics(xml, result, activation=activation)
|
||||||
ADMIN_SCOPE,
|
if adapter is not None
|
||||||
|
else ()
|
||||||
)
|
)
|
||||||
try:
|
diagnostics = list(result.diagnostics)
|
||||||
result = inspect_bpmn_xml(payload.xml)
|
diagnostics.extend(adapter_diagnostics)
|
||||||
except BpmnInspectionError as exc:
|
if adapter is None:
|
||||||
raise HTTPException(
|
suffix = f"@{adapter_version}" if adapter_version else ""
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
diagnostics.append(
|
||||||
detail=str(exc),
|
BpmnDiagnostic(
|
||||||
) from exc
|
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(
|
return BpmnInspectionResponse(
|
||||||
valid_xml=result.valid_xml,
|
valid_xml=result.valid_xml,
|
||||||
definitions_id=result.definitions_id,
|
definitions_id=result.definitions_id,
|
||||||
@@ -290,11 +329,167 @@ def api_inspect_bpmn(
|
|||||||
message=item.message,
|
message=item.message,
|
||||||
element_id=item.element_id,
|
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)
|
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
|
||||||
def api_node_types(
|
def api_node_types(
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
@@ -349,8 +544,9 @@ def api_node_types(
|
|||||||
for field in definition.config_fields
|
for field in definition.config_fields
|
||||||
],
|
],
|
||||||
default_config=dict(definition.default_config),
|
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,
|
principal=principal,
|
||||||
registry=get_registry(),
|
registry=get_registry(),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
start_origin=(
|
||||||
|
"user" if principal.auth_method == "session" else "api"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
@@ -531,6 +730,7 @@ def api_start_instance(
|
|||||||
"definition_id": instance.definition_id,
|
"definition_id": instance.definition_id,
|
||||||
"definition_revision_id": instance.definition_revision_id,
|
"definition_revision_id": instance.definition_revision_id,
|
||||||
"idempotency_key": instance.idempotency_key,
|
"idempotency_key": instance.idempotency_key,
|
||||||
|
"start_origin": instance.start_origin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = instance_response(session, instance, replayed=replayed)
|
response = instance_response(session, instance, replayed=replayed)
|
||||||
@@ -989,6 +1189,65 @@ def api_get_definition_revision(
|
|||||||
return revision_response(item)
|
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(
|
@router.post(
|
||||||
"/definitions/{definition_id}/activate",
|
"/definitions/{definition_id}/activate",
|
||||||
response_model=WorkflowDefinitionResponse,
|
response_model=WorkflowDefinitionResponse,
|
||||||
|
|||||||
@@ -4,12 +4,25 @@ import math
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
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"]
|
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
|
||||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||||
DefinitionKind = Literal["flow", "template"]
|
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[
|
BpmnSupportLevel = Literal[
|
||||||
"interchange_only",
|
"interchange_only",
|
||||||
"native_mapping",
|
"native_mapping",
|
||||||
@@ -29,26 +42,58 @@ class WorkflowPosition(BaseModel):
|
|||||||
return value
|
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):
|
class WorkflowNode(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=120)
|
id: str = Field(min_length=1, max_length=120)
|
||||||
type: str = Field(min_length=1, max_length=120)
|
type: str = Field(min_length=1, max_length=120)
|
||||||
label: str = Field(default="", max_length=300)
|
label: str = Field(default="", max_length=300)
|
||||||
position: WorkflowPosition = Field(default_factory=WorkflowPosition)
|
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)
|
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):
|
class WorkflowEdge(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=120)
|
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)
|
source: str = Field(min_length=1, max_length=120)
|
||||||
target: 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)
|
source_port: str = Field(default="output", min_length=1, max_length=120)
|
||||||
target_port: str = Field(default="input", 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):
|
class WorkflowGraph(BaseModel):
|
||||||
schema_version: Literal[1] = 1
|
schema_version: Literal[1] = 1
|
||||||
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
|
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
|
||||||
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
|
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowGraphValidationRequest(BaseModel):
|
class WorkflowGraphValidationRequest(BaseModel):
|
||||||
@@ -96,6 +141,7 @@ class WorkflowNodeTypeResponse(BaseModel):
|
|||||||
output_ports: list[WorkflowPortResponse]
|
output_ports: list[WorkflowPortResponse]
|
||||||
config_fields: list[WorkflowConfigFieldResponse]
|
config_fields: list[WorkflowConfigFieldResponse]
|
||||||
default_config: dict[str, Any]
|
default_config: dict[str, Any]
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowNodeLibraryResponse(BaseModel):
|
class WorkflowNodeLibraryResponse(BaseModel):
|
||||||
@@ -107,6 +153,13 @@ class WorkflowNodeLibraryResponse(BaseModel):
|
|||||||
|
|
||||||
class BpmnInspectionRequest(BaseModel):
|
class BpmnInspectionRequest(BaseModel):
|
||||||
xml: str = Field(min_length=1, max_length=1_048_576)
|
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):
|
class BpmnElementSupportResponse(BaseModel):
|
||||||
@@ -137,6 +190,24 @@ class BpmnInspectionResponse(BaseModel):
|
|||||||
support_counts: dict[str, int]
|
support_counts: dict[str, int]
|
||||||
elements: list[BpmnElementSupportResponse]
|
elements: list[BpmnElementSupportResponse]
|
||||||
diagnostics: list[BpmnDiagnosticResponse]
|
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):
|
class BpmnSupportProfileResponse(BaseModel):
|
||||||
@@ -146,6 +217,60 @@ class BpmnSupportProfileResponse(BaseModel):
|
|||||||
native_runtime: str
|
native_runtime: str
|
||||||
native_execution_elements: list[str]
|
native_execution_elements: list[str]
|
||||||
native_mapping_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):
|
class WorkflowDefinitionRevisionResponse(BaseModel):
|
||||||
@@ -156,6 +281,10 @@ class WorkflowDefinitionRevisionResponse(BaseModel):
|
|||||||
content_hash: str
|
content_hash: str
|
||||||
library_id: str
|
library_id: str
|
||||||
library_version: 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_by: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -221,6 +350,7 @@ class WorkflowDefinitionCreateRequest(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=300)
|
name: str = Field(min_length=1, max_length=300)
|
||||||
description: str | None = Field(default=None, max_length=4_000)
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
graph: WorkflowGraph
|
graph: WorkflowGraph
|
||||||
|
bpmn: BpmnRevisionInput | None = None
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
scope_type: DefinitionScopeType = "tenant"
|
scope_type: DefinitionScopeType = "tenant"
|
||||||
scope_id: str | None = Field(default=None, max_length=36)
|
scope_id: str | None = Field(default=None, max_length=36)
|
||||||
@@ -229,12 +359,26 @@ class WorkflowDefinitionCreateRequest(BaseModel):
|
|||||||
allow_start: bool = True
|
allow_start: bool = True
|
||||||
allow_reuse: bool = False
|
allow_reuse: bool = False
|
||||||
allow_automation: 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):
|
class WorkflowDefinitionUpdateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=300)
|
name: str = Field(min_length=1, max_length=300)
|
||||||
description: str | None = Field(default=None, max_length=4_000)
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
graph: WorkflowGraph
|
graph: WorkflowGraph
|
||||||
|
bpmn: BpmnRevisionInput | None = None
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
expected_revision: int = Field(ge=1)
|
expected_revision: int = Field(ge=1)
|
||||||
scope_type: DefinitionScopeType = "tenant"
|
scope_type: DefinitionScopeType = "tenant"
|
||||||
@@ -244,6 +388,19 @@ class WorkflowDefinitionUpdateRequest(BaseModel):
|
|||||||
allow_start: bool = True
|
allow_start: bool = True
|
||||||
allow_reuse: bool = False
|
allow_reuse: bool = False
|
||||||
allow_automation: 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):
|
class WorkflowDefinitionDeriveRequest(BaseModel):
|
||||||
@@ -264,6 +421,19 @@ class WorkflowDefinitionDeriveRequest(BaseModel):
|
|||||||
allow_start: bool = True
|
allow_start: bool = True
|
||||||
allow_reuse: bool = False
|
allow_reuse: bool = False
|
||||||
allow_automation: 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):
|
class WorkflowDefinitionActivateRequest(BaseModel):
|
||||||
@@ -332,6 +502,14 @@ class WorkflowInstanceStepResponse(BaseModel):
|
|||||||
updated_at: datetime
|
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):
|
class WorkflowInstanceEventResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
sequence: int
|
sequence: int
|
||||||
@@ -348,6 +526,9 @@ class WorkflowInstanceResponse(BaseModel):
|
|||||||
definition_name: str
|
definition_name: str
|
||||||
definition_revision: int
|
definition_revision: int
|
||||||
definition_hash: str
|
definition_hash: str
|
||||||
|
execution_mode: WorkflowExecutionMode
|
||||||
|
start_origin: WorkflowStartOrigin
|
||||||
|
view_context: WorkflowViewContextResponse | None = None
|
||||||
status: WorkflowInstanceStatus
|
status: WorkflowInstanceStatus
|
||||||
idempotency_key: str
|
idempotency_key: str
|
||||||
correlation_id: str | None
|
correlation_id: str | None
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -16,11 +17,31 @@ from govoplan_workflow.backend.db.models import (
|
|||||||
WorkflowDefinitionRevision,
|
WorkflowDefinitionRevision,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
|
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 (
|
from govoplan_workflow.backend.governance import (
|
||||||
definition_governance_payload,
|
definition_governance_payload,
|
||||||
require_definition_action,
|
require_definition_action,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.schemas import (
|
from govoplan_workflow.backend.schemas import (
|
||||||
|
BpmnRevisionInput,
|
||||||
|
BpmnRevisionSummaryResponse,
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
WorkflowDefinitionDeriveRequest,
|
WorkflowDefinitionDeriveRequest,
|
||||||
WorkflowDefinitionResponse,
|
WorkflowDefinitionResponse,
|
||||||
@@ -52,6 +73,28 @@ class WorkflowValidationError(WorkflowError):
|
|||||||
self.diagnostics = diagnostics
|
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(
|
def list_definitions(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -139,7 +182,10 @@ def create_definition(
|
|||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
payload: WorkflowDefinitionCreateRequest,
|
payload: WorkflowDefinitionCreateRequest,
|
||||||
) -> WorkflowDefinition:
|
) -> WorkflowDefinition:
|
||||||
graph = _validated_graph(payload.graph)
|
graph, bpmn = _prepare_revision_content(
|
||||||
|
graph=payload.graph,
|
||||||
|
bpmn=payload.bpmn,
|
||||||
|
)
|
||||||
stored_tenant_id = (
|
stored_tenant_id = (
|
||||||
None if payload.scope_type == "system" else tenant_id
|
None if payload.scope_type == "system" else tenant_id
|
||||||
)
|
)
|
||||||
@@ -185,6 +231,10 @@ def create_definition(
|
|||||||
tenant_id=stored_tenant_id,
|
tenant_id=stored_tenant_id,
|
||||||
revision=1,
|
revision=1,
|
||||||
graph=graph,
|
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,
|
actor_id=actor_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -227,9 +277,11 @@ def update_definition(
|
|||||||
raise WorkflowConflictError(
|
raise WorkflowConflictError(
|
||||||
"Definition kind is immutable; derive a flow or template instead."
|
"Definition kind is immutable; derive a flow or template instead."
|
||||||
)
|
)
|
||||||
graph = _validated_graph(payload.graph)
|
|
||||||
current = get_definition_revision(session, definition=definition)
|
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.name = payload.name.strip()
|
||||||
definition.description = _clean_optional(payload.description)
|
definition.description = _clean_optional(payload.description)
|
||||||
definition.metadata_ = dict(payload.metadata)
|
definition.metadata_ = dict(payload.metadata)
|
||||||
@@ -250,13 +302,24 @@ def update_definition(
|
|||||||
payload.allow_automation and ancestor_limits["allow_automation"]
|
payload.allow_automation and ancestor_limits["allow_automation"]
|
||||||
)
|
)
|
||||||
definition.updated_by = actor_id
|
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.current_revision += 1
|
||||||
definition.revisions.append(
|
definition.revisions.append(
|
||||||
_new_revision(
|
_new_revision(
|
||||||
tenant_id=definition.tenant_id,
|
tenant_id=definition.tenant_id,
|
||||||
revision=definition.current_revision,
|
revision=definition.current_revision,
|
||||||
graph=graph,
|
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,
|
actor_id=actor_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -341,6 +404,30 @@ def derive_definition(
|
|||||||
"derived_by": actor_id,
|
"derived_by": actor_id,
|
||||||
"derived_at": utcnow().isoformat(),
|
"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(
|
definition = WorkflowDefinition(
|
||||||
tenant_id=stored_tenant_id,
|
tenant_id=stored_tenant_id,
|
||||||
scope_type=payload.scope_type,
|
scope_type=payload.scope_type,
|
||||||
@@ -375,10 +462,25 @@ def derive_definition(
|
|||||||
tenant_id=stored_tenant_id,
|
tenant_id=stored_tenant_id,
|
||||||
revision=1,
|
revision=1,
|
||||||
schema_version=source_revision.schema_version,
|
schema_version=source_revision.schema_version,
|
||||||
graph=dict(source_revision.graph),
|
graph=_canonical_graph(source_graph),
|
||||||
content_hash=source_revision.content_hash,
|
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_id=source_revision.library_id,
|
||||||
library_version=source_revision.library_version,
|
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,
|
created_by=actor_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -410,6 +512,8 @@ def activate_definition(
|
|||||||
"Workflow templates cannot be activated or started."
|
"Workflow templates cannot be activated or started."
|
||||||
)
|
)
|
||||||
_validated_graph(WorkflowGraph.model_validate(selected.graph))
|
_validated_graph(WorkflowGraph.model_validate(selected.graph))
|
||||||
|
_validate_bpmn_activation(selected)
|
||||||
|
_validate_execution_mode_activation(selected)
|
||||||
definition.active_revision = selected.revision
|
definition.active_revision = selected.revision
|
||||||
definition.status = "active"
|
definition.status = "active"
|
||||||
definition.updated_by = actor_id
|
definition.updated_by = actor_id
|
||||||
@@ -496,10 +600,16 @@ def revision_response(
|
|||||||
id=revision.id,
|
id=revision.id,
|
||||||
revision=revision.revision,
|
revision=revision.revision,
|
||||||
schema_version=revision.schema_version,
|
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,
|
content_hash=revision.content_hash,
|
||||||
library_id=revision.library_id,
|
library_id=revision.library_id,
|
||||||
library_version=revision.library_version,
|
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_by=revision.created_by,
|
||||||
created_at=revision.created_at,
|
created_at=revision.created_at,
|
||||||
)
|
)
|
||||||
@@ -510,6 +620,10 @@ def _new_revision(
|
|||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
revision: int,
|
revision: int,
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
|
bpmn: _BpmnArtifact | None,
|
||||||
|
execution_mode: str,
|
||||||
|
view_id: str | None,
|
||||||
|
view_revision_id: str | None,
|
||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
) -> WorkflowDefinitionRevision:
|
) -> WorkflowDefinitionRevision:
|
||||||
return WorkflowDefinitionRevision(
|
return WorkflowDefinitionRevision(
|
||||||
@@ -517,14 +631,132 @@ def _new_revision(
|
|||||||
revision=revision,
|
revision=revision,
|
||||||
schema_version=graph.schema_version,
|
schema_version=graph.schema_version,
|
||||||
graph=_canonical_graph(graph),
|
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_id=WORKFLOW_GRAPH_LIBRARY.id,
|
||||||
library_version=WORKFLOW_GRAPH_LIBRARY.version,
|
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,
|
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:
|
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)
|
diagnostics = validate_workflow_graph(graph)
|
||||||
if any(item.severity == "error" for item in diagnostics):
|
if any(item.severity == "error" for item in diagnostics):
|
||||||
raise WorkflowValidationError(diagnostics)
|
raise WorkflowValidationError(diagnostics)
|
||||||
@@ -535,15 +767,93 @@ def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]:
|
|||||||
return graph.model_dump(mode="json")
|
return graph.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
def _content_hash(graph: WorkflowGraph) -> str:
|
def _content_hash(
|
||||||
encoded = json.dumps(
|
graph: WorkflowGraph,
|
||||||
_canonical_graph(graph),
|
*,
|
||||||
sort_keys=True,
|
bpmn: _BpmnArtifact | None = None,
|
||||||
separators=(",", ":"),
|
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()
|
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(
|
def _available_key(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -639,6 +949,7 @@ def _effective_governance_limits(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"WorkflowBpmnValidationError",
|
||||||
"WorkflowConflictError",
|
"WorkflowConflictError",
|
||||||
"WorkflowError",
|
"WorkflowError",
|
||||||
"WorkflowNotFoundError",
|
"WorkflowNotFoundError",
|
||||||
@@ -654,5 +965,6 @@ __all__ = [
|
|||||||
"list_definition_revisions",
|
"list_definition_revisions",
|
||||||
"list_definitions",
|
"list_definitions",
|
||||||
"revision_response",
|
"revision_response",
|
||||||
|
"revision_bpmn_summary",
|
||||||
"update_definition",
|
"update_definition",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
|
|||||||
)
|
)
|
||||||
outgoing = {node.id: 0 for node in graph.nodes}
|
outgoing = {node.id: 0 for node in graph.nodes}
|
||||||
for edge in graph.edges:
|
for edge in graph.edges:
|
||||||
if edge.source in outgoing:
|
if edge.type == "bpmn.sequenceFlow" and edge.source in outgoing:
|
||||||
outgoing[edge.source] += 1
|
outgoing[edge.source] += 1
|
||||||
for node in graph.nodes:
|
for node in graph.nodes:
|
||||||
definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type)
|
definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
continue
|
continue
|
||||||
for field in definition.config_fields:
|
for field in definition.config_fields:
|
||||||
|
if node.type.startswith("bpmn."):
|
||||||
|
continue
|
||||||
if field.required and _empty(node.config.get(field.id)):
|
if field.required and _empty(node.config.get(field.id)):
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
DefinitionDiagnostic(
|
DefinitionDiagnostic(
|
||||||
@@ -49,7 +51,7 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
|
|||||||
field=field.id,
|
field=field.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if definition.output_ports and outgoing[node.id] == 0:
|
if _requires_outgoing(node.type) and outgoing[node.id] == 0:
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
DefinitionDiagnostic(
|
DefinitionDiagnostic(
|
||||||
severity="error",
|
severity="error",
|
||||||
@@ -58,9 +60,187 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic,
|
|||||||
node_id=node.id,
|
node_id=node.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
diagnostics.extend(_bpmn_semantic_diagnostics(graph))
|
||||||
|
diagnostics.extend(_legacy_semantic_diagnostics(graph))
|
||||||
return _deduplicate(diagnostics)
|
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:
|
def _empty(value: object) -> bool:
|
||||||
return value is None or value == "" or value == [] or value == {}
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_workflow.backend.bpmn import (
|
from govoplan_workflow.backend.bpmn import (
|
||||||
|
BPMN_MODEL_NAMESPACE,
|
||||||
BpmnInspectionError,
|
BpmnInspectionError,
|
||||||
inspect_bpmn_xml,
|
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>
|
</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):
|
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:
|
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
|
||||||
result = inspect_bpmn_xml(BPMN)
|
result = inspect_bpmn_xml(BPMN)
|
||||||
|
|
||||||
@@ -47,7 +146,7 @@ class BpmnInspectionTests(unittest.TestCase):
|
|||||||
if item.element_id == "Collaboration_1"
|
if item.element_id == "Collaboration_1"
|
||||||
)
|
)
|
||||||
self.assertEqual("native_execution", review.support_level)
|
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:
|
def test_dangling_references_are_reported(self) -> None:
|
||||||
result = inspect_bpmn_xml(
|
result = inspect_bpmn_xml(
|
||||||
@@ -80,6 +179,177 @@ class BpmnInspectionTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
inspect_bpmn_xml("<definitions />")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ from govoplan_workflow.backend.service import (
|
|||||||
derive_definition,
|
derive_definition,
|
||||||
update_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"
|
POLICY_CAPABILITY = "policy.definitionGovernance"
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
PrincipalRef,
|
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 (
|
from govoplan_core.core.dataflows import (
|
||||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||||
DataflowRunDescriptor,
|
DataflowRunDescriptor,
|
||||||
@@ -33,6 +41,7 @@ from govoplan_workflow.backend.instance_service import (
|
|||||||
start_instance,
|
start_instance,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.schemas import (
|
from govoplan_workflow.backend.schemas import (
|
||||||
|
BpmnRevisionInput,
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
WorkflowEdge,
|
WorkflowEdge,
|
||||||
WorkflowGraph,
|
WorkflowGraph,
|
||||||
@@ -40,11 +49,18 @@ from govoplan_workflow.backend.schemas import (
|
|||||||
WorkflowNode,
|
WorkflowNode,
|
||||||
WorkflowStepActionRequest,
|
WorkflowStepActionRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_workflow.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID
|
||||||
from govoplan_workflow.backend.service import (
|
from govoplan_workflow.backend.service import (
|
||||||
WorkflowConflictError,
|
WorkflowConflictError,
|
||||||
activate_definition,
|
activate_definition,
|
||||||
create_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:
|
def principal() -> ApiPrincipal:
|
||||||
@@ -89,6 +105,10 @@ def runtime_graph() -> WorkflowGraph:
|
|||||||
"publication_target_ref": "",
|
"publication_target_ref": "",
|
||||||
"warning_policy": "review",
|
"warning_policy": "review",
|
||||||
"input_mapping": {},
|
"input_mapping": {},
|
||||||
|
"view_surface_ids": [
|
||||||
|
"dataflow.module",
|
||||||
|
"dataflow.route.pipelines",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
WorkflowNode(
|
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:
|
class FakeDataflowLifecycle:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.runs: dict[str, DataflowRunDescriptor] = {}
|
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:
|
class Registry:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
dataflow: FakeDataflowLifecycle,
|
dataflow: FakeDataflowLifecycle,
|
||||||
automation: FakeAutomationProvider | None = None,
|
automation: FakeAutomationProvider | None = None,
|
||||||
|
action: FakeActionProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.dataflow = dataflow
|
self.dataflow = dataflow
|
||||||
self.automation = automation
|
self.automation = automation
|
||||||
|
self.action = action
|
||||||
|
|
||||||
def has_capability(self, name: str) -> bool:
|
def has_capability(self, name: str) -> bool:
|
||||||
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
|
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
|
||||||
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
and self.automation is not None
|
and self.automation is not None
|
||||||
|
) or (
|
||||||
|
name == "test.actions"
|
||||||
|
and self.action is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
def capability(self, name: str):
|
def capability(self, name: str):
|
||||||
@@ -231,6 +371,8 @@ class Registry:
|
|||||||
and self.automation is not None
|
and self.automation is not None
|
||||||
):
|
):
|
||||||
return self.automation
|
return self.automation
|
||||||
|
if name == "test.actions" and self.action is not None:
|
||||||
|
return self.action
|
||||||
raise KeyError(name)
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +400,9 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
|
|||||||
payload=WorkflowDefinitionCreateRequest(
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
name="Monthly governed processing",
|
name="Monthly governed processing",
|
||||||
graph=runtime_graph(),
|
graph=runtime_graph(),
|
||||||
|
execution_mode="hybrid",
|
||||||
|
view_id="view-1",
|
||||||
|
view_revision_id="view-revision-1",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
activate_definition(
|
activate_definition(
|
||||||
@@ -319,8 +464,19 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
|
|||||||
self.assertTrue(was_replayed)
|
self.assertTrue(was_replayed)
|
||||||
self.assertEqual(instance.id, replayed.id)
|
self.assertEqual(instance.id, replayed.id)
|
||||||
self.assertEqual("waiting", instance.status)
|
self.assertEqual("waiting", instance.status)
|
||||||
|
self.assertEqual("user", instance.start_origin)
|
||||||
self.assertEqual(1, len(self.dataflow.requests))
|
self.assertEqual(1, len(self.dataflow.requests))
|
||||||
response = instance_response(self.session, instance)
|
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([1, 2], [step.sequence for step in response.steps])
|
||||||
self.assertEqual("run:1", response.steps[-1].external_ref)
|
self.assertEqual("run:1", response.steps[-1].external_ref)
|
||||||
self.assertGreaterEqual(len(response.events), 4)
|
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:
|
def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None:
|
||||||
instance = self._start()
|
instance = self._start()
|
||||||
self.dataflow.finish("run:1")
|
self.dataflow.finish("run:1")
|
||||||
@@ -477,6 +933,55 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
|
|||||||
instance.authorization_["last_resolution"]["status"],
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"d8f2a5c7e1b4",
|
"f1b7d3e5a9c2",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -43,6 +43,32 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
if name.startswith("workflow_")
|
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:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
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.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
|
||||||
from govoplan_workflow.backend.validation import validate_workflow_graph
|
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:
|
def test_library_has_domain_specific_cycle_policy(self) -> None:
|
||||||
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
|
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
|
||||||
self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -11,6 +12,7 @@ from govoplan_workflow.backend.db.models import (
|
|||||||
WorkflowDefinitionRevision,
|
WorkflowDefinitionRevision,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.schemas import (
|
from govoplan_workflow.backend.schemas import (
|
||||||
|
BpmnRevisionInput,
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
WorkflowDefinitionUpdateRequest,
|
WorkflowDefinitionUpdateRequest,
|
||||||
WorkflowEdge,
|
WorkflowEdge,
|
||||||
@@ -19,6 +21,7 @@ from govoplan_workflow.backend.schemas import (
|
|||||||
WorkflowPosition,
|
WorkflowPosition,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.service import (
|
from govoplan_workflow.backend.service import (
|
||||||
|
WorkflowBpmnValidationError,
|
||||||
WorkflowConflictError,
|
WorkflowConflictError,
|
||||||
WorkflowNotFoundError,
|
WorkflowNotFoundError,
|
||||||
activate_definition,
|
activate_definition,
|
||||||
@@ -29,6 +32,18 @@ from govoplan_workflow.backend.service import (
|
|||||||
list_definitions,
|
list_definitions,
|
||||||
update_definition,
|
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:
|
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:
|
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
|
||||||
definition = self._create()
|
definition = self._create()
|
||||||
with self.assertRaises(WorkflowConflictError):
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+3
-3
@@ -19,9 +19,9 @@
|
|||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": ">=7.18.2 <8",
|
"react-router": ">=8.3.0 <9",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
|
|||||||
+189
-2
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import type {
|
import type {
|
||||||
DefinitionGraph,
|
DefinitionGraph,
|
||||||
|
DefinitionGraphEdge,
|
||||||
DefinitionGraphNode,
|
DefinitionGraphNode,
|
||||||
DefinitionGraphNodeType
|
DefinitionGraphNodeType
|
||||||
} from "@govoplan/core-webui/definition-graph";
|
} from "@govoplan/core-webui/definition-graph";
|
||||||
@@ -13,10 +14,39 @@ import type {
|
|||||||
export type WorkflowStatus = "draft" | "active" | "archived";
|
export type WorkflowStatus = "draft" | "active" | "archived";
|
||||||
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
|
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
|
||||||
export type DefinitionKind = "flow" | "template";
|
export type DefinitionKind = "flow" | "template";
|
||||||
export type WorkflowGraphNode = DefinitionGraphNode;
|
export type WorkflowExecutionMode = "guided" | "automated" | "hybrid";
|
||||||
export type WorkflowGraph = DefinitionGraph & {
|
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;
|
schema_version: 1;
|
||||||
nodes: WorkflowGraphNode[];
|
nodes: WorkflowGraphNode[];
|
||||||
|
edges: WorkflowGraphEdge[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowDiagnostic = {
|
export type WorkflowDiagnostic = {
|
||||||
@@ -29,6 +59,71 @@ export type WorkflowDiagnostic = {
|
|||||||
|
|
||||||
export type WorkflowNodeType = DefinitionGraphNodeType;
|
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 = {
|
export type WorkflowRevision = {
|
||||||
id: string;
|
id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -37,6 +132,10 @@ export type WorkflowRevision = {
|
|||||||
content_hash: string;
|
content_hash: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
library_version: string;
|
library_version: string;
|
||||||
|
execution_mode: WorkflowExecutionMode;
|
||||||
|
view_id?: string | null;
|
||||||
|
view_revision_id?: string | null;
|
||||||
|
bpmn?: BpmnRevisionSummary | null;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
@@ -134,6 +233,15 @@ export type WorkflowInstance = {
|
|||||||
definition_name: string;
|
definition_name: string;
|
||||||
definition_revision: number;
|
definition_revision: number;
|
||||||
definition_hash: string;
|
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;
|
status: WorkflowInstanceStatus;
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
correlation_id?: string | null;
|
correlation_id?: string | null;
|
||||||
@@ -157,6 +265,11 @@ export type WorkflowDefinitionPayload = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
graph: WorkflowGraph;
|
graph: WorkflowGraph;
|
||||||
|
bpmn?: {
|
||||||
|
xml: string;
|
||||||
|
adapter_id: string;
|
||||||
|
adapter_version?: string | null;
|
||||||
|
} | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
scope_type: DefinitionScopeType;
|
scope_type: DefinitionScopeType;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
@@ -165,8 +278,68 @@ export type WorkflowDefinitionPayload = {
|
|||||||
allow_start: boolean;
|
allow_start: boolean;
|
||||||
allow_reuse: boolean;
|
allow_reuse: boolean;
|
||||||
allow_automation: 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(
|
export async function listWorkflowNodeTypes(
|
||||||
settings: ApiSettings
|
settings: ApiSettings
|
||||||
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
|
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
|
||||||
@@ -224,6 +397,9 @@ export function deriveWorkflowDefinition(
|
|||||||
allow_start: boolean;
|
allow_start: boolean;
|
||||||
allow_reuse: boolean;
|
allow_reuse: boolean;
|
||||||
allow_automation: boolean;
|
allow_automation: boolean;
|
||||||
|
execution_mode?: WorkflowExecutionMode | null;
|
||||||
|
view_id?: string | null;
|
||||||
|
view_revision_id?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<WorkflowDefinition> {
|
): Promise<WorkflowDefinition> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
@@ -244,6 +420,17 @@ export async function listWorkflowRevisions(
|
|||||||
return response.revisions;
|
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(
|
export function activateWorkflowDefinition(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
definitionId: string,
|
definitionId: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, type DragEvent } from "react";
|
import { useMemo, useRef, useState, type DragEvent } from "react";
|
||||||
import {
|
import {
|
||||||
addEdge,
|
addEdge,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
ConnectionLineType,
|
ConnectionLineType,
|
||||||
Controls,
|
Controls,
|
||||||
|
MarkerType,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
|
reconnectEdge,
|
||||||
type Connection,
|
type Connection,
|
||||||
type Edge,
|
type Edge,
|
||||||
type ReactFlowInstance
|
type ReactFlowInstance
|
||||||
@@ -17,6 +19,7 @@ import { definitionConnectionError } from "@govoplan/core-webui/definition-graph
|
|||||||
import type {
|
import type {
|
||||||
WorkflowDiagnostic,
|
WorkflowDiagnostic,
|
||||||
WorkflowGraph,
|
WorkflowGraph,
|
||||||
|
WorkflowGraphEdge,
|
||||||
WorkflowGraphNode,
|
WorkflowGraphNode,
|
||||||
WorkflowNodeType
|
WorkflowNodeType
|
||||||
} from "../../api/workflow";
|
} from "../../api/workflow";
|
||||||
@@ -30,23 +33,29 @@ export default function WorkflowCanvas({
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
nodeLibrary,
|
nodeLibrary,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
|
selectedEdgeId,
|
||||||
readOnly,
|
readOnly,
|
||||||
allowsCycles,
|
allowsCycles,
|
||||||
onGraphChange,
|
onGraphChange,
|
||||||
onSelectNode
|
onSelectNode,
|
||||||
|
onSelectEdge
|
||||||
}: {
|
}: {
|
||||||
graph: WorkflowGraph;
|
graph: WorkflowGraph;
|
||||||
diagnostics: WorkflowDiagnostic[];
|
diagnostics: WorkflowDiagnostic[];
|
||||||
nodeLibrary: WorkflowNodeType[];
|
nodeLibrary: WorkflowNodeType[];
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
|
selectedEdgeId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
allowsCycles: boolean;
|
allowsCycles: boolean;
|
||||||
onGraphChange: (graph: WorkflowGraph) => void;
|
onGraphChange: (graph: WorkflowGraph) => void;
|
||||||
onSelectNode: (nodeId: string | null) => void;
|
onSelectNode: (nodeId: string | null) => void;
|
||||||
|
onSelectEdge: (edgeId: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [instance, setInstance] = useState<
|
const [instance, setInstance] = useState<
|
||||||
ReactFlowInstance<WorkflowFlowNode, Edge> | null
|
ReactFlowInstance<WorkflowFlowNode, Edge> | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const reconnectSuccessful = useRef(true);
|
||||||
|
const reconnectingEdgeId = useRef<string | null>(null);
|
||||||
const definitions = useMemo(
|
const definitions = useMemo(
|
||||||
() => new Map(nodeLibrary.map((item) => [item.type, item])),
|
() => new Map(nodeLibrary.map((item) => [item.type, item])),
|
||||||
[nodeLibrary]
|
[nodeLibrary]
|
||||||
@@ -67,8 +76,8 @@ export default function WorkflowCanvas({
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
type: "workflow" as const,
|
type: "workflow" as const,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
initialWidth: 190,
|
initialWidth: canvasNodeSize(node, definition).width,
|
||||||
initialHeight: 56,
|
initialHeight: canvasNodeSize(node, definition).height,
|
||||||
selected: node.id === selectedNodeId,
|
selected: node.id === selectedNodeId,
|
||||||
data: {
|
data: {
|
||||||
label: node.label,
|
label: node.label,
|
||||||
@@ -88,13 +97,34 @@ export default function WorkflowCanvas({
|
|||||||
sourceHandle: edge.source_port ?? "output",
|
sourceHandle: edge.source_port ?? "output",
|
||||||
targetHandle: edge.target_port ?? "input",
|
targetHandle: edge.target_port ?? "input",
|
||||||
type: "smoothstep",
|
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 updateNodes = (nextNodes: WorkflowFlowNode[]) => {
|
||||||
const ids = new Set(nextNodes.map((node) => node.id));
|
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({
|
onGraphChange({
|
||||||
...graph,
|
...graph,
|
||||||
nodes: nextNodes.map((flowNode) => {
|
nodes: nextNodes.map((flowNode) => {
|
||||||
@@ -104,6 +134,10 @@ export default function WorkflowCanvas({
|
|||||||
}),
|
}),
|
||||||
edges: graph.edges.filter(
|
edges: graph.edges.filter(
|
||||||
(edge) => ids.has(edge.source) && ids.has(edge.target)
|
(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[]) => {
|
const updateEdges = (nextEdges: Edge[]) => {
|
||||||
onGraphChange({
|
onGraphChange({
|
||||||
...graph,
|
...graph,
|
||||||
edges: nextEdges.map((edge) => ({
|
edges: nextEdges.map((edge) => {
|
||||||
id: edge.id,
|
const current = graph.edges.find((item) => item.id === edge.id);
|
||||||
source: edge.source,
|
const endpointsChanged = Boolean(
|
||||||
target: edge.target,
|
current
|
||||||
source_port: edge.sourceHandle ?? "output",
|
&& (
|
||||||
target_port: edge.targetHandle ?? "input"
|
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 => {
|
const isValidConnection = (connection: Connection | Edge): boolean => {
|
||||||
if (readOnly || !connection.source || !connection.target) return false;
|
if (readOnly || !connection.source || !connection.target) return false;
|
||||||
return definitionConnectionError(
|
return definitionConnectionError(
|
||||||
graph,
|
reconnectingEdgeId.current
|
||||||
|
? {
|
||||||
|
...graph,
|
||||||
|
edges: graph.edges.filter(
|
||||||
|
(edge) => edge.id !== reconnectingEdgeId.current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: graph,
|
||||||
nodeLibrary,
|
nodeLibrary,
|
||||||
{
|
{
|
||||||
source: connection.source,
|
source: connection.source,
|
||||||
@@ -182,21 +239,66 @@ export default function WorkflowCanvas({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEdgesChange={(changes) => {
|
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) => {
|
onConnect={(connection) => {
|
||||||
if (!isValidConnection(connection)) return;
|
if (!isValidConnection(connection)) return;
|
||||||
updateEdges(addEdge({
|
updateEdges(addEdge({
|
||||||
...connection,
|
...connection,
|
||||||
id: `edge-${crypto.randomUUID()}`,
|
id: `edge-${crypto.randomUUID()}`,
|
||||||
type: "smoothstep"
|
type: "smoothstep"
|
||||||
}, edges));
|
}, 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}
|
isValidConnection={isValidConnection}
|
||||||
onNodeClick={(_event, node) => onSelectNode(node.id)}
|
onNodeClick={(_event, node) => {
|
||||||
onPaneClick={() => onSelectNode(null)}
|
onSelectEdge(null);
|
||||||
|
onSelectNode(node.id);
|
||||||
|
}}
|
||||||
|
onEdgeClick={(_event, edge) => {
|
||||||
|
onSelectNode(null);
|
||||||
|
onSelectEdge(edge.id);
|
||||||
|
}}
|
||||||
|
onPaneClick={() => {
|
||||||
|
onSelectNode(null);
|
||||||
|
onSelectEdge(null);
|
||||||
|
}}
|
||||||
nodesDraggable={!readOnly}
|
nodesDraggable={!readOnly}
|
||||||
nodesConnectable={!readOnly}
|
nodesConnectable={!readOnly}
|
||||||
|
edgesReconnectable={!readOnly}
|
||||||
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
|
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
|
||||||
connectionLineType={ConnectionLineType.SmoothStep}
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
|
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(
|
export function updateWorkflowGraphNode(
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
updatedNode: WorkflowGraphNode
|
updatedNode: WorkflowGraphNode
|
||||||
|
|||||||
@@ -1,30 +1,64 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField
|
FormField,
|
||||||
|
ReferenceMultiSelect,
|
||||||
|
useViewSurfaces,
|
||||||
|
type ReferenceOptionProvider
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import type {
|
import type {
|
||||||
|
WorkflowGraphEdge,
|
||||||
WorkflowGraphNode,
|
WorkflowGraphNode,
|
||||||
WorkflowNodeType
|
WorkflowNodeType
|
||||||
} from "../../api/workflow";
|
} from "../../api/workflow";
|
||||||
|
|
||||||
export default function WorkflowInspector({
|
export default function WorkflowInspector({
|
||||||
node,
|
node,
|
||||||
|
edge,
|
||||||
nodeLibrary,
|
nodeLibrary,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete
|
onDelete,
|
||||||
|
onEdgeChange,
|
||||||
|
onEdgeDelete
|
||||||
}: {
|
}: {
|
||||||
node: WorkflowGraphNode | null;
|
node: WorkflowGraphNode | null;
|
||||||
|
edge: WorkflowGraphEdge | null;
|
||||||
nodeLibrary: WorkflowNodeType[];
|
nodeLibrary: WorkflowNodeType[];
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onChange: (node: WorkflowGraphNode) => void;
|
onChange: (node: WorkflowGraphNode) => void;
|
||||||
onDelete: (nodeId: string) => void;
|
onDelete: (nodeId: string) => void;
|
||||||
|
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
||||||
|
onEdgeDelete: (edgeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
||||||
const [error, setError] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -44,6 +78,109 @@ export default function WorkflowInspector({
|
|||||||
setError("");
|
setError("");
|
||||||
}, [node?.id, nodeLibrary]);
|
}, [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) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||||
@@ -158,6 +295,16 @@ export default function WorkflowInspector({
|
|||||||
)}
|
)}
|
||||||
disabled={readOnly}
|
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
|
<input
|
||||||
value={textValue(node.config[field.id])}
|
value={textValue(node.config[field.id])}
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import {
|
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,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CirclePlay,
|
CirclePlay,
|
||||||
@@ -41,7 +70,36 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
split: Split,
|
split: Split,
|
||||||
"square-check-big": SquareCheckBig,
|
"square-check-big": SquareCheckBig,
|
||||||
timer: Timer,
|
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({
|
export default function WorkflowNode({
|
||||||
@@ -50,6 +108,7 @@ export default function WorkflowNode({
|
|||||||
isConnectable
|
isConnectable
|
||||||
}: NodeProps<WorkflowFlowNode>) {
|
}: NodeProps<WorkflowFlowNode>) {
|
||||||
const Icon = iconByName[data.definition.icon] ?? GitFork;
|
const Icon = iconByName[data.definition.icon] ?? GitFork;
|
||||||
|
const shape = String(data.definition.metadata?.shape ?? "activity");
|
||||||
const inputPorts = data.definition.input_ports;
|
const inputPorts = data.definition.input_ports;
|
||||||
const outputPorts = data.definition.output_ports;
|
const outputPorts = data.definition.output_ports;
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +116,7 @@ export default function WorkflowNode({
|
|||||||
className={[
|
className={[
|
||||||
"workflow-node",
|
"workflow-node",
|
||||||
`workflow-node-${data.definition.category}`,
|
`workflow-node-${data.definition.category}`,
|
||||||
|
`workflow-node-shape-${shape}`,
|
||||||
selected ? "is-selected" : "",
|
selected ? "is-selected" : "",
|
||||||
data.hasError ? "has-error" : ""
|
data.hasError ? "has-error" : ""
|
||||||
].filter(Boolean).join(" ")}
|
].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,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent
|
type DragEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CopyPlus,
|
CopyPlus,
|
||||||
|
Download,
|
||||||
GitFork,
|
GitFork,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -16,9 +18,11 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Settings2,
|
Settings2,
|
||||||
Trash2
|
Trash2,
|
||||||
|
Upload
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ReactFlowProvider } from "@xyflow/react";
|
import { ReactFlowProvider } from "@xyflow/react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
@@ -34,23 +38,27 @@ import {
|
|||||||
isApiError,
|
isApiError,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
|
useEffectiveView,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
activateWorkflowDefinition,
|
activateWorkflowDefinition,
|
||||||
archiveWorkflowDefinition,
|
archiveWorkflowDefinition,
|
||||||
|
compileWorkflowBpmn,
|
||||||
createWorkflowDefinition,
|
createWorkflowDefinition,
|
||||||
deleteWorkflowDefinition,
|
deleteWorkflowDefinition,
|
||||||
deriveWorkflowDefinition,
|
deriveWorkflowDefinition,
|
||||||
listWorkflowDefinitions,
|
listWorkflowDefinitions,
|
||||||
listWorkflowNodeTypes,
|
listWorkflowNodeTypes,
|
||||||
listWorkflowRevisions,
|
listWorkflowRevisions,
|
||||||
|
renderWorkflowBpmn,
|
||||||
updateWorkflowDefinition,
|
updateWorkflowDefinition,
|
||||||
validateWorkflowDefinition,
|
validateWorkflowDefinition,
|
||||||
workflowScopeReferenceProvider,
|
workflowScopeReferenceProvider,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
type WorkflowDiagnostic,
|
type WorkflowDiagnostic,
|
||||||
|
type WorkflowGraphEdge,
|
||||||
type WorkflowNodeType,
|
type WorkflowNodeType,
|
||||||
type WorkflowRevision
|
type WorkflowRevision
|
||||||
} from "../../api/workflow";
|
} from "../../api/workflow";
|
||||||
@@ -69,12 +77,12 @@ import {
|
|||||||
} from "./model";
|
} from "./model";
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"trigger",
|
"bpmn_event",
|
||||||
"activity",
|
"bpmn_activity",
|
||||||
"decision",
|
"bpmn_gateway",
|
||||||
"wait",
|
"bpmn_data",
|
||||||
"integration",
|
"bpmn_collaboration",
|
||||||
"outcome"
|
"bpmn_artifact"
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkflowPage({
|
export default function WorkflowPage({
|
||||||
@@ -85,6 +93,9 @@ export default function WorkflowPage({
|
|||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
}) {
|
}) {
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestNavigation } = useUnsavedChanges();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const requestedDefinitionId = searchParams.get("definition");
|
||||||
|
const requestedRunId = searchParams.get("run");
|
||||||
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
|
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
|
||||||
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
|
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
|
||||||
const [savedDraft, setSavedDraft] = 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 [allowsCycles, setAllowsCycles] = useState(true);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
@@ -106,6 +118,7 @@ export default function WorkflowPage({
|
|||||||
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
||||||
const [deriveOpen, setDeriveOpen] = useState(false);
|
const [deriveOpen, setDeriveOpen] = useState(false);
|
||||||
const [runsOpen, setRunsOpen] = useState(false);
|
const [runsOpen, setRunsOpen] = useState(false);
|
||||||
|
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const canWrite = hasScope(auth, "workflow:definition:write")
|
const canWrite = hasScope(auth, "workflow:definition:write")
|
||||||
|| hasScope(auth, "workflow:instance:admin");
|
|| hasScope(auth, "workflow:instance:admin");
|
||||||
@@ -133,10 +146,15 @@ export default function WorkflowPage({
|
|||||||
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
||||||
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
||||||
const readOnly = !canEdit || historicalRevision !== null;
|
const readOnly = !canEdit || historicalRevision !== null;
|
||||||
|
const graphReadOnly = readOnly;
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||||
[displayedGraph, selectedNodeId]
|
[displayedGraph, selectedNodeId]
|
||||||
);
|
);
|
||||||
|
const selectedEdge = useMemo(
|
||||||
|
() => displayedGraph?.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
|
||||||
|
[displayedGraph, selectedEdgeId]
|
||||||
|
);
|
||||||
const visibleDefinitions = useMemo(() => {
|
const visibleDefinitions = useMemo(() => {
|
||||||
const query = search.trim().toLocaleLowerCase();
|
const query = search.trim().toLocaleLowerCase();
|
||||||
if (!query) return definitions;
|
if (!query) return definitions;
|
||||||
@@ -162,6 +180,7 @@ export default function WorkflowPage({
|
|||||||
setSavedDraft(structuredClone(next));
|
setSavedDraft(structuredClone(next));
|
||||||
setHistoricalRevision(null);
|
setHistoricalRevision(null);
|
||||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -189,7 +208,7 @@ export default function WorkflowPage({
|
|||||||
}, [applyDefinition, settings]);
|
}, [applyDefinition, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload(requestedDefinitionId);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void listWorkflowNodeTypes(settings)
|
void listWorkflowNodeTypes(settings)
|
||||||
.then((library) => {
|
.then((library) => {
|
||||||
@@ -203,7 +222,17 @@ export default function WorkflowPage({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [reload, settings]);
|
}, [reload, requestedDefinitionId, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
requestedRunId
|
||||||
|
&& draft?.id
|
||||||
|
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
|
||||||
|
) {
|
||||||
|
setRunsOpen(true);
|
||||||
|
}
|
||||||
|
}, [draft?.id, requestedDefinitionId, requestedRunId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!draft?.id) {
|
if (!draft?.id) {
|
||||||
@@ -232,6 +261,7 @@ export default function WorkflowPage({
|
|||||||
setDraft(next);
|
setDraft(next);
|
||||||
setHistoricalRevision(null);
|
setHistoricalRevision(null);
|
||||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
@@ -305,6 +335,7 @@ export default function WorkflowPage({
|
|||||||
setRevisions([]);
|
setRevisions([]);
|
||||||
setHistoricalRevision(null);
|
setHistoricalRevision(null);
|
||||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
@@ -315,10 +346,11 @@ export default function WorkflowPage({
|
|||||||
if (!displayedGraph) return;
|
if (!displayedGraph) return;
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
try {
|
try {
|
||||||
const result = await validateWorkflowDefinition(settings, displayedGraph);
|
const result = await validateWorkflowDefinition(settings, displayedGraph);
|
||||||
setDiagnostics(result.diagnostics);
|
setDiagnostics(result.diagnostics);
|
||||||
setSuccess(result.valid ? "Workflow definition is valid." : "");
|
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
setError(apiErrorMessage(validationError));
|
setError(apiErrorMessage(validationError));
|
||||||
} finally {
|
} 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 () => {
|
const activate = async () => {
|
||||||
if (!draft?.id || dirty) return;
|
if (!draft?.id || dirty) return;
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
@@ -382,14 +481,14 @@ export default function WorkflowPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateGraph = (graph: WorkflowDraft["graph"]) => {
|
const updateGraph = (graph: WorkflowDraft["graph"]) => {
|
||||||
if (readOnly) return;
|
if (graphReadOnly) return;
|
||||||
setDraft((current) => current ? { ...current, graph } : current);
|
setDraft((current) => current ? { ...current, graph } : current);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
setSuccess("");
|
setSuccess("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeNode = (nodeId: string) => {
|
const removeNode = (nodeId: string) => {
|
||||||
if (!draft || readOnly) return;
|
if (!draft || graphReadOnly) return;
|
||||||
updateGraph({
|
updateGraph({
|
||||||
...draft.graph,
|
...draft.graph,
|
||||||
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
|
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
|
||||||
@@ -400,6 +499,25 @@ export default function WorkflowPage({
|
|||||||
setSelectedNodeId(null);
|
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 (
|
return (
|
||||||
<main className="workflow-page">
|
<main className="workflow-page">
|
||||||
<div className="workflow-shell">
|
<div className="workflow-shell">
|
||||||
@@ -501,21 +619,9 @@ export default function WorkflowPage({
|
|||||||
?? draft.currentRevision
|
?? draft.currentRevision
|
||||||
?? 1
|
?? 1
|
||||||
}
|
}
|
||||||
onChange={(event) => {
|
onChange={(event) => selectRevision(
|
||||||
const revision = Number(event.target.value);
|
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([]);
|
|
||||||
}}
|
|
||||||
aria-label="Workflow revision"
|
aria-label="Workflow revision"
|
||||||
>
|
>
|
||||||
{revisions.map((revision) => (
|
{revisions.map((revision) => (
|
||||||
@@ -533,7 +639,11 @@ export default function WorkflowPage({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
graph: structuredClone(historicalRevision.graph)
|
graph: structuredClone(historicalRevision.graph),
|
||||||
|
executionMode: historicalRevision.execution_mode,
|
||||||
|
viewId: historicalRevision.view_id ?? "",
|
||||||
|
viewRevisionId:
|
||||||
|
historicalRevision.view_revision_id ?? ""
|
||||||
});
|
});
|
||||||
setHistoricalRevision(null);
|
setHistoricalRevision(null);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
@@ -543,6 +653,30 @@ export default function WorkflowPage({
|
|||||||
<RotateCcw size={16} /> Restore
|
<RotateCcw size={16} /> Restore
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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}>
|
<Button onClick={() => void validate()} disabled={working}>
|
||||||
<CheckCircle2 size={16} /> Validate
|
<CheckCircle2 size={16} /> Validate
|
||||||
</Button>
|
</Button>
|
||||||
@@ -600,7 +734,9 @@ export default function WorkflowPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void saveDraft()}
|
onClick={() => void saveDraft()}
|
||||||
disabled={!canEdit || !dirty || working || readOnly}
|
disabled={
|
||||||
|
!canEdit || !dirty || working || readOnly
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Save size={16} /> Save
|
<Save size={16} /> Save
|
||||||
</Button>
|
</Button>
|
||||||
@@ -628,93 +764,98 @@ export default function WorkflowPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="workflow-editor">
|
<div className="workflow-editor">
|
||||||
<aside className="workflow-palette">
|
<aside className="workflow-palette">
|
||||||
<div className="workflow-panel-heading">
|
<div className="workflow-panel-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Node library</strong>
|
<strong>Node library</strong>
|
||||||
<small>Drag nodes onto the canvas</small>
|
<small>Drag nodes onto the canvas</small>
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
|
||||||
{working ? (
|
{working ? (
|
||||||
<div className="workflow-working-indicator" role="status">
|
<div className="workflow-working-indicator" role="status">
|
||||||
Working...
|
Working...
|
||||||
@@ -775,9 +916,17 @@ export default function WorkflowPage({
|
|||||||
open={runsOpen}
|
open={runsOpen}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
definition={selectedDefinition}
|
definition={selectedDefinition}
|
||||||
|
initialInstanceId={requestedRunId}
|
||||||
canStart={canStart}
|
canStart={canStart}
|
||||||
canTransition={canTransition}
|
canTransition={canTransition}
|
||||||
onClose={() => setRunsOpen(false)}
|
onClose={() => {
|
||||||
|
setRunsOpen(false);
|
||||||
|
if (requestedRunId) {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete("run");
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -799,6 +948,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
|
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
|
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
|
||||||
const scopeProvider = useMemo(
|
const scopeProvider = useMemo(
|
||||||
() => workflowScopeReferenceProvider(settings, referenceScopeType),
|
() => workflowScopeReferenceProvider(settings, referenceScopeType),
|
||||||
@@ -881,6 +1031,61 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
<option value="template">Template</option>
|
<option value="template">Template</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</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">
|
<div className="workflow-definition-toggles">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Visible to lower scopes"
|
label="Visible to lower scopes"
|
||||||
@@ -903,7 +1108,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Allow automation"
|
label="Allow automation"
|
||||||
checked={draft.allowAutomation}
|
checked={draft.allowAutomation}
|
||||||
disabled={!editable}
|
disabled={!editable || draft.executionMode === "guided"}
|
||||||
onChange={(value) => onChange({ allowAutomation: value })}
|
onChange={(value) => onChange({ allowAutomation: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1094,6 +1299,14 @@ function startPaletteDrag(
|
|||||||
event.dataTransfer.effectAllowed = "copy";
|
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 {
|
function apiErrorMessage(error: unknown): string {
|
||||||
if (!isApiError(error)) {
|
if (!isApiError(error)) {
|
||||||
return error instanceof Error ? error.message : "The request failed.";
|
return error instanceof Error ? error.message : "The request failed.";
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
StageRail,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
type ApiSettings
|
dispatchWorkflowViewChanged,
|
||||||
|
usePlatformUiCapability,
|
||||||
|
type StageRailTone,
|
||||||
|
type ApiSettings,
|
||||||
|
type ViewsRuntimeUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
cancelWorkflowInstance,
|
cancelWorkflowInstance,
|
||||||
@@ -50,6 +55,7 @@ export default function WorkflowRunsDialog({
|
|||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
definition,
|
definition,
|
||||||
|
initialInstanceId,
|
||||||
canStart,
|
canStart,
|
||||||
canTransition,
|
canTransition,
|
||||||
onClose
|
onClose
|
||||||
@@ -57,6 +63,7 @@ export default function WorkflowRunsDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
definition: WorkflowDefinition | null;
|
definition: WorkflowDefinition | null;
|
||||||
|
initialInstanceId?: string | null;
|
||||||
canStart: boolean;
|
canStart: boolean;
|
||||||
canTransition: boolean;
|
canTransition: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -69,6 +76,9 @@ export default function WorkflowRunsDialog({
|
|||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [evidence, setEvidence] = useState("");
|
const [evidence, setEvidence] = useState("");
|
||||||
const [cancelOpen, setCancelOpen] = useState(false);
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
|
||||||
|
"views.runtime"
|
||||||
|
);
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
|
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
|
||||||
@@ -99,7 +109,10 @@ export default function WorkflowRunsDialog({
|
|||||||
const items = await listWorkflowInstances(settings, definition.id);
|
const items = await listWorkflowInstances(settings, definition.id);
|
||||||
setInstances(items);
|
setInstances(items);
|
||||||
setSelectedId((current) => (
|
setSelectedId((current) => (
|
||||||
items.some((item) => item.id === current)
|
initialInstanceId
|
||||||
|
&& items.some((item) => item.id === initialInstanceId)
|
||||||
|
? initialInstanceId
|
||||||
|
: items.some((item) => item.id === current)
|
||||||
? current
|
? current
|
||||||
: items[0]?.id ?? null
|
: items[0]?.id ?? null
|
||||||
));
|
));
|
||||||
@@ -108,7 +121,7 @@ export default function WorkflowRunsDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [definition?.id, open, settings]);
|
}, [definition?.id, initialInstanceId, open, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -117,6 +130,39 @@ export default function WorkflowRunsDialog({
|
|||||||
void load();
|
void load();
|
||||||
}, [load, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!open
|
!open
|
||||||
@@ -236,6 +282,10 @@ export default function WorkflowRunsDialog({
|
|||||||
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
||||||
? currentStep.handoff.action_url
|
? currentStep.handoff.action_url
|
||||||
: "";
|
: "";
|
||||||
|
const close = () => {
|
||||||
|
dispatchWorkflowViewChanged(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -244,8 +294,8 @@ export default function WorkflowRunsDialog({
|
|||||||
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
|
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
|
||||||
className="workflow-runs-dialog"
|
className="workflow-runs-dialog"
|
||||||
bodyClassName="workflow-runs-dialog-body"
|
bodyClassName="workflow-runs-dialog-body"
|
||||||
onClose={onClose}
|
onClose={close}
|
||||||
footer={<Button onClick={onClose}>Close</Button>}
|
footer={<Button onClick={close}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="workflow-runs-toolbar">
|
<div className="workflow-runs-toolbar">
|
||||||
<span>
|
<span>
|
||||||
@@ -419,40 +469,26 @@ export default function WorkflowRunsDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
<section className="workflow-run-history">
|
<section className="workflow-run-history">
|
||||||
<h3>Progress</h3>
|
<h3>Progress</h3>
|
||||||
<div>
|
<StageRail
|
||||||
{selected.steps.map((step) => (
|
ariaLabel="Workflow instance progress"
|
||||||
<div
|
items={selected.steps.map((step) => ({
|
||||||
key={step.id}
|
id: step.id,
|
||||||
className="workflow-run-stage"
|
label: step.node_id,
|
||||||
data-state={step.status}
|
detail: `${step.node_type} · attempt ${step.attempt}`,
|
||||||
data-current={
|
statusLabel: step.status,
|
||||||
step.id === selected.current_step_id || undefined
|
current: step.id === selected.current_step_id,
|
||||||
}
|
tone: stepTone(step.status),
|
||||||
>
|
icon: step.status === "completed" ? (
|
||||||
<span className="workflow-run-stage-marker">
|
<Check size={15} aria-hidden="true" />
|
||||||
{step.status === "completed" ? (
|
) : step.status === "failed" ? (
|
||||||
<Check size={15} aria-hidden="true" />
|
<AlertTriangle size={15} aria-hidden="true" />
|
||||||
) : step.status === "failed" ? (
|
) : ["running", "waiting"].includes(step.status) ? (
|
||||||
<AlertTriangle size={15} aria-hidden="true" />
|
<Clock3 size={15} aria-hidden="true" />
|
||||||
) : ["running", "waiting"].includes(step.status) ? (
|
) : (
|
||||||
<Clock3 size={15} aria-hidden="true" />
|
<Circle size={13} aria-hidden="true" />
|
||||||
) : (
|
)
|
||||||
<Circle size={13} aria-hidden="true" />
|
}))}
|
||||||
)}
|
/>
|
||||||
</span>
|
|
||||||
<span className="workflow-run-stage-copy">
|
|
||||||
<strong>{step.node_id}</strong>
|
|
||||||
<small>
|
|
||||||
{step.node_type} · attempt {step.attempt}
|
|
||||||
</small>
|
|
||||||
<StatusBadge
|
|
||||||
status={step.status}
|
|
||||||
label={step.status}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<section className="workflow-run-events">
|
<section className="workflow-run-events">
|
||||||
<h3>Evidence trail</h3>
|
<h3>Evidence trail</h3>
|
||||||
@@ -530,6 +566,15 @@ function actionLabel(action: WorkflowAction): string {
|
|||||||
}[action];
|
}[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 {
|
function formatDateTime(value: string): string {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
WorkflowGovernance,
|
WorkflowGovernance,
|
||||||
WorkflowGraph,
|
WorkflowGraph,
|
||||||
WorkflowGraphNode,
|
WorkflowGraphNode,
|
||||||
|
WorkflowExecutionMode,
|
||||||
WorkflowNodeType,
|
WorkflowNodeType,
|
||||||
WorkflowStatus
|
WorkflowStatus
|
||||||
} from "../../api/workflow";
|
} from "../../api/workflow";
|
||||||
@@ -27,44 +28,53 @@ export type WorkflowDraft = {
|
|||||||
allowStart: boolean;
|
allowStart: boolean;
|
||||||
allowReuse: boolean;
|
allowReuse: boolean;
|
||||||
allowAutomation: boolean;
|
allowAutomation: boolean;
|
||||||
|
executionMode: WorkflowExecutionMode;
|
||||||
|
viewId: string;
|
||||||
|
viewRevisionId: string;
|
||||||
governance: WorkflowGovernance | null;
|
governance: WorkflowGovernance | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputPort = [{
|
const inputPort = [{
|
||||||
id: "input",
|
id: "incoming",
|
||||||
label: "Input",
|
label: "Incoming",
|
||||||
required: true,
|
required: true,
|
||||||
multiple: false,
|
multiple: true,
|
||||||
minimum_connections: 1
|
minimum_connections: 1
|
||||||
}];
|
}];
|
||||||
const outputPort = [{
|
const outputPort = [{
|
||||||
id: "output",
|
id: "outgoing",
|
||||||
label: "Output",
|
label: "Outgoing",
|
||||||
required: true,
|
required: false,
|
||||||
multiple: false,
|
multiple: true,
|
||||||
minimum_connections: 1
|
minimum_connections: 0
|
||||||
}];
|
}];
|
||||||
|
|
||||||
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
|
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
|
||||||
{
|
{
|
||||||
type: "workflow.start.manual",
|
type: "bpmn.startEvent",
|
||||||
category: "trigger",
|
category: "bpmn_event",
|
||||||
category_label: "Start",
|
category_label: "Events",
|
||||||
label: "Manual start",
|
label: "Start event",
|
||||||
description: "Start through an explicit action.",
|
description: "Start a BPMN process.",
|
||||||
icon: "circle-play",
|
icon: "circle-play",
|
||||||
input_ports: [],
|
input_ports: [],
|
||||||
output_ports: outputPort,
|
output_ports: outputPort,
|
||||||
config_fields: [],
|
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",
|
type: "bpmn.userTask",
|
||||||
category: "activity",
|
category: "bpmn_activity",
|
||||||
category_label: "Activities",
|
category_label: "Activities",
|
||||||
label: "Activity",
|
label: "User task",
|
||||||
description: "A governed unit of work.",
|
description: "A governed user task.",
|
||||||
icon: "square-check-big",
|
icon: "user-round-check",
|
||||||
input_ports: inputPort,
|
input_ports: inputPort,
|
||||||
output_ports: outputPort,
|
output_ports: outputPort,
|
||||||
config_fields: [
|
config_fields: [
|
||||||
@@ -80,20 +90,29 @@ export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
|
|||||||
title: "",
|
title: "",
|
||||||
instructions: "",
|
instructions: "",
|
||||||
assignee: "",
|
assignee: "",
|
||||||
due_after: ""
|
due_after: "",
|
||||||
}
|
task_mode: "activity",
|
||||||
|
documentation: ""
|
||||||
|
},
|
||||||
|
metadata: { notation: "bpmn-2.0", shape: "activity" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "workflow.end.completed",
|
type: "bpmn.endEvent",
|
||||||
category: "outcome",
|
category: "bpmn_event",
|
||||||
category_label: "Outcomes",
|
category_label: "Events",
|
||||||
label: "Completed",
|
label: "End event",
|
||||||
description: "Complete successfully.",
|
description: "End the BPMN process.",
|
||||||
icon: "circle-check-big",
|
icon: "circle-stop",
|
||||||
input_ports: [{ ...inputPort[0], multiple: true }],
|
input_ports: [{ ...inputPort[0], multiple: true }],
|
||||||
output_ports: [],
|
output_ports: [],
|
||||||
config_fields: [],
|
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,
|
allowStart: true,
|
||||||
allowReuse: false,
|
allowReuse: false,
|
||||||
allowAutomation: false,
|
allowAutomation: false,
|
||||||
|
executionMode: "hybrid",
|
||||||
|
viewId: "",
|
||||||
|
viewRevisionId: "",
|
||||||
governance: null,
|
governance: null,
|
||||||
graph: {
|
graph: {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
id: "start",
|
id: "start",
|
||||||
type: "workflow.start.manual",
|
type: "bpmn.startEvent",
|
||||||
label: "Start",
|
label: "Start",
|
||||||
position: { x: 60, y: 150 },
|
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",
|
id: "activity",
|
||||||
type: "workflow.activity",
|
type: "bpmn.userTask",
|
||||||
label: "Activity",
|
label: "Activity",
|
||||||
position: { x: 320, y: 150 },
|
position: { x: 320, y: 150 },
|
||||||
|
size: { width: 120, height: 80 },
|
||||||
|
process_id: "Process_1",
|
||||||
config: {
|
config: {
|
||||||
title: "Complete activity",
|
title: "Complete activity",
|
||||||
instructions: "",
|
instructions: "",
|
||||||
assignee: "",
|
assignee: "",
|
||||||
due_after: ""
|
due_after: "",
|
||||||
|
task_mode: "activity",
|
||||||
|
documentation: ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "complete",
|
id: "complete",
|
||||||
type: "workflow.end.completed",
|
type: "bpmn.endEvent",
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
position: { x: 580, y: 150 },
|
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: [
|
edges: [
|
||||||
{
|
{
|
||||||
id: "start-activity",
|
id: "start-activity",
|
||||||
|
type: "bpmn.sequenceFlow",
|
||||||
|
label: "",
|
||||||
source: "start",
|
source: "start",
|
||||||
target: "activity",
|
target: "activity",
|
||||||
source_port: "output",
|
source_port: "outgoing",
|
||||||
target_port: "input"
|
target_port: "incoming",
|
||||||
|
config: {},
|
||||||
|
waypoints: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "activity-complete",
|
id: "activity-complete",
|
||||||
|
type: "bpmn.sequenceFlow",
|
||||||
|
label: "",
|
||||||
source: "activity",
|
source: "activity",
|
||||||
target: "complete",
|
target: "complete",
|
||||||
source_port: "output",
|
source_port: "outgoing",
|
||||||
target_port: "input"
|
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,
|
allowStart: definition.governance.allow_start,
|
||||||
allowReuse: definition.governance.allow_reuse,
|
allowReuse: definition.governance.allow_reuse,
|
||||||
allowAutomation: definition.governance.allow_automation,
|
allowAutomation: definition.governance.allow_automation,
|
||||||
|
executionMode: definition.revision.execution_mode,
|
||||||
|
viewId: definition.revision.view_id ?? "",
|
||||||
|
viewRevisionId: definition.revision.view_revision_id ?? "",
|
||||||
governance: definition.governance
|
governance: definition.governance
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -198,7 +265,10 @@ export function workflowPayload(
|
|||||||
inherit_to_lower_scopes: draft.inheritToLowerScopes,
|
inherit_to_lower_scopes: draft.inheritToLowerScopes,
|
||||||
allow_start: draft.allowStart,
|
allow_start: draft.allowStart,
|
||||||
allow_reuse: draft.allowReuse,
|
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,
|
inheritToLowerScopes: draft.inheritToLowerScopes,
|
||||||
allowStart: draft.allowStart,
|
allowStart: draft.allowStart,
|
||||||
allowReuse: draft.allowReuse,
|
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 },
|
position: { x: number; y: number },
|
||||||
library: WorkflowNodeType[]
|
library: WorkflowNodeType[]
|
||||||
): WorkflowGraphNode {
|
): WorkflowGraphNode {
|
||||||
return createDefinitionGraphNode<WorkflowGraphNode>(
|
const node = createDefinitionGraphNode<WorkflowGraphNode>(
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
library
|
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 { 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 "@xyflow/react/dist/style.css";
|
||||||
import "./styles/workflow.css";
|
import "./styles/workflow.css";
|
||||||
|
|
||||||
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
|
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
|
||||||
|
const WorkflowOpenWorkWidget = lazy(
|
||||||
|
() => import("./features/workflow/WorkflowOpenWorkWidget")
|
||||||
|
);
|
||||||
const readScopes = [
|
const readScopes = [
|
||||||
"workflow:definition:read",
|
"workflow:definition:read",
|
||||||
"workflow:instance:admin"
|
"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 = {
|
export const workflowModule: PlatformWebModule = {
|
||||||
id: "workflow",
|
id: "workflow",
|
||||||
@@ -22,6 +76,15 @@ export const workflowModule: PlatformWebModule = {
|
|||||||
"policy",
|
"policy",
|
||||||
"tasks"
|
"tasks"
|
||||||
],
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "workflow.widget.open-work",
|
||||||
|
moduleId: "workflow",
|
||||||
|
kind: "section",
|
||||||
|
label: "Open workflow work widget",
|
||||||
|
order: 76
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/workflow",
|
to: "/workflow",
|
||||||
@@ -39,7 +102,10 @@ export const workflowModule: PlatformWebModule = {
|
|||||||
render: ({ settings, auth }) =>
|
render: ({ settings, auth }) =>
|
||||||
createElement(WorkflowPage, { settings, auth })
|
createElement(WorkflowPage, { settings, auth })
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
uiCapabilities: {
|
||||||
|
"dashboard.widgets": workflowDashboardWidgets
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default workflowModule;
|
export default workflowModule;
|
||||||
|
|||||||
+119
-105
@@ -274,6 +274,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-bpmn-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-palette,
|
.workflow-palette,
|
||||||
.workflow-inspector,
|
.workflow-inspector,
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
@@ -457,38 +461,101 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
width: 190px;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--line-dark);
|
border: 1px solid var(--line-dark);
|
||||||
border-left: 4px solid #3d6f9e;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-trigger {
|
.workflow-node-bpmn_activity {
|
||||||
border-left-color: #2f7d6d;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-activity {
|
.workflow-node-bpmn_collaboration {
|
||||||
border-left-color: #3d6f9e;
|
border-width: 2px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-decision {
|
.workflow-node-shape-participant,
|
||||||
border-left-color: #b7791f;
|
.workflow-node-shape-lane {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-wait {
|
.workflow-node-shape-lane {
|
||||||
border-left-color: #8a6d3b;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-integration {
|
.workflow-node-shape-group {
|
||||||
border-left-color: #76569b;
|
align-items: flex-start;
|
||||||
|
border: 2px dashed var(--line-dark);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-outcome {
|
.workflow-node-shape-text-annotation {
|
||||||
border-left-color: #9d4e63;
|
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 {
|
.workflow-node.is-selected {
|
||||||
@@ -498,10 +565,26 @@
|
|||||||
var(--shadow-xs);
|
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 {
|
.workflow-node.has-error {
|
||||||
border-color: var(--danger-text);
|
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 {
|
.workflow-node-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -552,6 +635,25 @@
|
|||||||
background: var(--accent);
|
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 {
|
.workflow-inspector-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
@@ -899,100 +1001,10 @@
|
|||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-history > div {
|
.workflow-run-history .stage-rail {
|
||||||
grid-template-columns: repeat(
|
|
||||||
auto-fit,
|
|
||||||
minmax(min(150px, 100%), 1fr)
|
|
||||||
);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 2px 0 4px;
|
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-history small,
|
||||||
.workflow-run-events small {
|
.workflow-run-events small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1025,6 +1037,7 @@
|
|||||||
.workflow-editor {
|
.workflow-editor {
|
||||||
grid-template-columns: 180px minmax(0, 1fr) 270px;
|
grid-template-columns: 180px minmax(0, 1fr) 270px;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -1042,6 +1055,7 @@
|
|||||||
border-top: var(--border-line);
|
border-top: var(--border-line);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@
|
|||||||
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
|
"@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"],
|
"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": ["../../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"]
|
"include": ["src"]
|
||||||
|
|||||||
Reference in New Issue
Block a user