feat: add BPMN inspection and workflow progress visuals

This commit is contained in:
2026-07-30 17:42:10 +02:00
parent a6e0e89829
commit c505e81006
9 changed files with 736 additions and 12 deletions
+37
View File
@@ -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.
+24
View File
@@ -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
View File
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
+309
View File
@@ -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",
]
+91
View File
@@ -29,7 +29,19 @@ from govoplan_workflow.backend.manifest import (
INSTANCE_TRANSITION_SCOPE,
)
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 (
BpmnDiagnosticResponse,
BpmnElementSupportResponse,
BpmnInspectionRequest,
BpmnInspectionResponse,
BpmnSupportProfileResponse,
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
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)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
+48
View File
@@ -10,6 +10,11 @@ from pydantic import BaseModel, Field, field_validator
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
BpmnSupportLevel = Literal[
"interchange_only",
"native_mapping",
"native_execution",
]
class WorkflowPosition(BaseModel):
@@ -100,6 +105,49 @@ class WorkflowNodeLibraryResponse(BaseModel):
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):
id: str
revision: int
+85
View File
@@ -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
} from "react";
import {
AlertTriangle,
Check,
Circle,
Clock3,
ExternalLink,
Play,
RefreshCw,
@@ -417,13 +421,36 @@ export default function WorkflowRunsDialog({
<h3>Progress</h3>
<div>
{selected.steps.map((step) => (
<span key={step.id}>
<div
key={step.id}
className="workflow-run-stage"
data-state={step.status}
data-current={
step.id === selected.current_step_id || undefined
}
>
<span className="workflow-run-stage-marker">
{step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)}
</span>
<span className="workflow-run-stage-copy">
<strong>{step.node_id}</strong>
<small>
{step.node_type} · attempt {step.attempt}
</small>
<StatusBadge status={step.status} label={step.status} />
<StatusBadge
status={step.status}
label={step.status}
/>
</span>
</div>
))}
</div>
</section>
+107 -4
View File
@@ -328,14 +328,20 @@
.workflow-palette-items {
display: grid;
grid-auto-rows: max-content;
align-content: start;
height: calc(100% - 44px);
gap: 4px;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding: 8px;
scrollbar-gutter: stable;
}
.workflow-palette-group {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 2px;
}
@@ -548,11 +554,15 @@
.workflow-inspector-fields {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
min-height: 0;
height: calc(100% - 44px);
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding: 12px;
scrollbar-gutter: stable;
}
.workflow-inspector-fields input,
@@ -879,7 +889,6 @@
display: grid;
}
.workflow-run-history > div > span,
.workflow-run-events > div > span {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
@@ -890,6 +899,100 @@
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-events small {
color: var(--muted);