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