Implement native BPMN workflows and guided modes

This commit is contained in:
2026-07-31 02:48:57 +02:00
parent c505e81006
commit f4974b4949
40 changed files with 8203 additions and 489 deletions
+303 -44
View File
@@ -28,19 +28,41 @@ from govoplan_workflow.backend.manifest import (
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnDiagnostic,
BpmnInspectionError,
NATIVE_EXECUTION_ELEMENTS,
NATIVE_MAPPING_ELEMENTS,
inspect_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.backend.bpmn_graph import (
BpmnGraphError,
NATIVE_BPMN_ADAPTER_ID,
NATIVE_BPMN_ADAPTER_VERSION,
canonical_bpmn_graph,
export_bpmn_graph,
)
from govoplan_workflow.backend.schemas import (
BpmnAdapterProfileResponse,
BpmnCompileRequest,
BpmnCompileResponse,
BpmnDiagnosticResponse,
BpmnElementSupportResponse,
BpmnInspectionRequest,
BpmnInspectionResponse,
BpmnRevisionDocumentResponse,
BpmnRenderRequest,
BpmnRenderResponse,
BpmnSupportProfileResponse,
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
@@ -74,6 +96,7 @@ from govoplan_workflow.backend.instance_service import (
)
from govoplan_workflow.backend.runtime import get_registry
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
@@ -89,6 +112,7 @@ from govoplan_workflow.backend.service import (
list_definition_revisions,
list_definitions,
revision_response,
revision_bpmn_summary,
update_definition,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -111,6 +135,22 @@ def _http_error(exc: WorkflowError) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, WorkflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, WorkflowBpmnValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in exc.diagnostics
],
},
)
if isinstance(exc, WorkflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
@@ -216,52 +256,51 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None:
)
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
def api_bpmn_support_profile(
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnSupportProfileResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return BpmnSupportProfileResponse(
specification="BPMN 2.0.2",
model_namespace=BPMN_MODEL_NAMESPACE,
interchange=(
"Secure XML inventory and diagnostics are available. Visual "
"round-trip modeling requires the planned bpmn-js adapter."
),
native_runtime=(
"Only the explicitly listed executable subset maps to current "
"native runtime semantics; all other elements are interchange-only."
),
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
native_mapping_elements=sorted(
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
),
def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse:
return BpmnAdapterProfileResponse(
id=profile.id,
version=profile.version,
label=profile.label,
description=profile.description,
conformance=profile.conformance,
runtime_kind=profile.runtime_kind,
executable=profile.executable,
supported_elements=list(profile.supported_elements),
supported_event_definitions=list(profile.supported_event_definitions),
requirements=list(profile.requirements),
)
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
def api_inspect_bpmn(
payload: BpmnInspectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
def _bpmn_inspection_response(
xml: str,
*,
adapter_id: str,
adapter_version: str | None = None,
activation: bool,
) -> BpmnInspectionResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
result = inspect_bpmn_xml(xml)
adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version)
adapter_diagnostics = (
adapter.diagnostics(xml, result, activation=activation)
if adapter is not None
else ()
)
try:
result = inspect_bpmn_xml(payload.xml)
except BpmnInspectionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
diagnostics = list(result.diagnostics)
diagnostics.extend(adapter_diagnostics)
if adapter is None:
suffix = f"@{adapter_version}" if adapter_version else ""
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.unavailable",
message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.",
)
)
deduplicated = {
(item.code, item.element_id, item.message): item
for item in diagnostics
}.values()
diagnostic_items = list(deduplicated)
return BpmnInspectionResponse(
valid_xml=result.valid_xml,
definitions_id=result.definitions_id,
@@ -290,11 +329,167 @@ def api_inspect_bpmn(
message=item.message,
element_id=item.element_id,
)
for item in result.diagnostics
for item in diagnostic_items
],
adapter_id=adapter.profile.id if adapter else adapter_id,
adapter_version=(
adapter.profile.version if adapter else adapter_version
),
runtime_kind=adapter.profile.runtime_kind if adapter else None,
executable=bool(adapter and adapter.profile.executable),
activatable=bool(
activation
and adapter
and adapter.profile.executable
and not any(item.severity == "error" for item in diagnostic_items)
),
)
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
def api_bpmn_support_profile(
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnSupportProfileResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return BpmnSupportProfileResponse(
specification="BPMN 2.0.2",
model_namespace=BPMN_MODEL_NAMESPACE,
interchange=(
"BPMN 2.0 is the native Workflow graph language. XML import and "
"export preserve standard notation and diagram geometry without "
"a browser-side BPMN modeler."
),
native_runtime=(
"Activation is fail-closed and requires a pinned adapter whose "
"declared conformance profile covers the complete document."
),
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
native_mapping_elements=sorted(
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
),
adapters=[
_adapter_profile_response(profile)
for profile in bpmn_adapter_registry().profiles()
],
)
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
def api_inspect_bpmn(
payload: BpmnInspectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnInspectionResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
return _bpmn_inspection_response(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
activation=payload.activation,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/bpmn/compile", response_model=BpmnCompileResponse)
def api_compile_bpmn(
payload: BpmnCompileRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnCompileResponse:
_require_any_scope(
principal,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
adapter, _inspection, graph = compile_bpmn_to_graph(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
)
if graph is None:
raise BpmnAdapterError(
(
BpmnDiagnostic(
severity="error",
code="adapter.no_runtime_materialization",
message="The selected adapter does not compile to a native graph.",
),
)
)
inspection_response = _bpmn_inspection_response(
payload.xml,
adapter_id=adapter.profile.id,
adapter_version=adapter.profile.version,
activation=False,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
diagnostics = getattr(exc, "diagnostics", ())
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in diagnostics
],
},
) from exc
return BpmnCompileResponse(
adapter=_adapter_profile_response(adapter.profile),
graph=graph,
inspection=inspection_response,
)
@router.post("/bpmn/render", response_model=BpmnRenderResponse)
def api_render_bpmn(
payload: BpmnRenderRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRenderResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
xml = export_bpmn_graph(
canonical_bpmn_graph(payload.graph),
name=payload.name,
)
inspection = _bpmn_inspection_response(
xml,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
adapter_version=NATIVE_BPMN_ADAPTER_VERSION,
activation=False,
)
except (BpmnGraphError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRenderResponse(xml=xml, inspection=inspection)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -349,8 +544,9 @@ def api_node_types(
for field in definition.config_fields
],
default_config=dict(definition.default_config),
metadata=dict(definition.metadata),
)
for definition in WORKFLOW_GRAPH_LIBRARY.node_types
for definition in BPMN_NODE_TYPES
],
)
@@ -515,6 +711,9 @@ def api_start_instance(
principal=principal,
registry=get_registry(),
payload=payload,
start_origin=(
"user" if principal.auth_method == "session" else "api"
),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
@@ -531,6 +730,7 @@ def api_start_instance(
"definition_id": instance.definition_id,
"definition_revision_id": instance.definition_revision_id,
"idempotency_key": instance.idempotency_key,
"start_origin": instance.start_origin,
},
)
response = instance_response(session, instance, replayed=replayed)
@@ -989,6 +1189,65 @@ def api_get_definition_revision(
return revision_response(item)
@router.get(
"/definitions/{definition_id}/revisions/{revision}/bpmn",
response_model=BpmnRevisionDocumentResponse,
)
def api_get_definition_revision_bpmn(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRevisionDocumentResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
summary = revision_bpmn_summary(item)
if summary is None or not item.bpmn_xml:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="This Workflow revision has no BPMN document.",
)
try:
inspection = _bpmn_inspection_response(
item.bpmn_xml,
adapter_id=summary.adapter_id,
adapter_version=summary.adapter_version,
activation=True,
)
except BpmnInspectionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRevisionDocumentResponse(
**summary.model_dump(),
definition_id=definition.id,
revision=item.revision,
xml=item.bpmn_xml,
inspection=inspection,
)
@router.post(
"/definitions/{definition_id}/activate",
response_model=WorkflowDefinitionResponse,