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
+326 -14
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import hashlib
import json
import re
@@ -16,11 +17,31 @@ from govoplan_workflow.backend.db.models import (
WorkflowDefinitionRevision,
)
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 (
definition_governance_payload,
require_definition_action,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
BpmnRevisionSummaryResponse,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionResponse,
@@ -52,6 +73,28 @@ class WorkflowValidationError(WorkflowError):
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(
session: Session,
*,
@@ -139,7 +182,10 @@ def create_definition(
actor_id: str | None,
payload: WorkflowDefinitionCreateRequest,
) -> WorkflowDefinition:
graph = _validated_graph(payload.graph)
graph, bpmn = _prepare_revision_content(
graph=payload.graph,
bpmn=payload.bpmn,
)
stored_tenant_id = (
None if payload.scope_type == "system" else tenant_id
)
@@ -185,6 +231,10 @@ def create_definition(
tenant_id=stored_tenant_id,
revision=1,
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,
)
)
@@ -227,9 +277,11 @@ def update_definition(
raise WorkflowConflictError(
"Definition kind is immutable; derive a flow or template instead."
)
graph = _validated_graph(payload.graph)
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.description = _clean_optional(payload.description)
definition.metadata_ = dict(payload.metadata)
@@ -250,13 +302,24 @@ def update_definition(
payload.allow_automation and ancestor_limits["allow_automation"]
)
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.revisions.append(
_new_revision(
tenant_id=definition.tenant_id,
revision=definition.current_revision,
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,
)
)
@@ -341,6 +404,30 @@ def derive_definition(
"derived_by": actor_id,
"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(
tenant_id=stored_tenant_id,
scope_type=payload.scope_type,
@@ -375,10 +462,25 @@ def derive_definition(
tenant_id=stored_tenant_id,
revision=1,
schema_version=source_revision.schema_version,
graph=dict(source_revision.graph),
content_hash=source_revision.content_hash,
graph=_canonical_graph(source_graph),
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_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,
)
)
@@ -410,6 +512,8 @@ def activate_definition(
"Workflow templates cannot be activated or started."
)
_validated_graph(WorkflowGraph.model_validate(selected.graph))
_validate_bpmn_activation(selected)
_validate_execution_mode_activation(selected)
definition.active_revision = selected.revision
definition.status = "active"
definition.updated_by = actor_id
@@ -496,10 +600,16 @@ def revision_response(
id=revision.id,
revision=revision.revision,
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,
library_id=revision.library_id,
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_at=revision.created_at,
)
@@ -510,6 +620,10 @@ def _new_revision(
tenant_id: str | None,
revision: int,
graph: WorkflowGraph,
bpmn: _BpmnArtifact | None,
execution_mode: str,
view_id: str | None,
view_revision_id: str | None,
actor_id: str | None,
) -> WorkflowDefinitionRevision:
return WorkflowDefinitionRevision(
@@ -517,14 +631,132 @@ def _new_revision(
revision=revision,
schema_version=graph.schema_version,
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_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,
)
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:
try:
graph = canonical_bpmn_graph(graph)
except BpmnGraphError as exc:
raise WorkflowBpmnValidationError(exc.diagnostics) from exc
diagnostics = validate_workflow_graph(graph)
if any(item.severity == "error" for item in diagnostics):
raise WorkflowValidationError(diagnostics)
@@ -535,15 +767,93 @@ def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]:
return graph.model_dump(mode="json")
def _content_hash(graph: WorkflowGraph) -> str:
encoded = json.dumps(
_canonical_graph(graph),
sort_keys=True,
separators=(",", ":"),
)
def _content_hash(
graph: WorkflowGraph,
*,
bpmn: _BpmnArtifact | None = None,
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()
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(
session: Session,
*,
@@ -639,6 +949,7 @@ def _effective_governance_limits(
__all__ = [
"WorkflowBpmnValidationError",
"WorkflowConflictError",
"WorkflowError",
"WorkflowNotFoundError",
@@ -654,5 +965,6 @@ __all__ = [
"list_definition_revisions",
"list_definitions",
"revision_response",
"revision_bpmn_summary",
"update_definition",
]