feat: add BPMN inspection and workflow progress visuals
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
# BPMN Interoperability
|
||||||
|
|
||||||
|
GovOPlaN distinguishes BPMN notation and XML interchange from executable
|
||||||
|
workflow semantics.
|
||||||
|
|
||||||
|
## Current Contract
|
||||||
|
|
||||||
|
- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support
|
||||||
|
profile.
|
||||||
|
- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML,
|
||||||
|
inventories every BPMN model element, detects duplicate IDs and selected
|
||||||
|
dangling references, and classifies elements as interchange-only, natively
|
||||||
|
mappable, or natively executable.
|
||||||
|
- XML entities, DTD-based expansion, oversized documents, and malformed roots
|
||||||
|
are rejected.
|
||||||
|
- The native GovOPlaN graph remains the authoritative executable definition.
|
||||||
|
|
||||||
|
Inspection is not XML Schema validation and does not claim that every BPMN
|
||||||
|
semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter
|
||||||
|
can provide complete visual notation and XML round-tripping without forcing
|
||||||
|
unsupported elements into the native runner.
|
||||||
|
|
||||||
|
## Execution Boundary
|
||||||
|
|
||||||
|
Adding a BPMN shape is not equivalent to implementing its token semantics,
|
||||||
|
event subscriptions, compensation, transactions, choreography, or conformance
|
||||||
|
behavior. Each executable mapping therefore needs:
|
||||||
|
|
||||||
|
1. an explicit native semantic mapping;
|
||||||
|
2. validation rules and lifecycle behavior;
|
||||||
|
3. resumability and idempotency tests;
|
||||||
|
4. migration and round-trip fixtures;
|
||||||
|
5. a declared fallback when the installed runtime cannot execute it.
|
||||||
|
|
||||||
|
Unsupported constructs remain visible and preserved by the future interchange
|
||||||
|
adapter, but activation must remain blocked until an execution adapter declares
|
||||||
|
support.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Workflow Visual Model
|
||||||
|
|
||||||
|
The Campaign review flow is the reference for runtime workflow progress:
|
||||||
|
|
||||||
|
- a compact stage rail communicates order, current state, completion, warning,
|
||||||
|
failure, partial progress, and locks;
|
||||||
|
- the active handoff owns the detailed controls;
|
||||||
|
- evidence remains visible without turning every stage into a permanent card;
|
||||||
|
- unavailable stages stay visibly unavailable while non-blocking optional
|
||||||
|
stages do not interrupt the connector state.
|
||||||
|
|
||||||
|
Workflow now applies that language to instance progress without importing
|
||||||
|
Campaign code. Once the state vocabulary has stabilized, the rail should move
|
||||||
|
to Core as a generic process-stage component and Campaign should consume it.
|
||||||
|
|
||||||
|
Navigation has three distinct layers:
|
||||||
|
|
||||||
|
1. the platform siderail selects a module or focused View;
|
||||||
|
2. the module workspace selects an object or definition;
|
||||||
|
3. the workflow stage rail describes progress inside that object.
|
||||||
|
|
||||||
|
A focused View or active Workflow may suppress unrelated platform and module
|
||||||
|
navigation, but must always provide a visible escape back to the normal View.
|
||||||
|
Modules should not add another persistent navigation tier for workflow stages.
|
||||||
+1
-1
@@ -10,7 +10,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.14"]
|
dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
from xml.etree.ElementTree import ParseError
|
||||||
|
|
||||||
|
from defusedxml.ElementTree import fromstring
|
||||||
|
from defusedxml.common import DefusedXmlException
|
||||||
|
|
||||||
|
|
||||||
|
BPMN_MODEL_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
BPMN_DI_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
|
OMG_DI_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DI"
|
||||||
|
OMG_DC_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DC"
|
||||||
|
MAX_BPMN_XML_BYTES = 1_048_576
|
||||||
|
MAX_BPMN_ELEMENTS = 20_000
|
||||||
|
|
||||||
|
SupportLevel = Literal[
|
||||||
|
"interchange_only",
|
||||||
|
"native_mapping",
|
||||||
|
"native_execution",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
NATIVE_EXECUTION_ELEMENTS = frozenset(
|
||||||
|
{
|
||||||
|
"startEvent",
|
||||||
|
"endEvent",
|
||||||
|
"manualTask",
|
||||||
|
"userTask",
|
||||||
|
"serviceTask",
|
||||||
|
"businessRuleTask",
|
||||||
|
"receiveTask",
|
||||||
|
"sendTask",
|
||||||
|
"exclusiveGateway",
|
||||||
|
"parallelGateway",
|
||||||
|
"sequenceFlow",
|
||||||
|
"intermediateCatchEvent",
|
||||||
|
"intermediateThrowEvent",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
|
||||||
|
{
|
||||||
|
"task",
|
||||||
|
"scriptTask",
|
||||||
|
"callActivity",
|
||||||
|
"subProcess",
|
||||||
|
"transaction",
|
||||||
|
"adHocSubProcess",
|
||||||
|
"inclusiveGateway",
|
||||||
|
"eventBasedGateway",
|
||||||
|
"complexGateway",
|
||||||
|
"boundaryEvent",
|
||||||
|
"eventSubProcess",
|
||||||
|
"dataObject",
|
||||||
|
"dataObjectReference",
|
||||||
|
"dataStoreReference",
|
||||||
|
"messageFlow",
|
||||||
|
"participant",
|
||||||
|
"lane",
|
||||||
|
"laneSet",
|
||||||
|
"textAnnotation",
|
||||||
|
"association",
|
||||||
|
"group",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BpmnDiagnostic:
|
||||||
|
severity: Literal["error", "warning", "info"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
element_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BpmnElementInventoryItem:
|
||||||
|
element_type: str
|
||||||
|
element_id: str | None
|
||||||
|
name: str | None
|
||||||
|
parent_type: str | None
|
||||||
|
parent_id: str | None
|
||||||
|
support_level: SupportLevel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BpmnInspection:
|
||||||
|
definitions_id: str | None
|
||||||
|
target_namespace: str | None
|
||||||
|
process_count: int
|
||||||
|
executable_process_count: int
|
||||||
|
collaboration_count: int
|
||||||
|
choreography_count: int
|
||||||
|
element_counts: dict[str, int]
|
||||||
|
support_counts: dict[str, int]
|
||||||
|
elements: tuple[BpmnElementInventoryItem, ...]
|
||||||
|
diagnostics: tuple[BpmnDiagnostic, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid_xml(self) -> bool:
|
||||||
|
return not any(item.severity == "error" for item in self.diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnInspectionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def bpmn_support_level(element_type: str) -> SupportLevel:
|
||||||
|
if element_type in NATIVE_EXECUTION_ELEMENTS:
|
||||||
|
return "native_execution"
|
||||||
|
if element_type in NATIVE_MAPPING_ELEMENTS:
|
||||||
|
return "native_mapping"
|
||||||
|
return "interchange_only"
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_bpmn_xml(xml: str) -> BpmnInspection:
|
||||||
|
encoded = xml.encode("utf-8")
|
||||||
|
if not encoded:
|
||||||
|
raise BpmnInspectionError("BPMN XML is empty")
|
||||||
|
if len(encoded) > MAX_BPMN_XML_BYTES:
|
||||||
|
raise BpmnInspectionError(
|
||||||
|
f"BPMN XML exceeds the {MAX_BPMN_XML_BYTES}-byte inspection limit"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
root = fromstring(encoded)
|
||||||
|
except (DefusedXmlException, ParseError, ValueError) as exc:
|
||||||
|
raise BpmnInspectionError(f"BPMN XML is not safe and well formed: {exc}") from exc
|
||||||
|
|
||||||
|
namespace, local_name = _qualified_name(root.tag)
|
||||||
|
if namespace != BPMN_MODEL_NAMESPACE or local_name != "definitions":
|
||||||
|
raise BpmnInspectionError(
|
||||||
|
"BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace"
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnostics: list[BpmnDiagnostic] = []
|
||||||
|
elements: list[BpmnElementInventoryItem] = []
|
||||||
|
ids: dict[str, str] = {}
|
||||||
|
references: list[tuple[str, str | None, str]] = []
|
||||||
|
process_count = 0
|
||||||
|
executable_process_count = 0
|
||||||
|
collaboration_count = 0
|
||||||
|
choreography_count = 0
|
||||||
|
|
||||||
|
stack = [(root, None, None)]
|
||||||
|
visited = 0
|
||||||
|
while stack:
|
||||||
|
element, parent_type, parent_id = stack.pop()
|
||||||
|
visited += 1
|
||||||
|
if visited > MAX_BPMN_ELEMENTS:
|
||||||
|
raise BpmnInspectionError(
|
||||||
|
f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit"
|
||||||
|
)
|
||||||
|
element_namespace, element_type = _qualified_name(element.tag)
|
||||||
|
element_id = _bounded_attribute(element.attrib.get("id"), 255)
|
||||||
|
if element_namespace == BPMN_MODEL_NAMESPACE:
|
||||||
|
name = _bounded_attribute(element.attrib.get("name"), 300)
|
||||||
|
support = bpmn_support_level(element_type)
|
||||||
|
elements.append(
|
||||||
|
BpmnElementInventoryItem(
|
||||||
|
element_type=element_type,
|
||||||
|
element_id=element_id,
|
||||||
|
name=name,
|
||||||
|
parent_type=parent_type,
|
||||||
|
parent_id=parent_id,
|
||||||
|
support_level=support,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if element_id:
|
||||||
|
previous = ids.get(element_id)
|
||||||
|
if previous is not None:
|
||||||
|
diagnostics.append(
|
||||||
|
BpmnDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="duplicate_bpmn_id",
|
||||||
|
message=(
|
||||||
|
f"BPMN id {element_id!r} is used by both "
|
||||||
|
f"{previous} and {element_type}"
|
||||||
|
),
|
||||||
|
element_id=element_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ids[element_id] = element_type
|
||||||
|
if element_type == "process":
|
||||||
|
process_count += 1
|
||||||
|
if element.attrib.get("isExecutable", "").strip().lower() == "true":
|
||||||
|
executable_process_count += 1
|
||||||
|
elif element_type == "collaboration":
|
||||||
|
collaboration_count += 1
|
||||||
|
elif element_type in {"choreography", "globalChoreographyTask"}:
|
||||||
|
choreography_count += 1
|
||||||
|
_collect_references(element_type, element_id, element.attrib, references)
|
||||||
|
next_parent_type = element_type
|
||||||
|
next_parent_id = element_id
|
||||||
|
else:
|
||||||
|
next_parent_type = parent_type
|
||||||
|
next_parent_id = parent_id
|
||||||
|
for child in reversed(list(element)):
|
||||||
|
stack.append((child, next_parent_type, next_parent_id))
|
||||||
|
|
||||||
|
if process_count == 0 and collaboration_count == 0 and choreography_count == 0:
|
||||||
|
diagnostics.append(
|
||||||
|
BpmnDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="no_bpmn_process_or_collaboration",
|
||||||
|
message="BPMN definitions contain no process, collaboration, or choreography",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for reference, element_id, field in references:
|
||||||
|
if reference not in ids:
|
||||||
|
diagnostics.append(
|
||||||
|
BpmnDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="dangling_bpmn_reference",
|
||||||
|
message=(
|
||||||
|
f"{field} references unknown BPMN id {reference!r}"
|
||||||
|
),
|
||||||
|
element_id=element_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unsupported = [
|
||||||
|
item
|
||||||
|
for item in elements
|
||||||
|
if item.support_level == "interchange_only"
|
||||||
|
and item.element_type not in {"definitions", "process"}
|
||||||
|
]
|
||||||
|
if unsupported:
|
||||||
|
diagnostics.append(
|
||||||
|
BpmnDiagnostic(
|
||||||
|
severity="info",
|
||||||
|
code="interchange_only_elements",
|
||||||
|
message=(
|
||||||
|
f"{len(unsupported)} BPMN element(s) can be inventoried and "
|
||||||
|
"round-tripped by a BPMN modeler but are not executable by "
|
||||||
|
"the native GovOPlaN workflow runtime"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
element_counts = dict(
|
||||||
|
sorted(Counter(item.element_type for item in elements).items())
|
||||||
|
)
|
||||||
|
support_counts = dict(
|
||||||
|
sorted(Counter(item.support_level for item in elements).items())
|
||||||
|
)
|
||||||
|
return BpmnInspection(
|
||||||
|
definitions_id=_bounded_attribute(root.attrib.get("id"), 255),
|
||||||
|
target_namespace=_bounded_attribute(
|
||||||
|
root.attrib.get("targetNamespace"),
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
process_count=process_count,
|
||||||
|
executable_process_count=executable_process_count,
|
||||||
|
collaboration_count=collaboration_count,
|
||||||
|
choreography_count=choreography_count,
|
||||||
|
element_counts=element_counts,
|
||||||
|
support_counts=support_counts,
|
||||||
|
elements=tuple(elements),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_references(
|
||||||
|
element_type: str,
|
||||||
|
element_id: str | None,
|
||||||
|
attributes: dict[str, str],
|
||||||
|
references: list[tuple[str, str | None, str]],
|
||||||
|
) -> None:
|
||||||
|
fields: tuple[str, ...]
|
||||||
|
if element_type == "sequenceFlow":
|
||||||
|
fields = ("sourceRef", "targetRef")
|
||||||
|
elif element_type == "messageFlow":
|
||||||
|
fields = ("sourceRef", "targetRef", "messageRef")
|
||||||
|
elif element_type == "participant":
|
||||||
|
fields = ("processRef",)
|
||||||
|
elif element_type == "lane":
|
||||||
|
fields = ("partitionElementRef",)
|
||||||
|
else:
|
||||||
|
fields = ()
|
||||||
|
for field in fields:
|
||||||
|
value = attributes.get(field)
|
||||||
|
if value:
|
||||||
|
references.append((value, element_id, field))
|
||||||
|
|
||||||
|
|
||||||
|
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 _bounded_attribute(value: str | None, limit: int) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text[:limit] if text else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BPMN_MODEL_NAMESPACE",
|
||||||
|
"BpmnInspectionError",
|
||||||
|
"NATIVE_EXECUTION_ELEMENTS",
|
||||||
|
"NATIVE_MAPPING_ELEMENTS",
|
||||||
|
"bpmn_support_level",
|
||||||
|
"inspect_bpmn_xml",
|
||||||
|
]
|
||||||
@@ -29,7 +29,19 @@ from govoplan_workflow.backend.manifest import (
|
|||||||
INSTANCE_TRANSITION_SCOPE,
|
INSTANCE_TRANSITION_SCOPE,
|
||||||
)
|
)
|
||||||
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 (
|
||||||
|
BPMN_MODEL_NAMESPACE,
|
||||||
|
BpmnInspectionError,
|
||||||
|
NATIVE_EXECUTION_ELEMENTS,
|
||||||
|
NATIVE_MAPPING_ELEMENTS,
|
||||||
|
inspect_bpmn_xml,
|
||||||
|
)
|
||||||
from govoplan_workflow.backend.schemas import (
|
from govoplan_workflow.backend.schemas import (
|
||||||
|
BpmnDiagnosticResponse,
|
||||||
|
BpmnElementSupportResponse,
|
||||||
|
BpmnInspectionRequest,
|
||||||
|
BpmnInspectionResponse,
|
||||||
|
BpmnSupportProfileResponse,
|
||||||
WorkflowConfigFieldResponse,
|
WorkflowConfigFieldResponse,
|
||||||
WorkflowDefinitionActivateRequest,
|
WorkflowDefinitionActivateRequest,
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
@@ -204,6 +216,85 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
|
||||||
|
def api_bpmn_support_profile(
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> BpmnSupportProfileResponse:
|
||||||
|
_require_any_scope(
|
||||||
|
principal,
|
||||||
|
DEFINITION_READ_SCOPE,
|
||||||
|
DEFINITION_WRITE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
)
|
||||||
|
return BpmnSupportProfileResponse(
|
||||||
|
specification="BPMN 2.0.2",
|
||||||
|
model_namespace=BPMN_MODEL_NAMESPACE,
|
||||||
|
interchange=(
|
||||||
|
"Secure XML inventory and diagnostics are available. Visual "
|
||||||
|
"round-trip modeling requires the planned bpmn-js adapter."
|
||||||
|
),
|
||||||
|
native_runtime=(
|
||||||
|
"Only the explicitly listed executable subset maps to current "
|
||||||
|
"native runtime semantics; all other elements are interchange-only."
|
||||||
|
),
|
||||||
|
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
|
||||||
|
native_mapping_elements=sorted(
|
||||||
|
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
result = inspect_bpmn_xml(payload.xml)
|
||||||
|
except BpmnInspectionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return BpmnInspectionResponse(
|
||||||
|
valid_xml=result.valid_xml,
|
||||||
|
definitions_id=result.definitions_id,
|
||||||
|
target_namespace=result.target_namespace,
|
||||||
|
process_count=result.process_count,
|
||||||
|
executable_process_count=result.executable_process_count,
|
||||||
|
collaboration_count=result.collaboration_count,
|
||||||
|
choreography_count=result.choreography_count,
|
||||||
|
element_counts=result.element_counts,
|
||||||
|
support_counts=result.support_counts,
|
||||||
|
elements=[
|
||||||
|
BpmnElementSupportResponse(
|
||||||
|
element_type=item.element_type,
|
||||||
|
element_id=item.element_id,
|
||||||
|
name=item.name,
|
||||||
|
parent_type=item.parent_type,
|
||||||
|
parent_id=item.parent_id,
|
||||||
|
support_level=item.support_level,
|
||||||
|
)
|
||||||
|
for item in result.elements
|
||||||
|
],
|
||||||
|
diagnostics=[
|
||||||
|
BpmnDiagnosticResponse(
|
||||||
|
severity=item.severity,
|
||||||
|
code=item.code,
|
||||||
|
message=item.message,
|
||||||
|
element_id=item.element_id,
|
||||||
|
)
|
||||||
|
for item in result.diagnostics
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@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),
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ from pydantic import BaseModel, Field, field_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"]
|
||||||
|
BpmnSupportLevel = Literal[
|
||||||
|
"interchange_only",
|
||||||
|
"native_mapping",
|
||||||
|
"native_execution",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class WorkflowPosition(BaseModel):
|
class WorkflowPosition(BaseModel):
|
||||||
@@ -100,6 +105,49 @@ class WorkflowNodeLibraryResponse(BaseModel):
|
|||||||
nodes: list[WorkflowNodeTypeResponse]
|
nodes: list[WorkflowNodeTypeResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnInspectionRequest(BaseModel):
|
||||||
|
xml: str = Field(min_length=1, max_length=1_048_576)
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnElementSupportResponse(BaseModel):
|
||||||
|
element_type: str
|
||||||
|
element_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
parent_type: str | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
support_level: BpmnSupportLevel
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnDiagnosticResponse(BaseModel):
|
||||||
|
severity: Literal["error", "warning", "info"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
element_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnInspectionResponse(BaseModel):
|
||||||
|
valid_xml: bool
|
||||||
|
definitions_id: str | None = None
|
||||||
|
target_namespace: str | None = None
|
||||||
|
process_count: int
|
||||||
|
executable_process_count: int
|
||||||
|
collaboration_count: int
|
||||||
|
choreography_count: int
|
||||||
|
element_counts: dict[str, int]
|
||||||
|
support_counts: dict[str, int]
|
||||||
|
elements: list[BpmnElementSupportResponse]
|
||||||
|
diagnostics: list[BpmnDiagnosticResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnSupportProfileResponse(BaseModel):
|
||||||
|
specification: str
|
||||||
|
model_namespace: str
|
||||||
|
interchange: str
|
||||||
|
native_runtime: str
|
||||||
|
native_execution_elements: list[str]
|
||||||
|
native_mapping_elements: list[str]
|
||||||
|
|
||||||
|
|
||||||
class WorkflowDefinitionRevisionResponse(BaseModel):
|
class WorkflowDefinitionRevisionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
revision: int
|
revision: int
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_workflow.backend.bpmn import (
|
||||||
|
BpmnInspectionError,
|
||||||
|
inspect_bpmn_xml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BPMN = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_1"
|
||||||
|
targetNamespace="https://govoplan.example.test/workflow">
|
||||||
|
<bpmn:process id="Process_1" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Start_1" />
|
||||||
|
<bpmn:userTask id="Review_1" name="Review request" />
|
||||||
|
<bpmn:exclusiveGateway id="Decision_1" />
|
||||||
|
<bpmn:endEvent id="End_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Review_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_2" sourceRef="Review_1" targetRef="Decision_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_3" sourceRef="Decision_1" targetRef="End_1" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:collaboration id="Collaboration_1">
|
||||||
|
<bpmn:participant id="Participant_1" processRef="Process_1" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
</bpmn:definitions>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BpmnInspectionTests(unittest.TestCase):
|
||||||
|
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
|
||||||
|
result = inspect_bpmn_xml(BPMN)
|
||||||
|
|
||||||
|
self.assertTrue(result.valid_xml)
|
||||||
|
self.assertEqual(1, result.process_count)
|
||||||
|
self.assertEqual(1, result.executable_process_count)
|
||||||
|
self.assertEqual(1, result.collaboration_count)
|
||||||
|
self.assertEqual(3, result.element_counts["sequenceFlow"])
|
||||||
|
review = next(
|
||||||
|
item for item in result.elements if item.element_id == "Review_1"
|
||||||
|
)
|
||||||
|
collaboration = next(
|
||||||
|
item
|
||||||
|
for item in result.elements
|
||||||
|
if item.element_id == "Collaboration_1"
|
||||||
|
)
|
||||||
|
self.assertEqual("native_execution", review.support_level)
|
||||||
|
self.assertEqual("interchange_only", collaboration.support_level)
|
||||||
|
|
||||||
|
def test_dangling_references_are_reported(self) -> None:
|
||||||
|
result = inspect_bpmn_xml(
|
||||||
|
BPMN.replace('targetRef="End_1"', 'targetRef="Missing_1"')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result.valid_xml)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item.code == "dangling_bpmn_reference"
|
||||||
|
for item in result.diagnostics
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_entities_are_rejected(self) -> None:
|
||||||
|
unsafe = """<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||||
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_1" targetNamespace="x">&xxe;</bpmn:definitions>"""
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
BpmnInspectionError,
|
||||||
|
"not safe and well formed",
|
||||||
|
):
|
||||||
|
inspect_bpmn_xml(unsafe)
|
||||||
|
|
||||||
|
def test_non_bpmn_root_is_rejected(self) -> None:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
BpmnInspectionError,
|
||||||
|
"bpmn:definitions",
|
||||||
|
):
|
||||||
|
inspect_bpmn_xml("<definitions />")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Circle,
|
||||||
|
Clock3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -417,13 +421,36 @@ export default function WorkflowRunsDialog({
|
|||||||
<h3>Progress</h3>
|
<h3>Progress</h3>
|
||||||
<div>
|
<div>
|
||||||
{selected.steps.map((step) => (
|
{selected.steps.map((step) => (
|
||||||
<span key={step.id}>
|
<div
|
||||||
<strong>{step.node_id}</strong>
|
key={step.id}
|
||||||
<small>
|
className="workflow-run-stage"
|
||||||
{step.node_type} · attempt {step.attempt}
|
data-state={step.status}
|
||||||
</small>
|
data-current={
|
||||||
<StatusBadge status={step.status} label={step.status} />
|
step.id === selected.current_step_id || undefined
|
||||||
</span>
|
}
|
||||||
|
>
|
||||||
|
<span className="workflow-run-stage-marker">
|
||||||
|
{step.status === "completed" ? (
|
||||||
|
<Check size={15} aria-hidden="true" />
|
||||||
|
) : step.status === "failed" ? (
|
||||||
|
<AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
) : ["running", "waiting"].includes(step.status) ? (
|
||||||
|
<Clock3 size={15} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Circle size={13} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="workflow-run-stage-copy">
|
||||||
|
<strong>{step.node_id}</strong>
|
||||||
|
<small>
|
||||||
|
{step.node_type} · attempt {step.attempt}
|
||||||
|
</small>
|
||||||
|
<StatusBadge
|
||||||
|
status={step.status}
|
||||||
|
label={step.status}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -328,14 +328,20 @@
|
|||||||
|
|
||||||
.workflow-palette-items {
|
.workflow-palette-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
height: calc(100% - 44px);
|
height: calc(100% - 44px);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette-group {
|
.workflow-palette-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,11 +554,15 @@
|
|||||||
|
|
||||||
.workflow-inspector-fields {
|
.workflow-inspector-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
height: calc(100% - 44px);
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-inspector-fields input,
|
.workflow-inspector-fields input,
|
||||||
@@ -879,7 +889,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-history > div > span,
|
|
||||||
.workflow-run-events > div > span {
|
.workflow-run-events > div > span {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
|
||||||
@@ -890,6 +899,100 @@
|
|||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-run-history > div {
|
||||||
|
grid-template-columns: repeat(
|
||||||
|
auto-fit,
|
||||||
|
minmax(min(150px, 100%), 1fr)
|
||||||
|
);
|
||||||
|
overflow-x: auto;
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user