Implement native BPMN workflows and guided modes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user