feat: extract headless workflow engine
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Headless GovOPlaN Workflow Engine module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow backend."""
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from xml.etree.ElementTree import Element, 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",
|
||||
"task",
|
||||
"manualTask",
|
||||
"userTask",
|
||||
"serviceTask",
|
||||
"sendTask",
|
||||
"receiveTask",
|
||||
"intermediateCatchEvent",
|
||||
"sequenceFlow",
|
||||
}
|
||||
)
|
||||
|
||||
NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
|
||||
{
|
||||
"definitions",
|
||||
"process",
|
||||
"collaboration",
|
||||
"documentation",
|
||||
"extensionElements",
|
||||
"incoming",
|
||||
"outgoing",
|
||||
"conditionExpression",
|
||||
"scriptTask",
|
||||
"serviceTask",
|
||||
"businessRuleTask",
|
||||
"receiveTask",
|
||||
"sendTask",
|
||||
"callActivity",
|
||||
"subProcess",
|
||||
"transaction",
|
||||
"adHocSubProcess",
|
||||
"exclusiveGateway",
|
||||
"parallelGateway",
|
||||
"inclusiveGateway",
|
||||
"eventBasedGateway",
|
||||
"complexGateway",
|
||||
"boundaryEvent",
|
||||
"eventSubProcess",
|
||||
"intermediateCatchEvent",
|
||||
"intermediateThrowEvent",
|
||||
"dataObject",
|
||||
"dataObjectReference",
|
||||
"dataStoreReference",
|
||||
"messageFlow",
|
||||
"participant",
|
||||
"lane",
|
||||
"laneSet",
|
||||
"textAnnotation",
|
||||
"association",
|
||||
"group",
|
||||
"dataInputAssociation",
|
||||
"dataOutputAssociation",
|
||||
"choreography",
|
||||
"choreographyTask",
|
||||
"callChoreography",
|
||||
"subChoreography",
|
||||
"conversation",
|
||||
"callConversation",
|
||||
"subConversation",
|
||||
"conversationLink",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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 parse_bpmn_xml(xml: str) -> Element:
|
||||
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"
|
||||
)
|
||||
if sum(1 for _item in root.iter()) > MAX_BPMN_ELEMENTS:
|
||||
raise BpmnInspectionError(
|
||||
f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def inspect_bpmn_xml(xml: str) -> BpmnInspection:
|
||||
root = parse_bpmn_xml(xml)
|
||||
|
||||
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
|
||||
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_by_element = {
|
||||
"sequenceFlow": ("sourceRef", "targetRef"),
|
||||
"messageFlow": ("sourceRef", "targetRef", "messageRef"),
|
||||
"association": ("sourceRef", "targetRef"),
|
||||
"participant": ("processRef",),
|
||||
"lane": ("partitionElementRef",),
|
||||
"boundaryEvent": ("attachedToRef",),
|
||||
"dataInputAssociation": ("sourceRef", "targetRef"),
|
||||
"dataOutputAssociation": ("sourceRef", "targetRef"),
|
||||
"dataObjectReference": ("dataObjectRef",),
|
||||
"dataStoreReference": ("dataStoreRef",),
|
||||
"messageEventDefinition": ("messageRef", "operationRef"),
|
||||
"signalEventDefinition": ("signalRef",),
|
||||
"errorEventDefinition": ("errorRef",),
|
||||
"escalationEventDefinition": ("escalationRef",),
|
||||
"compensateEventDefinition": ("activityRef",),
|
||||
}
|
||||
fields = fields_by_element.get(element_type, ())
|
||||
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",
|
||||
"parse_bpmn_xml",
|
||||
]
|
||||
@@ -0,0 +1,721 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from importlib.metadata import entry_points
|
||||
from threading import Lock
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from govoplan_workflow_engine.backend.bpmn import (
|
||||
BPMN_DI_NAMESPACE,
|
||||
BPMN_MODEL_NAMESPACE,
|
||||
OMG_DC_NAMESPACE,
|
||||
BpmnDiagnostic,
|
||||
BpmnInspection,
|
||||
inspect_bpmn_xml,
|
||||
parse_bpmn_xml,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn_graph import (
|
||||
BPMN_EDGE_LOCAL_NAMES,
|
||||
BPMN_NODE_LOCAL_NAMES,
|
||||
BpmnGraphError,
|
||||
NATIVE_BPMN_ADAPTER_ID,
|
||||
NATIVE_BPMN_ADAPTER_VERSION,
|
||||
import_bpmn_graph,
|
||||
runtime_diagnostics,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowPosition,
|
||||
)
|
||||
|
||||
|
||||
BPMN_ADAPTER_ENTRY_POINT_GROUP = "govoplan.workflow.bpmn_adapters"
|
||||
INTERCHANGE_ADAPTER_ID = "bpmn.interchange"
|
||||
NATIVE_LINEAR_ADAPTER_ID = "govoplan.native.linear"
|
||||
|
||||
RuntimeKind = Literal["model_only", "native_graph", "external"]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BpmnExecutionProfile:
|
||||
id: str
|
||||
version: str
|
||||
label: str
|
||||
description: str
|
||||
conformance: str
|
||||
runtime_kind: RuntimeKind
|
||||
executable: bool
|
||||
supported_elements: tuple[str, ...]
|
||||
supported_event_definitions: tuple[str, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BpmnExecutionAdapter(Protocol):
|
||||
profile: BpmnExecutionProfile
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
*,
|
||||
activation: bool,
|
||||
) -> tuple[BpmnDiagnostic, ...]:
|
||||
...
|
||||
|
||||
def compile(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
) -> WorkflowGraph | None:
|
||||
...
|
||||
|
||||
|
||||
class BpmnAdapterError(ValueError):
|
||||
def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None:
|
||||
self.diagnostics = diagnostics
|
||||
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 adapter validation failed."
|
||||
)
|
||||
|
||||
|
||||
class BpmnExecutionAdapterRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._adapters: dict[tuple[str, str], BpmnExecutionAdapter] = {}
|
||||
|
||||
def register(self, adapter: BpmnExecutionAdapter) -> None:
|
||||
if not isinstance(adapter, BpmnExecutionAdapter):
|
||||
raise TypeError("BPMN adapter does not implement BpmnExecutionAdapter.")
|
||||
profile = adapter.profile
|
||||
if not profile.id.strip() or not profile.version.strip():
|
||||
raise ValueError("BPMN adapters require a stable id and version.")
|
||||
key = (profile.id, profile.version)
|
||||
if key in self._adapters:
|
||||
raise ValueError(
|
||||
f"Duplicate BPMN execution adapter {profile.id}@{profile.version}."
|
||||
)
|
||||
self._adapters[key] = adapter
|
||||
|
||||
def profiles(self) -> tuple[BpmnExecutionProfile, ...]:
|
||||
return tuple(
|
||||
adapter.profile
|
||||
for _key, adapter in sorted(
|
||||
self._adapters.items(),
|
||||
key=lambda item: (
|
||||
not item[1].profile.executable,
|
||||
item[1].profile.label.casefold(),
|
||||
item[1].profile.id,
|
||||
item[1].profile.version,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
adapter_id: str,
|
||||
version: str | None = None,
|
||||
) -> BpmnExecutionAdapter | None:
|
||||
if version is not None:
|
||||
return self._adapters.get((adapter_id, version))
|
||||
matches = [
|
||||
adapter
|
||||
for (candidate_id, _candidate_version), adapter in self._adapters.items()
|
||||
if candidate_id == adapter_id
|
||||
]
|
||||
return max(matches, key=lambda item: item.profile.version) if matches else None
|
||||
|
||||
def require(
|
||||
self,
|
||||
adapter_id: str,
|
||||
version: str | None = None,
|
||||
) -> BpmnExecutionAdapter:
|
||||
adapter = self.resolve(adapter_id, version)
|
||||
if adapter is None:
|
||||
suffix = f"@{version}" if version else ""
|
||||
raise BpmnAdapterError(
|
||||
(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.unavailable",
|
||||
message=(
|
||||
f"BPMN execution adapter {adapter_id}{suffix} is not "
|
||||
"installed."
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
class InterchangeOnlyAdapter:
|
||||
profile = BpmnExecutionProfile(
|
||||
id=INTERCHANGE_ADAPTER_ID,
|
||||
version="1.0.0",
|
||||
label="Historical model-only revision",
|
||||
description=(
|
||||
"Read historical BPMN 2.0 revisions that were stored without a "
|
||||
"native graph or executable semantics."
|
||||
),
|
||||
conformance="BPMN 2.0 interchange",
|
||||
runtime_kind="model_only",
|
||||
executable=False,
|
||||
supported_elements=("*",),
|
||||
)
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
*,
|
||||
activation: bool,
|
||||
) -> tuple[BpmnDiagnostic, ...]:
|
||||
del xml, inspection
|
||||
if not activation:
|
||||
return ()
|
||||
return (
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.model_only",
|
||||
message=(
|
||||
"This BPMN revision is model-only. Select an executable "
|
||||
"conformance profile and save a new revision before activation."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
) -> WorkflowGraph | None:
|
||||
del xml, inspection
|
||||
return None
|
||||
|
||||
|
||||
class NativeBpmnGraphAdapter:
|
||||
profile = BpmnExecutionProfile(
|
||||
id=NATIVE_BPMN_ADAPTER_ID,
|
||||
version=NATIVE_BPMN_ADAPTER_VERSION,
|
||||
label="GovOPlaN native BPMN",
|
||||
description=(
|
||||
"Use BPMN 2.0 as the canonical GovOPlaN graph language. The "
|
||||
"native graph preserves standard notation while activation "
|
||||
"fails closed for runtime semantics that are not implemented."
|
||||
),
|
||||
conformance="BPMN 2.0 native graph profile 1",
|
||||
runtime_kind="native_graph",
|
||||
executable=True,
|
||||
supported_elements=tuple(
|
||||
sorted(
|
||||
{
|
||||
"definitions",
|
||||
"process",
|
||||
"collaboration",
|
||||
"choreography",
|
||||
"documentation",
|
||||
"extensionElements",
|
||||
"incoming",
|
||||
"outgoing",
|
||||
"conditionExpression",
|
||||
"laneSet",
|
||||
"flowNodeRef",
|
||||
*BPMN_NODE_LOCAL_NAMES,
|
||||
*BPMN_EDGE_LOCAL_NAMES,
|
||||
}
|
||||
)
|
||||
),
|
||||
supported_event_definitions=(
|
||||
"message",
|
||||
"timer",
|
||||
"conditional",
|
||||
"signal",
|
||||
"error",
|
||||
"escalation",
|
||||
"compensation",
|
||||
"link",
|
||||
"cancel",
|
||||
"terminate",
|
||||
),
|
||||
requirements=(
|
||||
"BPMN is stored as the canonical native graph",
|
||||
"Imported XML is parsed with bounded, entity-safe inspection",
|
||||
"Unsupported execution semantics remain editable but block activation",
|
||||
),
|
||||
)
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
*,
|
||||
activation: bool,
|
||||
) -> tuple[BpmnDiagnostic, ...]:
|
||||
diagnostics = [
|
||||
item for item in inspection.diagnostics if item.severity == "error"
|
||||
]
|
||||
try:
|
||||
graph = import_bpmn_graph(xml)
|
||||
except BpmnGraphError as exc:
|
||||
diagnostics.extend(exc.diagnostics)
|
||||
return _deduplicate_diagnostics(diagnostics)
|
||||
if activation:
|
||||
diagnostics.extend(runtime_diagnostics(graph))
|
||||
return _deduplicate_diagnostics(diagnostics)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
) -> WorkflowGraph | None:
|
||||
del inspection
|
||||
return import_bpmn_graph(xml)
|
||||
|
||||
|
||||
class NativeLinearAdapter:
|
||||
_supported_elements = frozenset(
|
||||
{
|
||||
"definitions",
|
||||
"process",
|
||||
"documentation",
|
||||
"extensionElements",
|
||||
"incoming",
|
||||
"outgoing",
|
||||
"startEvent",
|
||||
"endEvent",
|
||||
"task",
|
||||
"manualTask",
|
||||
"userTask",
|
||||
"sequenceFlow",
|
||||
}
|
||||
)
|
||||
_node_types = frozenset(
|
||||
{"startEvent", "endEvent", "task", "manualTask", "userTask"}
|
||||
)
|
||||
|
||||
profile = BpmnExecutionProfile(
|
||||
id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
version="1.0.0",
|
||||
label="GovOPlaN native linear",
|
||||
description=(
|
||||
"Execute a single linear process containing a plain start event, "
|
||||
"human tasks, and one or more plain end events."
|
||||
),
|
||||
conformance="GovOPlaN native linear BPMN profile 1",
|
||||
runtime_kind="native_graph",
|
||||
executable=True,
|
||||
supported_elements=tuple(sorted(_supported_elements)),
|
||||
requirements=(
|
||||
"Exactly one executable process",
|
||||
"Exactly one plain start event",
|
||||
"No gateways, subprocesses, boundary events, or event definitions",
|
||||
"Every activity has one incoming and one outgoing sequence flow",
|
||||
),
|
||||
)
|
||||
|
||||
def diagnostics(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
*,
|
||||
activation: bool,
|
||||
) -> tuple[BpmnDiagnostic, ...]:
|
||||
del activation
|
||||
diagnostics = list(
|
||||
item
|
||||
for item in inspection.diagnostics
|
||||
if item.severity == "error"
|
||||
)
|
||||
unsupported = [
|
||||
item
|
||||
for item in inspection.elements
|
||||
if item.element_type not in self._supported_elements
|
||||
]
|
||||
for item in unsupported:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.unsupported_element",
|
||||
message=(
|
||||
f"{item.element_type} is not supported by the "
|
||||
f"{self.profile.label} profile."
|
||||
),
|
||||
element_id=item.element_id,
|
||||
)
|
||||
)
|
||||
if inspection.process_count != 1:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.process_count",
|
||||
message="The native linear profile requires exactly one process.",
|
||||
)
|
||||
)
|
||||
if inspection.executable_process_count != 1:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.executable_process",
|
||||
message=(
|
||||
"The native linear profile requires one process with "
|
||||
"isExecutable=\"true\"."
|
||||
),
|
||||
)
|
||||
)
|
||||
if diagnostics:
|
||||
return _deduplicate_diagnostics(diagnostics)
|
||||
|
||||
root = parse_bpmn_xml(xml)
|
||||
process = next(
|
||||
(
|
||||
item
|
||||
for item in root
|
||||
if _qualified_name(item.tag)
|
||||
== (BPMN_MODEL_NAMESPACE, "process")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if process is None:
|
||||
return (
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.process_missing",
|
||||
message="The executable BPMN process is missing.",
|
||||
),
|
||||
)
|
||||
node_ids = {
|
||||
item.attrib.get("id", "")
|
||||
for item in process
|
||||
if _qualified_name(item.tag)[1] in self._node_types
|
||||
and item.attrib.get("id")
|
||||
}
|
||||
starts = [
|
||||
item
|
||||
for item in process
|
||||
if _qualified_name(item.tag)[1] == "startEvent"
|
||||
]
|
||||
if len(starts) != 1:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.start_count",
|
||||
message="The native linear profile requires exactly one start event.",
|
||||
)
|
||||
)
|
||||
incoming: Counter[str] = Counter()
|
||||
outgoing: Counter[str] = Counter()
|
||||
for item in process:
|
||||
if _qualified_name(item.tag)[1] != "sequenceFlow":
|
||||
continue
|
||||
source = item.attrib.get("sourceRef", "")
|
||||
target = item.attrib.get("targetRef", "")
|
||||
if source in node_ids:
|
||||
outgoing[source] += 1
|
||||
if target in node_ids:
|
||||
incoming[target] += 1
|
||||
for item in process:
|
||||
_namespace, element_type = _qualified_name(item.tag)
|
||||
if element_type not in self._node_types:
|
||||
continue
|
||||
element_id = item.attrib.get("id")
|
||||
if not element_id:
|
||||
continue
|
||||
if element_type != "startEvent" and incoming[element_id] != 1:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.incoming_count",
|
||||
message=(
|
||||
f"{element_type} requires exactly one incoming "
|
||||
"sequence flow in the native linear profile."
|
||||
),
|
||||
element_id=element_id,
|
||||
)
|
||||
)
|
||||
if element_type != "endEvent" and outgoing[element_id] != 1:
|
||||
diagnostics.append(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.outgoing_count",
|
||||
message=(
|
||||
f"{element_type} requires exactly one outgoing "
|
||||
"sequence flow in the native linear profile."
|
||||
),
|
||||
element_id=element_id,
|
||||
)
|
||||
)
|
||||
return _deduplicate_diagnostics(diagnostics)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
xml: str,
|
||||
inspection: BpmnInspection,
|
||||
) -> WorkflowGraph | None:
|
||||
diagnostics = self.diagnostics(xml, inspection, activation=True)
|
||||
if any(item.severity == "error" for item in diagnostics):
|
||||
raise BpmnAdapterError(diagnostics)
|
||||
root = parse_bpmn_xml(xml)
|
||||
process = next(
|
||||
item
|
||||
for item in root
|
||||
if _qualified_name(item.tag) == (BPMN_MODEL_NAMESPACE, "process")
|
||||
)
|
||||
positions = _diagram_positions(root)
|
||||
raw_nodes = [
|
||||
item
|
||||
for item in process
|
||||
if _qualified_name(item.tag)[1] in self._node_types
|
||||
]
|
||||
id_map = {
|
||||
item.attrib["id"]: _graph_id(item.attrib["id"])
|
||||
for item in raw_nodes
|
||||
}
|
||||
fallback_positions = {
|
||||
item.attrib["id"]: WorkflowPosition(x=80 + index * 240, y=140)
|
||||
for index, item in enumerate(_linear_node_order(process, raw_nodes))
|
||||
}
|
||||
nodes: list[WorkflowNode] = []
|
||||
for item in raw_nodes:
|
||||
element_type = _qualified_name(item.tag)[1]
|
||||
bpmn_id = item.attrib["id"]
|
||||
label = item.attrib.get("name", "").strip()
|
||||
if element_type == "startEvent":
|
||||
node_type = "workflow.start.manual"
|
||||
config = {"input_schema_ref": ""}
|
||||
label = label or "Start"
|
||||
elif element_type == "endEvent":
|
||||
node_type = "workflow.end.completed"
|
||||
config = {"output_mapping": {}}
|
||||
label = label or "Completed"
|
||||
else:
|
||||
node_type = "workflow.activity"
|
||||
label = label or "Activity"
|
||||
config = {
|
||||
"title": label,
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
}
|
||||
nodes.append(
|
||||
WorkflowNode(
|
||||
id=id_map[bpmn_id],
|
||||
type=node_type,
|
||||
label=label,
|
||||
position=positions.get(bpmn_id, fallback_positions[bpmn_id]),
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
edges = [
|
||||
WorkflowEdge(
|
||||
id=_graph_id(item.attrib["id"]),
|
||||
source=id_map[item.attrib["sourceRef"]],
|
||||
target=id_map[item.attrib["targetRef"]],
|
||||
)
|
||||
for item in process
|
||||
if _qualified_name(item.tag)[1] == "sequenceFlow"
|
||||
]
|
||||
return WorkflowGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
def assess_bpmn_adapter(
|
||||
xml: str,
|
||||
*,
|
||||
adapter_id: str,
|
||||
adapter_version: str | None = None,
|
||||
activation: bool = False,
|
||||
registry: BpmnExecutionAdapterRegistry | None = None,
|
||||
) -> tuple[
|
||||
BpmnExecutionAdapter,
|
||||
BpmnInspection,
|
||||
tuple[BpmnDiagnostic, ...],
|
||||
]:
|
||||
active_registry = registry or bpmn_adapter_registry()
|
||||
adapter = active_registry.require(adapter_id, adapter_version)
|
||||
inspection = inspect_bpmn_xml(xml)
|
||||
diagnostics = adapter.diagnostics(
|
||||
xml,
|
||||
inspection,
|
||||
activation=activation,
|
||||
)
|
||||
return adapter, inspection, diagnostics
|
||||
|
||||
|
||||
def compile_bpmn_to_graph(
|
||||
xml: str,
|
||||
*,
|
||||
adapter_id: str,
|
||||
adapter_version: str | None = None,
|
||||
activation: bool = False,
|
||||
registry: BpmnExecutionAdapterRegistry | None = None,
|
||||
) -> tuple[BpmnExecutionAdapter, BpmnInspection, WorkflowGraph | None]:
|
||||
adapter, inspection, diagnostics = assess_bpmn_adapter(
|
||||
xml,
|
||||
adapter_id=adapter_id,
|
||||
adapter_version=adapter_version,
|
||||
activation=activation,
|
||||
registry=registry,
|
||||
)
|
||||
if any(item.severity == "error" for item in diagnostics):
|
||||
raise BpmnAdapterError(diagnostics)
|
||||
graph = adapter.compile(xml, inspection)
|
||||
if adapter.profile.executable and graph is None:
|
||||
raise BpmnAdapterError(
|
||||
(
|
||||
BpmnDiagnostic(
|
||||
severity="error",
|
||||
code="adapter.no_runtime_materialization",
|
||||
message=(
|
||||
f"{adapter.profile.label} did not produce executable "
|
||||
"Workflow runtime materialization."
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
return adapter, inspection, graph
|
||||
|
||||
|
||||
_registry: BpmnExecutionAdapterRegistry | None = None
|
||||
_registry_lock = Lock()
|
||||
|
||||
|
||||
def bpmn_adapter_registry() -> BpmnExecutionAdapterRegistry:
|
||||
global _registry
|
||||
if _registry is not None:
|
||||
return _registry
|
||||
with _registry_lock:
|
||||
if _registry is not None:
|
||||
return _registry
|
||||
registry = BpmnExecutionAdapterRegistry()
|
||||
registry.register(NativeBpmnGraphAdapter())
|
||||
registry.register(NativeLinearAdapter())
|
||||
registry.register(InterchangeOnlyAdapter())
|
||||
for entry_point in entry_points(group=BPMN_ADAPTER_ENTRY_POINT_GROUP):
|
||||
try:
|
||||
loaded = entry_point.load()
|
||||
candidate = (
|
||||
loaded()
|
||||
if callable(loaded) and not hasattr(loaded, "profile")
|
||||
else loaded
|
||||
)
|
||||
registry.register(candidate)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Could not register BPMN execution adapter %s",
|
||||
entry_point.name,
|
||||
)
|
||||
_registry = registry
|
||||
return registry
|
||||
|
||||
|
||||
def _diagram_positions(root: Element) -> dict[str, WorkflowPosition]:
|
||||
positions: dict[str, WorkflowPosition] = {}
|
||||
for item in root.iter():
|
||||
if _qualified_name(item.tag) != (BPMN_DI_NAMESPACE, "BPMNShape"):
|
||||
continue
|
||||
element_id = item.attrib.get("bpmnElement")
|
||||
if not element_id:
|
||||
continue
|
||||
bounds = next(
|
||||
(
|
||||
child
|
||||
for child in item
|
||||
if _qualified_name(child.tag) == (OMG_DC_NAMESPACE, "Bounds")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if bounds is None:
|
||||
continue
|
||||
try:
|
||||
positions[element_id] = WorkflowPosition(
|
||||
x=float(bounds.attrib.get("x", "0")),
|
||||
y=float(bounds.attrib.get("y", "0")),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
return positions
|
||||
|
||||
|
||||
def _linear_node_order(
|
||||
process: Element,
|
||||
nodes: list[Element],
|
||||
) -> list[Element]:
|
||||
by_id = {item.attrib["id"]: item for item in nodes}
|
||||
target_by_source = {
|
||||
item.attrib.get("sourceRef"): item.attrib.get("targetRef")
|
||||
for item in process
|
||||
if _qualified_name(item.tag)[1] == "sequenceFlow"
|
||||
}
|
||||
start = next(
|
||||
(
|
||||
item
|
||||
for item in nodes
|
||||
if _qualified_name(item.tag)[1] == "startEvent"
|
||||
),
|
||||
None,
|
||||
)
|
||||
ordered: list[Element] = []
|
||||
seen: set[str] = set()
|
||||
current = start
|
||||
while current is not None:
|
||||
current_id = current.attrib["id"]
|
||||
if current_id in seen:
|
||||
break
|
||||
seen.add(current_id)
|
||||
ordered.append(current)
|
||||
current = by_id.get(target_by_source.get(current_id, ""))
|
||||
ordered.extend(item for item in nodes if item.attrib["id"] not in seen)
|
||||
return ordered
|
||||
|
||||
|
||||
def _graph_id(value: str) -> str:
|
||||
if len(value) <= 120:
|
||||
return value
|
||||
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{value[:103]}-{digest}"
|
||||
|
||||
|
||||
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 _deduplicate_diagnostics(
|
||||
diagnostics: list[BpmnDiagnostic],
|
||||
) -> tuple[BpmnDiagnostic, ...]:
|
||||
seen: set[tuple[str, str | None, str]] = set()
|
||||
result: list[BpmnDiagnostic] = []
|
||||
for item in diagnostics:
|
||||
key = (item.code, item.element_id, item.message)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BPMN_ADAPTER_ENTRY_POINT_GROUP",
|
||||
"INTERCHANGE_ADAPTER_ID",
|
||||
"NATIVE_BPMN_ADAPTER_ID",
|
||||
"NATIVE_LINEAR_ADAPTER_ID",
|
||||
"BpmnAdapterError",
|
||||
"BpmnExecutionAdapter",
|
||||
"BpmnExecutionAdapterRegistry",
|
||||
"BpmnExecutionProfile",
|
||||
"RuntimeKind",
|
||||
"assess_bpmn_adapter",
|
||||
"bpmn_adapter_registry",
|
||||
"compile_bpmn_to_graph",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.workflows import (
|
||||
WorkflowDefinitionContribution,
|
||||
workflow_definition_contribution_hash,
|
||||
)
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
BpmnRevisionInput,
|
||||
WorkflowGraph,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowConflictError,
|
||||
WorkflowNotFoundError,
|
||||
_available_key,
|
||||
_new_revision,
|
||||
_prepare_revision_content,
|
||||
_slug,
|
||||
_validate_bpmn_activation,
|
||||
_validate_execution_mode_activation,
|
||||
get_definition,
|
||||
)
|
||||
|
||||
|
||||
def reconcile_workflow_definition_contributions(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object | None,
|
||||
tenant_ids: Sequence[str] = (),
|
||||
) -> dict[str, object]:
|
||||
contributions = _registered_contributions(registry)
|
||||
normalized_tenant_ids = tuple(
|
||||
dict.fromkeys(str(item).strip() for item in tenant_ids if str(item).strip())
|
||||
)
|
||||
result: dict[str, object] = {
|
||||
"discovered": len(contributions),
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"blocked": 0,
|
||||
"pending_tenant_scope": 0,
|
||||
"items": [],
|
||||
}
|
||||
items = cast(list[dict[str, object]], result["items"])
|
||||
for contribution in contributions:
|
||||
targets: tuple[str | None, ...]
|
||||
if contribution.scope_type == "system":
|
||||
targets = (None,)
|
||||
elif normalized_tenant_ids:
|
||||
targets = normalized_tenant_ids
|
||||
else:
|
||||
result["pending_tenant_scope"] = (
|
||||
int(result["pending_tenant_scope"]) + 1
|
||||
)
|
||||
items.append(
|
||||
_item(
|
||||
contribution,
|
||||
state="pending_tenant_scope",
|
||||
message="A tenant-scoped standard needs an explicit tenant target.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
missing = _missing_requirements(contribution, registry)
|
||||
if missing:
|
||||
result["blocked"] = int(result["blocked"]) + len(targets)
|
||||
for tenant_id in targets:
|
||||
items.append(
|
||||
_item(
|
||||
contribution,
|
||||
tenant_id=tenant_id,
|
||||
state="blocked",
|
||||
message="Required Engine integration is unavailable.",
|
||||
missing=missing,
|
||||
)
|
||||
)
|
||||
continue
|
||||
for tenant_id in targets:
|
||||
try:
|
||||
with session.begin_nested():
|
||||
state, definition = _reconcile_contribution(
|
||||
session,
|
||||
contribution=contribution,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except (ValueError, WorkflowConflictError) as exc:
|
||||
result["blocked"] = int(result["blocked"]) + 1
|
||||
items.append(
|
||||
_item(
|
||||
contribution,
|
||||
tenant_id=tenant_id,
|
||||
state="blocked",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
continue
|
||||
result[state] = int(result[state]) + 1
|
||||
items.append(
|
||||
_item(
|
||||
contribution,
|
||||
tenant_id=tenant_id,
|
||||
state=state,
|
||||
definition_id=definition.id,
|
||||
revision=definition.current_revision,
|
||||
active_revision=definition.active_revision,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return result
|
||||
|
||||
|
||||
def reset_workflow_override_to_standard(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
definition_id: str,
|
||||
actor_id: str | None,
|
||||
) -> WorkflowDefinition:
|
||||
override = get_definition(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
if not override.derived_from_definition_id:
|
||||
raise WorkflowConflictError(
|
||||
"Only a definition derived from a module standard can be reset."
|
||||
)
|
||||
baseline = session.get(
|
||||
WorkflowDefinition,
|
||||
override.derived_from_definition_id,
|
||||
)
|
||||
if (
|
||||
baseline is None
|
||||
or baseline.deleted_at is not None
|
||||
or not baseline.standard_origin_module_id
|
||||
):
|
||||
raise WorkflowNotFoundError(
|
||||
"The module standard behind this override is unavailable."
|
||||
)
|
||||
now = utcnow()
|
||||
override.status = "archived"
|
||||
override.updated_by = actor_id
|
||||
override.metadata_ = {
|
||||
**dict(override.metadata_),
|
||||
"standard_reset": {
|
||||
"baseline_definition_id": baseline.id,
|
||||
"baseline_revision": baseline.current_revision,
|
||||
"baseline_hash": baseline.standard_contribution_hash,
|
||||
"reset_by": actor_id,
|
||||
"reset_at": now.isoformat(),
|
||||
},
|
||||
}
|
||||
session.flush()
|
||||
return baseline
|
||||
|
||||
|
||||
class SqlWorkflowDefinitionContributionProvider:
|
||||
def __init__(self, *, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_ids: Sequence[str] = (),
|
||||
) -> Mapping[str, object]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Workflow contribution reconciliation requires a Session.")
|
||||
return reconcile_workflow_definition_contributions(
|
||||
session,
|
||||
registry=self._registry,
|
||||
tenant_ids=tenant_ids,
|
||||
)
|
||||
|
||||
|
||||
def _registered_contributions(
|
||||
registry: object | None,
|
||||
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||
if registry is None or not hasattr(registry, "manifests"):
|
||||
return ()
|
||||
return tuple(
|
||||
contribution
|
||||
for manifest in registry.manifests()
|
||||
for contribution in manifest.workflow_definitions
|
||||
)
|
||||
|
||||
|
||||
def _missing_requirements(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
registry: object | None,
|
||||
) -> tuple[str, ...]:
|
||||
if registry is None:
|
||||
return tuple(
|
||||
[
|
||||
*(f"capability:{item}" for item in contribution.required_capabilities),
|
||||
*(f"interface:{item}" for item in contribution.required_interfaces),
|
||||
]
|
||||
)
|
||||
missing = [
|
||||
f"capability:{item}"
|
||||
for item in contribution.required_capabilities
|
||||
if not (
|
||||
hasattr(registry, "has_capability")
|
||||
and registry.has_capability(item)
|
||||
)
|
||||
]
|
||||
available_interfaces = {
|
||||
item.name
|
||||
for manifest in registry.manifests()
|
||||
for item in manifest.provides_interfaces
|
||||
}
|
||||
missing.extend(
|
||||
f"interface:{item}"
|
||||
for item in contribution.required_interfaces
|
||||
if item not in available_interfaces
|
||||
)
|
||||
return tuple(missing)
|
||||
|
||||
|
||||
def _reconcile_contribution(
|
||||
session: Session,
|
||||
*,
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
tenant_id: str | None,
|
||||
) -> tuple[str, WorkflowDefinition]:
|
||||
scope_key = "system" if tenant_id is None else f"tenant:{tenant_id}"
|
||||
contribution_hash = (
|
||||
contribution.content_hash
|
||||
or workflow_definition_contribution_hash(contribution)
|
||||
)
|
||||
definition = session.scalar(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.scope_key == scope_key,
|
||||
WorkflowDefinition.standard_origin_module_id
|
||||
== contribution.origin_module_id,
|
||||
WorkflowDefinition.standard_definition_key
|
||||
== contribution.definition_key,
|
||||
)
|
||||
)
|
||||
graph, bpmn = _prepare_revision_content(
|
||||
graph=WorkflowGraph.model_validate(contribution.graph),
|
||||
bpmn=(
|
||||
BpmnRevisionInput(
|
||||
xml=contribution.bpmn_xml,
|
||||
adapter_id=contribution.bpmn_adapter_id,
|
||||
adapter_version=contribution.bpmn_adapter_version,
|
||||
)
|
||||
if contribution.bpmn_xml
|
||||
else None
|
||||
),
|
||||
)
|
||||
now = utcnow()
|
||||
if definition is None:
|
||||
definition = WorkflowDefinition(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=contribution.scope_type,
|
||||
scope_id=tenant_id,
|
||||
scope_key=scope_key,
|
||||
definition_kind=contribution.definition_kind,
|
||||
inherit_to_lower_scopes=contribution.inherit_to_lower_scopes,
|
||||
allow_start=contribution.allow_start,
|
||||
allow_reuse=contribution.allow_reuse,
|
||||
allow_automation=contribution.allow_automation,
|
||||
standard_origin_module_id=contribution.origin_module_id,
|
||||
standard_definition_key=contribution.definition_key,
|
||||
standard_origin_module_version=contribution.origin_module_version,
|
||||
standard_contribution_schema_version=(
|
||||
contribution.contribution_schema_version
|
||||
),
|
||||
standard_contribution_hash=contribution_hash,
|
||||
standard_reconciled_at=now,
|
||||
definition_key=_available_key(
|
||||
session,
|
||||
scope_key=scope_key,
|
||||
requested=_standard_storage_key(contribution),
|
||||
name=contribution.name,
|
||||
),
|
||||
name=contribution.name.strip(),
|
||||
description=_clean_optional(contribution.description),
|
||||
status="draft",
|
||||
current_revision=1,
|
||||
active_revision=None,
|
||||
metadata_={
|
||||
**dict(contribution.metadata),
|
||||
"workflow_standard": _contribution_metadata(contribution),
|
||||
},
|
||||
created_by=f"module:{contribution.origin_module_id}",
|
||||
updated_by=f"module:{contribution.origin_module_id}",
|
||||
)
|
||||
revision = _contribution_revision(
|
||||
contribution,
|
||||
tenant_id=tenant_id,
|
||||
revision=1,
|
||||
graph=graph,
|
||||
bpmn=bpmn,
|
||||
contribution_hash=contribution_hash,
|
||||
)
|
||||
_validate_contribution_activation(contribution, revision)
|
||||
definition.revisions.append(revision)
|
||||
if contribution.activate_on_install and contribution.definition_kind == "flow":
|
||||
definition.status = "active"
|
||||
definition.active_revision = 1
|
||||
session.add(definition)
|
||||
session.flush()
|
||||
return "created", definition
|
||||
|
||||
if definition.standard_contribution_hash == contribution_hash:
|
||||
if (
|
||||
definition.standard_origin_module_version
|
||||
!= contribution.origin_module_version
|
||||
or definition.standard_contribution_schema_version
|
||||
!= contribution.contribution_schema_version
|
||||
):
|
||||
definition.standard_origin_module_version = (
|
||||
contribution.origin_module_version
|
||||
)
|
||||
definition.standard_contribution_schema_version = (
|
||||
contribution.contribution_schema_version
|
||||
)
|
||||
definition.standard_reconciled_at = now
|
||||
definition.updated_by = f"module:{contribution.origin_module_id}"
|
||||
session.flush()
|
||||
return "unchanged", definition
|
||||
|
||||
definition.standard_origin_module_version = contribution.origin_module_version
|
||||
definition.standard_contribution_schema_version = (
|
||||
contribution.contribution_schema_version
|
||||
)
|
||||
definition.standard_reconciled_at = now
|
||||
definition.updated_by = f"module:{contribution.origin_module_id}"
|
||||
revision_number = definition.current_revision + 1
|
||||
revision = _contribution_revision(
|
||||
contribution,
|
||||
tenant_id=tenant_id,
|
||||
revision=revision_number,
|
||||
graph=graph,
|
||||
bpmn=bpmn,
|
||||
contribution_hash=contribution_hash,
|
||||
)
|
||||
_validate_contribution_activation(contribution, revision)
|
||||
definition.name = contribution.name.strip()
|
||||
definition.description = _clean_optional(contribution.description)
|
||||
definition.definition_kind = contribution.definition_kind
|
||||
definition.inherit_to_lower_scopes = contribution.inherit_to_lower_scopes
|
||||
definition.allow_start = contribution.allow_start
|
||||
definition.allow_reuse = contribution.allow_reuse
|
||||
definition.allow_automation = contribution.allow_automation
|
||||
definition.metadata_ = {
|
||||
**dict(contribution.metadata),
|
||||
"workflow_standard": _contribution_metadata(contribution),
|
||||
}
|
||||
definition.current_revision = revision_number
|
||||
definition.standard_contribution_hash = contribution_hash
|
||||
definition.revisions.append(revision)
|
||||
if (
|
||||
definition.active_revision is None
|
||||
and contribution.activate_on_install
|
||||
and contribution.definition_kind == "flow"
|
||||
):
|
||||
definition.active_revision = revision_number
|
||||
definition.status = "active"
|
||||
session.flush()
|
||||
return "updated", definition
|
||||
|
||||
|
||||
def _contribution_revision(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
revision: int,
|
||||
graph: WorkflowGraph,
|
||||
bpmn: Any,
|
||||
contribution_hash: str,
|
||||
) -> WorkflowDefinitionRevision:
|
||||
item = _new_revision(
|
||||
tenant_id=tenant_id,
|
||||
revision=revision,
|
||||
graph=graph,
|
||||
bpmn=bpmn,
|
||||
execution_mode=contribution.execution_mode,
|
||||
view_id=contribution.view_id,
|
||||
view_revision_id=contribution.view_revision_id,
|
||||
actor_id=f"module:{contribution.origin_module_id}",
|
||||
)
|
||||
item.contribution_origin_module_version = contribution.origin_module_version
|
||||
item.contribution_schema_version = contribution.contribution_schema_version
|
||||
item.contribution_hash = contribution_hash
|
||||
item.contribution_metadata = _contribution_metadata(contribution)
|
||||
return item
|
||||
|
||||
|
||||
def _validate_contribution_activation(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
revision: WorkflowDefinitionRevision,
|
||||
) -> None:
|
||||
if not contribution.activate_on_install or contribution.definition_kind == "template":
|
||||
return
|
||||
_validate_bpmn_activation(revision)
|
||||
_validate_execution_mode_activation(revision)
|
||||
|
||||
|
||||
def _standard_storage_key(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
) -> str:
|
||||
base = _slug(
|
||||
f"{contribution.origin_module_id}-{contribution.definition_key}"
|
||||
)
|
||||
return base[:120]
|
||||
|
||||
|
||||
def _contribution_metadata(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"origin_module_id": contribution.origin_module_id,
|
||||
"origin_module_version": contribution.origin_module_version,
|
||||
"definition_key": contribution.definition_key,
|
||||
"schema_version": contribution.contribution_schema_version,
|
||||
"required_capabilities": list(contribution.required_capabilities),
|
||||
"required_interfaces": list(contribution.required_interfaces),
|
||||
"policy": dict(contribution.policy_metadata),
|
||||
}
|
||||
|
||||
|
||||
def _clean_optional(value: str | None) -> str | None:
|
||||
cleaned = str(value or "").strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _item(
|
||||
contribution: WorkflowDefinitionContribution,
|
||||
*,
|
||||
state: str,
|
||||
tenant_id: str | None = None,
|
||||
message: str | None = None,
|
||||
missing: Sequence[str] = (),
|
||||
definition_id: str | None = None,
|
||||
revision: int | None = None,
|
||||
active_revision: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"origin_module_id": contribution.origin_module_id,
|
||||
"origin_module_version": contribution.origin_module_version,
|
||||
"definition_key": contribution.definition_key,
|
||||
"scope_type": contribution.scope_type,
|
||||
"tenant_id": tenant_id,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"missing": list(missing),
|
||||
"definition_id": definition_id,
|
||||
"revision": revision,
|
||||
"active_revision": active_revision,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SqlWorkflowDefinitionContributionProvider",
|
||||
"reconcile_workflow_definition_contributions",
|
||||
"reset_workflow_override_to_standard",
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WorkflowDefinition",
|
||||
"WorkflowDefinitionRevision",
|
||||
"WorkflowInstance",
|
||||
"WorkflowInstanceEvent",
|
||||
"WorkflowInstanceStep",
|
||||
]
|
||||
@@ -0,0 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class WorkflowDefinition(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_definitions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"scope_key",
|
||||
"definition_key",
|
||||
name="uq_workflow_definition_key",
|
||||
),
|
||||
Index("ix_workflow_definitions_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_workflow_definitions_tenant_updated", "tenant_id", "updated_at"),
|
||||
Index(
|
||||
"uq_workflow_standard_origin_scope",
|
||||
"scope_key",
|
||||
"standard_origin_module_id",
|
||||
"standard_definition_key",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="tenant",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
scope_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_key: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
definition_kind: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="flow",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
inherit_to_lower_scopes: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
allow_start: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
allow_reuse: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
allow_automation: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
derived_from_definition_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
derived_from_revision: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
derived_from_hash: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
derivation_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
standard_origin_module_id: Mapped[str | None] = mapped_column(
|
||||
String(120),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
standard_definition_key: Mapped[str | None] = mapped_column(
|
||||
String(120),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
standard_origin_module_version: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
nullable=True,
|
||||
)
|
||||
standard_contribution_schema_version: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
nullable=True,
|
||||
)
|
||||
standard_contribution_hash: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
standard_reconciled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
definition_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default="draft",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
active_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
revisions: Mapped[list["WorkflowDefinitionRevision"]] = relationship(
|
||||
back_populates="definition",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowDefinitionRevision.revision",
|
||||
)
|
||||
instances: Mapped[list["WorkflowInstance"]] = relationship(
|
||||
back_populates="definition",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstance.created_at",
|
||||
)
|
||||
|
||||
|
||||
class WorkflowDefinitionRevision(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_definition_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"definition_id",
|
||||
"revision",
|
||||
name="uq_workflow_definition_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_definition_revisions_tenant_definition",
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_definition_revisions_content_hash",
|
||||
"tenant_id",
|
||||
"content_hash",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_definition_revisions_contribution_hash",
|
||||
"contribution_hash",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
definition_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
graph: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
library_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
library_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
execution_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="hybrid",
|
||||
nullable=False,
|
||||
)
|
||||
view_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
view_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
)
|
||||
bpmn_xml: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
bpmn_hash: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
bpmn_adapter_id: Mapped[str | None] = mapped_column(
|
||||
String(120),
|
||||
nullable=True,
|
||||
)
|
||||
bpmn_adapter_version: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
nullable=True,
|
||||
)
|
||||
bpmn_runtime_kind: Mapped[str | None] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
bpmn_executable: Mapped[bool | None] = mapped_column(
|
||||
Boolean,
|
||||
nullable=True,
|
||||
)
|
||||
contribution_origin_module_version: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
nullable=True,
|
||||
)
|
||||
contribution_schema_version: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
nullable=True,
|
||||
)
|
||||
contribution_hash: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
contribution_metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
definition: Mapped[WorkflowDefinition] = relationship(back_populates="revisions")
|
||||
|
||||
|
||||
class WorkflowInstance(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_instances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"idempotency_key",
|
||||
name="uq_workflow_instance_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
"status",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
definition_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
definition_revision_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definition_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="running",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
start_origin: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="user",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
correlation_id: Mapped[str | None] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
current_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
input_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"input",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
context_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"context",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
output_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"output",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
authorization_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"authorization",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
cancellation_requested_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
definition: Mapped[WorkflowDefinition] = relationship(
|
||||
back_populates="instances"
|
||||
)
|
||||
steps: Mapped[list["WorkflowInstanceStep"]] = relationship(
|
||||
back_populates="instance",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstanceStep.sequence",
|
||||
)
|
||||
events: Mapped[list["WorkflowInstanceEvent"]] = relationship(
|
||||
back_populates="instance",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstanceEvent.sequence",
|
||||
)
|
||||
|
||||
|
||||
class WorkflowInstanceStep(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_instance_steps"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_step_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
instance_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
node_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
attempt: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
input_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"input",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
output_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"output",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
handoff: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
external_ref: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
instance: Mapped[WorkflowInstance] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
class WorkflowInstanceEvent(Base):
|
||||
__tablename__ = "workflow_instance_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_event_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
"tenant_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
instance_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
step_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
instance: Mapped[WorkflowInstance] = relationship(back_populates="events")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkflowDefinition",
|
||||
"WorkflowDefinitionRevision",
|
||||
"WorkflowInstance",
|
||||
"WorkflowInstanceEvent",
|
||||
"WorkflowInstanceStep",
|
||||
"new_uuid",
|
||||
]
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.policy import (
|
||||
DefinitionGovernanceAction,
|
||||
DefinitionGovernanceRequest,
|
||||
DefinitionScopeRef,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_core.core.workflows import workflow_runtime_worker
|
||||
from govoplan_workflow_engine.backend.db.models import WorkflowDefinition
|
||||
|
||||
|
||||
WorkflowAction = Literal[
|
||||
"view",
|
||||
"edit",
|
||||
"start",
|
||||
"reuse",
|
||||
"derive",
|
||||
"automate",
|
||||
]
|
||||
WORKFLOW_ACTIONS: tuple[WorkflowAction, ...] = (
|
||||
"view",
|
||||
"edit",
|
||||
"start",
|
||||
"reuse",
|
||||
"derive",
|
||||
"automate",
|
||||
)
|
||||
|
||||
|
||||
def normalize_definition_scope(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
administrative: bool,
|
||||
) -> tuple[str | None, str, str | None, str]:
|
||||
clean_type = scope_type.strip().casefold()
|
||||
clean_id = str(scope_id or "").strip() or None
|
||||
if clean_type == "system":
|
||||
if not has_scope(principal, "system:governance:write"):
|
||||
raise PermissionError(
|
||||
"System definitions require system governance permission."
|
||||
)
|
||||
if clean_id is not None:
|
||||
raise ValueError("System definitions do not carry a scope ID.")
|
||||
return None, "system", None, "system"
|
||||
if clean_type == "tenant":
|
||||
if clean_id not in {None, principal.tenant_id}:
|
||||
raise PermissionError(
|
||||
"Definitions can only be created for the active tenant."
|
||||
)
|
||||
return (
|
||||
principal.tenant_id,
|
||||
"tenant",
|
||||
principal.tenant_id,
|
||||
f"tenant:{principal.tenant_id}",
|
||||
)
|
||||
if clean_type == "group":
|
||||
if not clean_id:
|
||||
raise ValueError("Group definitions require a group ID.")
|
||||
if clean_id not in principal.group_ids and not administrative:
|
||||
raise PermissionError(
|
||||
"Definitions can only be created for one of the actor's groups."
|
||||
)
|
||||
return principal.tenant_id, "group", clean_id, f"group:{clean_id}"
|
||||
if clean_type == "user":
|
||||
clean_id = clean_id or principal.account_id
|
||||
if (
|
||||
clean_id not in {principal.membership_id, principal.account_id}
|
||||
and not administrative
|
||||
):
|
||||
raise PermissionError(
|
||||
"Definitions can only be created for the current user."
|
||||
)
|
||||
if clean_id == principal.membership_id:
|
||||
clean_id = principal.account_id
|
||||
return principal.tenant_id, "user", clean_id, f"user:{clean_id}"
|
||||
raise ValueError(
|
||||
"Definition scope must be system, tenant, group, or user."
|
||||
)
|
||||
|
||||
|
||||
def definition_decision(
|
||||
definition: WorkflowDefinition,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
action: WorkflowAction,
|
||||
) -> PolicyDecision:
|
||||
policy_action: DefinitionGovernanceAction = (
|
||||
"run" if action == "start" else action
|
||||
)
|
||||
request = DefinitionGovernanceRequest(
|
||||
module_id="workflow",
|
||||
definition_ref=f"workflow-definition:{definition.id}",
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_scope=DefinitionScopeRef(
|
||||
scope_type=definition.scope_type, # type: ignore[arg-type]
|
||||
scope_id=definition.scope_id,
|
||||
),
|
||||
target_scope=_target_scope(definition, principal),
|
||||
definition_kind=definition.definition_kind, # type: ignore[arg-type]
|
||||
action=policy_action,
|
||||
actor=principal.to_platform_principal(),
|
||||
status=definition.status,
|
||||
inherit_to_lower_scopes=definition.inherit_to_lower_scopes,
|
||||
allow_run=definition.allow_start,
|
||||
allow_reuse=definition.allow_reuse,
|
||||
allow_automation=definition.allow_automation,
|
||||
context=_ancestor_context(definition),
|
||||
)
|
||||
provider = definition_governance_policy(registry)
|
||||
if provider is not None:
|
||||
return provider.resolve_definition_action(request=request)
|
||||
return _tenant_local_fallback(request, displayed_action=action)
|
||||
|
||||
|
||||
def definition_governance_payload(
|
||||
definition: WorkflowDefinition,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
) -> dict[str, object]:
|
||||
runtime_available = workflow_runtime_worker(registry) is not None
|
||||
actions = {
|
||||
action: definition_decision(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
action=action,
|
||||
).to_dict()
|
||||
for action in WORKFLOW_ACTIONS
|
||||
}
|
||||
return {
|
||||
"scope_type": definition.scope_type,
|
||||
"scope_id": definition.scope_id,
|
||||
"definition_kind": definition.definition_kind,
|
||||
"inherit_to_lower_scopes": definition.inherit_to_lower_scopes,
|
||||
"allow_start": definition.allow_start,
|
||||
"allow_reuse": definition.allow_reuse,
|
||||
"allow_automation": definition.allow_automation,
|
||||
"derived_from_definition_id": (
|
||||
definition.derived_from_definition_id
|
||||
),
|
||||
"derived_from_revision": definition.derived_from_revision,
|
||||
"derived_from_hash": definition.derived_from_hash,
|
||||
"derivation_provenance": dict(definition.derivation_provenance),
|
||||
"actions": actions,
|
||||
"automation_runtime_available": runtime_available,
|
||||
"automation_runtime_reason": (
|
||||
None
|
||||
if runtime_available
|
||||
else "Automatic reconciliation requires the Workflow runtime worker."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def require_definition_action(
|
||||
definition: WorkflowDefinition,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
action: WorkflowAction,
|
||||
) -> PolicyDecision:
|
||||
decision = definition_decision(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
action=action,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise PermissionError(
|
||||
decision.reason or f"Definition action is not allowed: {action}"
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def _target_scope(
|
||||
definition: WorkflowDefinition,
|
||||
principal: ApiPrincipal,
|
||||
) -> DefinitionScopeRef:
|
||||
if (
|
||||
definition.scope_type == "group"
|
||||
and definition.scope_id in principal.group_ids
|
||||
):
|
||||
return DefinitionScopeRef("group", definition.scope_id)
|
||||
if definition.scope_type == "user" and definition.scope_id in {
|
||||
principal.membership_id,
|
||||
principal.account_id,
|
||||
}:
|
||||
return DefinitionScopeRef("user", definition.scope_id)
|
||||
return DefinitionScopeRef("tenant", principal.tenant_id)
|
||||
|
||||
|
||||
def _ancestor_context(
|
||||
definition: WorkflowDefinition,
|
||||
) -> dict[str, object]:
|
||||
provenance = definition.derivation_provenance
|
||||
limits = provenance.get("source_effective_limits")
|
||||
source = provenance.get("source_scope")
|
||||
context: dict[str, object] = {}
|
||||
if isinstance(limits, Mapping):
|
||||
context["ancestor_limits"] = {
|
||||
"inherit_to_lower_scopes": bool(
|
||||
limits.get("inherit_to_lower_scopes", True)
|
||||
),
|
||||
"allow_run": bool(limits.get("allow_start")),
|
||||
"allow_reuse": bool(limits.get("allow_reuse")),
|
||||
"allow_automation": bool(limits.get("allow_automation")),
|
||||
}
|
||||
if isinstance(source, Mapping):
|
||||
context["ancestor_source"] = dict(source)
|
||||
return context
|
||||
|
||||
|
||||
def _tenant_local_fallback(
|
||||
request: DefinitionGovernanceRequest,
|
||||
*,
|
||||
displayed_action: WorkflowAction,
|
||||
) -> PolicyDecision:
|
||||
ancestor = request.context.get("ancestor_limits")
|
||||
ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {}
|
||||
effective_limits = {
|
||||
"inherit_to_lower_scopes": (
|
||||
request.inherit_to_lower_scopes
|
||||
and _fallback_ancestor_flag(
|
||||
ancestor_limits,
|
||||
"inherit_to_lower_scopes",
|
||||
)
|
||||
),
|
||||
"allow_run": request.allow_run
|
||||
and _fallback_ancestor_flag(ancestor_limits, "allow_run"),
|
||||
"allow_reuse": request.allow_reuse
|
||||
and _fallback_ancestor_flag(ancestor_limits, "allow_reuse"),
|
||||
"allow_automation": request.allow_automation
|
||||
and _fallback_ancestor_flag(
|
||||
ancestor_limits,
|
||||
"allow_automation",
|
||||
),
|
||||
}
|
||||
local = (
|
||||
request.definition_scope.scope_type == "tenant"
|
||||
and request.definition_scope.scope_id == request.tenant_id
|
||||
and request.actor.tenant_id == request.tenant_id
|
||||
)
|
||||
allowed = False
|
||||
reason: str | None = None
|
||||
if not local:
|
||||
reason = (
|
||||
"Inherited definitions require the Policy module; only local "
|
||||
"tenant definitions are available."
|
||||
)
|
||||
elif request.action in {"view", "edit"}:
|
||||
allowed = True
|
||||
elif request.action == "run":
|
||||
allowed = (
|
||||
request.definition_kind == "flow"
|
||||
and request.status == "active"
|
||||
and effective_limits["allow_run"]
|
||||
)
|
||||
reason = (
|
||||
None
|
||||
if allowed
|
||||
else "Only active local flows with starting enabled can start."
|
||||
)
|
||||
else:
|
||||
reason = "Definition reuse and automation require the Policy module."
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type=request.definition_scope.scope_type,
|
||||
scope_id=request.definition_scope.scope_id,
|
||||
label="Tenant-local conservative fallback",
|
||||
applied_fields=(
|
||||
"definition_kind",
|
||||
"status",
|
||||
"allow_run",
|
||||
),
|
||||
policy={
|
||||
"policy_module_available": False,
|
||||
"definition_kind": request.definition_kind,
|
||||
"status": request.status,
|
||||
"allow_start": request.allow_run,
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
()
|
||||
if allowed
|
||||
else (f"workflow.definition.{displayed_action}",)
|
||||
),
|
||||
details={
|
||||
"fallback": "tenant_local",
|
||||
"action": displayed_action,
|
||||
"effective_limits": effective_limits,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _fallback_ancestor_flag(
|
||||
limits: Mapping[str, object],
|
||||
key: str,
|
||||
) -> bool:
|
||||
value = limits.get(key)
|
||||
return True if value is None else value is True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WORKFLOW_ACTIONS",
|
||||
"definition_decision",
|
||||
"definition_governance_payload",
|
||||
"normalize_definition_scope",
|
||||
"require_definition_action",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.notifications import (
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
)
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.db import models as workflow_models
|
||||
|
||||
|
||||
MODULE_ID = "workflow_engine"
|
||||
MODULE_NAME = "Workflow Engine"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
|
||||
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
||||
INSTANCE_READ_SCOPE = "workflow:instance:read"
|
||||
INSTANCE_START_SCOPE = "workflow:instance:start"
|
||||
INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition"
|
||||
ADMIN_SCOPE = "workflow:instance:admin"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Workflow",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
DEFINITION_READ_SCOPE,
|
||||
"View workflow definitions",
|
||||
"Read workflow graphs, versions, diagnostics, and referenced contracts.",
|
||||
),
|
||||
_permission(
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
"Manage workflow definitions",
|
||||
"Create, edit, validate, and publish workflow definitions.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_READ_SCOPE,
|
||||
"View workflow instances",
|
||||
"Read workflow progress, pending actions, and transition evidence.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_START_SCOPE,
|
||||
"Start workflows",
|
||||
"Start approved workflow definitions for authorized subjects.",
|
||||
),
|
||||
_permission(
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
"Advance workflows",
|
||||
"Complete activities and invoke authorized workflow transitions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer workflows",
|
||||
"Manage workflow definitions and instances across the tenant.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="workflow_designer",
|
||||
name="Workflow designer",
|
||||
description="Design, validate, and publish workflow definitions.",
|
||||
permissions=(DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="workflow_operator",
|
||||
name="Workflow operator",
|
||||
description="Start and advance approved workflows.",
|
||||
permissions=(
|
||||
DEFINITION_READ_SCOPE,
|
||||
INSTANCE_READ_SCOPE,
|
||||
INSTANCE_START_SCOPE,
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_workflow_engine.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
from govoplan_workflow_engine.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _runtime_worker(context: ModuleContext):
|
||||
from govoplan_workflow_engine.backend.instance_service import (
|
||||
SqlWorkflowRuntimeWorker,
|
||||
)
|
||||
|
||||
return SqlWorkflowRuntimeWorker(registry=context.registry)
|
||||
|
||||
|
||||
def _definition_contribution_provider(context: ModuleContext):
|
||||
from govoplan_workflow_engine.backend.contributions import (
|
||||
SqlWorkflowDefinitionContributionProvider,
|
||||
)
|
||||
|
||||
return SqlWorkflowDefinitionContributionProvider(registry=context.registry)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
permission_namespace="workflow",
|
||||
dependencies=(),
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"audit",
|
||||
"dataflow",
|
||||
"datasources",
|
||||
"notifications",
|
||||
"policy",
|
||||
"tasks",
|
||||
"views",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
CAPABILITY_VIEWS_RESOLVER,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name="workflow.definition_contributions",
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name="workflow.bpmn_execution_adapters",
|
||||
version="1.0.0",
|
||||
),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="dataflow.run_lifecycle",
|
||||
version_min="0.1.14",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="auth.automation_principal",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="policy.definition_governance",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="views.resolver",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
capability_factories={
|
||||
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS: (
|
||||
_definition_contribution_provider
|
||||
),
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker,
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
workflow_models.WorkflowInstanceEvent,
|
||||
workflow_models.WorkflowInstanceStep,
|
||||
workflow_models.WorkflowInstance,
|
||||
workflow_models.WorkflowDefinitionRevision,
|
||||
workflow_models.WorkflowDefinition,
|
||||
label="Workflow",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement drops Workflow definitions and immutable revisions "
|
||||
"after the installer captures a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
workflow_models.WorkflowDefinition,
|
||||
workflow_models.WorkflowDefinitionRevision,
|
||||
workflow_models.WorkflowInstance,
|
||||
workflow_models.WorkflowInstanceStep,
|
||||
workflow_models.WorkflowInstanceEvent,
|
||||
label="Workflow",
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="workflow.definition-graphs",
|
||||
title="Workflow definition graphs",
|
||||
summary="Governed process graphs exposed independently of an editor.",
|
||||
body=(
|
||||
"Workflow Engine provides trigger, activity, decision, wait, integration, "
|
||||
"and outcome node library on top of Core's domain-neutral graph contract. "
|
||||
"Unlike Dataflow, Workflow permits cycles for correction and retry paths. "
|
||||
"Module actions are addressed through versioned capabilities rather than "
|
||||
"implementation imports. Definitions are persisted as immutable graph "
|
||||
"revisions; activation pins the exact revision used by future instances."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
|
||||
order=76,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="workflow.bpmn-interchange",
|
||||
title="BPMN modeling and execution profiles",
|
||||
summary=(
|
||||
"Lossless BPMN 2.0 revisions with explicit, fail-closed "
|
||||
"execution conformance."
|
||||
),
|
||||
body=(
|
||||
"BPMN 2.0 is Workflow's canonical native graph language. The "
|
||||
"shared graph editor models BPMN nodes, flows, containment, and "
|
||||
"diagram geometry directly without a separate browser-side "
|
||||
"modeler. Imported XML is normalized into the graph and every "
|
||||
"immutable revision pins a deterministic XML artifact. Activation "
|
||||
"requires a pinned execution adapter and version whose declared "
|
||||
"profile accepts every modeled runtime semantic. Unsupported "
|
||||
"runtime constructs remain editable and exportable. "
|
||||
"Adapter packages integrate through the "
|
||||
"govoplan.workflow.bpmn_adapters entry-point group and must "
|
||||
"materialize canonical Workflow runtime state; Workflow Engine never "
|
||||
"imports a concrete engine module."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||
related_modules=("audit", "policy", "views"),
|
||||
order=77,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_SCOPE",
|
||||
"DEFINITION_READ_SCOPE",
|
||||
"DEFINITION_WRITE_SCOPE",
|
||||
"INSTANCE_READ_SCOPE",
|
||||
"INSTANCE_START_SCOPE",
|
||||
"INSTANCE_TRANSITION_SCOPE",
|
||||
"MODULE_ID",
|
||||
"MODULE_VERSION",
|
||||
"get_manifest",
|
||||
"manifest",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow database migrations."""
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
"""v0.1.14 module Workflow standards and contribution provenance
|
||||
|
||||
Revision ID: 0b4e7c9a2d6f
|
||||
Revises: f1b7d3e5a9c2
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0b4e7c9a2d6f"
|
||||
down_revision = "f1b7d3e5a9c2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definitions") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("standard_origin_module_id", sa.String(120), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("standard_definition_key", sa.String(120), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"standard_origin_module_version",
|
||||
sa.String(40),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"standard_contribution_schema_version",
|
||||
sa.String(40),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("standard_contribution_hash", sa.String(64), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"standard_reconciled_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_workflow_definitions_standard_origin_module_id",
|
||||
["standard_origin_module_id"],
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_workflow_definitions_standard_definition_key",
|
||||
["standard_definition_key"],
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_workflow_definitions_standard_contribution_hash",
|
||||
["standard_contribution_hash"],
|
||||
)
|
||||
batch.create_index(
|
||||
"uq_workflow_standard_origin_scope",
|
||||
[
|
||||
"scope_key",
|
||||
"standard_origin_module_id",
|
||||
"standard_definition_key",
|
||||
],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"contribution_origin_module_version",
|
||||
sa.String(40),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"contribution_schema_version",
|
||||
sa.String(40),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("contribution_hash", sa.String(64), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("contribution_metadata", sa.JSON(), nullable=True)
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_workflow_definition_revisions_contribution_hash",
|
||||
["contribution_hash"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch:
|
||||
batch.drop_index(
|
||||
"ix_workflow_definition_revisions_contribution_hash"
|
||||
)
|
||||
batch.drop_column("contribution_metadata")
|
||||
batch.drop_column("contribution_hash")
|
||||
batch.drop_column("contribution_schema_version")
|
||||
batch.drop_column("contribution_origin_module_version")
|
||||
|
||||
with op.batch_alter_table("workflow_definitions") as batch:
|
||||
batch.drop_index("uq_workflow_standard_origin_scope")
|
||||
batch.drop_index(
|
||||
"ix_workflow_definitions_standard_contribution_hash"
|
||||
)
|
||||
batch.drop_index("ix_workflow_definitions_standard_definition_key")
|
||||
batch.drop_index(
|
||||
"ix_workflow_definitions_standard_origin_module_id"
|
||||
)
|
||||
batch.drop_column("standard_reconciled_at")
|
||||
batch.drop_column("standard_contribution_hash")
|
||||
batch.drop_column("standard_contribution_schema_version")
|
||||
batch.drop_column("standard_origin_module_version")
|
||||
batch.drop_column("standard_definition_key")
|
||||
batch.drop_column("standard_origin_module_id")
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow Alembic revisions."""
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
"""v0.1.14 Workflow definitions
|
||||
|
||||
Revision ID: a7c4e2f9b1d3
|
||||
Revises: None
|
||||
Create Date: 2026-07-28 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a7c4e2f9b1d3"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_definitions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("name", sa.String(length=300), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("active_revision", sa.Integer(), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_workflow_definitions")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_key",
|
||||
name="uq_workflow_definition_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_created_by"),
|
||||
"workflow_definitions",
|
||||
["created_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_deleted_at"),
|
||||
"workflow_definitions",
|
||||
["deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_status"),
|
||||
"workflow_definitions",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_tenant_id"),
|
||||
"workflow_definitions",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_updated_by"),
|
||||
"workflow_definitions",
|
||||
["updated_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_tenant_status",
|
||||
"workflow_definitions",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_tenant_updated",
|
||||
"workflow_definitions",
|
||||
["tenant_id", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_definition_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||
sa.Column("graph", sa.JSON(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("library_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("library_version", sa.String(length=40), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["definition_id"],
|
||||
["workflow_definitions.id"],
|
||||
name=op.f(
|
||||
"fk_workflow_definition_revisions_definition_id_workflow_definitions"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_workflow_definition_revisions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"definition_id",
|
||||
"revision",
|
||||
name="uq_workflow_definition_revision",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_created_by"),
|
||||
"workflow_definition_revisions",
|
||||
["created_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_definition_id"),
|
||||
"workflow_definition_revisions",
|
||||
["definition_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_tenant_id"),
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definition_revisions_content_hash",
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id", "content_hash"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definition_revisions_tenant_definition",
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id", "definition_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_workflow_definition_revisions_tenant_definition",
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_definition_revisions_content_hash",
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_tenant_id"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_definition_id"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_created_by"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_table("workflow_definition_revisions")
|
||||
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_tenant_updated",
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_tenant_status",
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_updated_by"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_tenant_id"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_status"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_deleted_at"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_created_by"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_table("workflow_definitions")
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"""v0.1.14 governed Workflow definitions
|
||||
|
||||
Revision ID: c6d8f1a3e5b7
|
||||
Revises: a7c4e2f9b1d3
|
||||
Create Date: 2026-07-28 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c6d8f1a3e5b7"
|
||||
down_revision = "a7c4e2f9b1d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"scope_type",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="tenant",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("scope_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"scope_key",
|
||||
sa.String(length=80),
|
||||
nullable=False,
|
||||
server_default="tenant:legacy",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"definition_kind",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="flow",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"inherit_to_lower_scopes",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_start",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_reuse",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_automation",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derived_from_definition_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("derived_from_revision", sa.Integer(), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derived_from_hash",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derivation_provenance",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definitions "
|
||||
"SET scope_id = tenant_id, "
|
||||
"scope_key = 'tenant:' || tenant_id "
|
||||
"WHERE tenant_id IS NOT NULL"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
["scope_key", "definition_key"],
|
||||
)
|
||||
for column in (
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scope_key",
|
||||
"definition_kind",
|
||||
"derived_from_definition_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_definitions_{column}"),
|
||||
"workflow_definitions",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
with op.batch_alter_table(
|
||||
"workflow_definition_revisions"
|
||||
) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definition_revisions "
|
||||
"SET tenant_id = COALESCE(tenant_id, 'system')"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table(
|
||||
"workflow_definition_revisions"
|
||||
) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
for column in (
|
||||
"derived_from_definition_id",
|
||||
"definition_kind",
|
||||
"scope_key",
|
||||
"scope_id",
|
||||
"scope_type",
|
||||
):
|
||||
op.drop_index(
|
||||
op.f(f"ix_workflow_definitions_{column}"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definitions "
|
||||
"SET tenant_id = COALESCE(tenant_id, 'system')"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
["tenant_id", "definition_key"],
|
||||
)
|
||||
batch_op.drop_column("derivation_provenance")
|
||||
batch_op.drop_column("derived_from_hash")
|
||||
batch_op.drop_column("derived_from_revision")
|
||||
batch_op.drop_column("derived_from_definition_id")
|
||||
batch_op.drop_column("allow_automation")
|
||||
batch_op.drop_column("allow_reuse")
|
||||
batch_op.drop_column("allow_start")
|
||||
batch_op.drop_column("inherit_to_lower_scopes")
|
||||
batch_op.drop_column("definition_kind")
|
||||
batch_op.drop_column("scope_key")
|
||||
batch_op.drop_column("scope_id")
|
||||
batch_op.drop_column("scope_type")
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
)
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
"""v0.1.14 Workflow instances and resumable handoffs
|
||||
|
||||
Revision ID: d8f2a5c7e1b4
|
||||
Revises: c6d8f1a3e5b7
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f2a5c7e1b4"
|
||||
down_revision = "c6d8f1a3e5b7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_instances",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=36), nullable=False),
|
||||
sa.Column(
|
||||
"definition_revision_id",
|
||||
sa.String(length=36),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("correlation_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("current_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("input", sa.JSON(), nullable=False),
|
||||
sa.Column("context", sa.JSON(), nullable=False),
|
||||
sa.Column("output", sa.JSON(), nullable=False),
|
||||
sa.Column("authorization", sa.JSON(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"cancellation_requested_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["definition_id"],
|
||||
["workflow_definitions.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["definition_revision_id"],
|
||||
["workflow_definition_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"idempotency_key",
|
||||
name="uq_workflow_instance_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"definition_revision_id",
|
||||
"status",
|
||||
"idempotency_key",
|
||||
"correlation_id",
|
||||
"current_step_id",
|
||||
"created_by",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instances_{column}"),
|
||||
"workflow_instances",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
"workflow_instances",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
"workflow_instances",
|
||||
["status", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_instance_steps",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("instance_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("node_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("input", sa.JSON(), nullable=False),
|
||||
sa.Column("output", sa.JSON(), nullable=False),
|
||||
sa.Column("handoff", sa.JSON(), nullable=False),
|
||||
sa.Column("external_ref", sa.String(length=500), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("completed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"],
|
||||
["workflow_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_step_sequence",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"instance_id",
|
||||
"node_id",
|
||||
"node_type",
|
||||
"status",
|
||||
"external_ref",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instance_steps_{column}"),
|
||||
"workflow_instance_steps",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
"workflow_instance_steps",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_instance_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("instance_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=120), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"],
|
||||
["workflow_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_event_sequence",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"instance_id",
|
||||
"step_id",
|
||||
"kind",
|
||||
"actor_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instance_events_{column}"),
|
||||
"workflow_instance_events",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
"workflow_instance_events",
|
||||
["tenant_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
table_name="workflow_instance_events",
|
||||
)
|
||||
op.drop_table("workflow_instance_events")
|
||||
op.drop_index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
table_name="workflow_instance_steps",
|
||||
)
|
||||
op.drop_table("workflow_instance_steps")
|
||||
op.drop_index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
table_name="workflow_instances",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
table_name="workflow_instances",
|
||||
)
|
||||
op.drop_table("workflow_instances")
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""v0.1.14 normalized BPMN revision artifacts
|
||||
|
||||
Revision ID: e9a4c6b8d2f1
|
||||
Revises: d8f2a5c7e1b4
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e9a4c6b8d2f1"
|
||||
down_revision = "d8f2a5c7e1b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
|
||||
batch_op.add_column(sa.Column("bpmn_xml", sa.Text(), nullable=True))
|
||||
batch_op.add_column(
|
||||
sa.Column("bpmn_hash", sa.String(length=64), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"bpmn_adapter_id",
|
||||
sa.String(length=120),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"bpmn_adapter_version",
|
||||
sa.String(length=40),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"bpmn_runtime_kind",
|
||||
sa.String(length=20),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("bpmn_executable", sa.Boolean(), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_bpmn_hash"),
|
||||
"workflow_definition_revisions",
|
||||
["bpmn_hash"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_bpmn_hash"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
|
||||
batch_op.drop_column("bpmn_executable")
|
||||
batch_op.drop_column("bpmn_runtime_kind")
|
||||
batch_op.drop_column("bpmn_adapter_version")
|
||||
batch_op.drop_column("bpmn_adapter_id")
|
||||
batch_op.drop_column("bpmn_hash")
|
||||
batch_op.drop_column("bpmn_xml")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""v0.1.14 workflow execution modes and focused Views
|
||||
|
||||
Revision ID: f1b7d3e5a9c2
|
||||
Revises: e9a4c6b8d2f1
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f1b7d3e5a9c2"
|
||||
down_revision = "e9a4c6b8d2f1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"execution_mode",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="hybrid",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("view_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"view_revision_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table("workflow_instances") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"start_origin",
|
||||
sa.String(length=30),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_workflow_instances_start_origin",
|
||||
["start_origin"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("workflow_instances") as batch_op:
|
||||
batch_op.drop_index("ix_workflow_instances_start_origin")
|
||||
batch_op.drop_column("start_origin")
|
||||
with op.batch_alter_table("workflow_definition_revisions") as batch_op:
|
||||
batch_op.drop_column("view_revision_id")
|
||||
batch_op.drop_column("view_id")
|
||||
batch_op.drop_column("execution_mode")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||
|
||||
|
||||
_runtime = ModuleRuntimeState("Workflow")
|
||||
|
||||
configure_runtime = _runtime.configure_runtime
|
||||
get_registry = _runtime.get_registry
|
||||
get_settings = _runtime.get_settings
|
||||
settings = _runtime.settings
|
||||
@@ -0,0 +1,573 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
BpmnRuntimeKind = Literal["model_only", "native_graph", "external"]
|
||||
WorkflowExecutionMode = Literal["guided", "automated", "hybrid"]
|
||||
WorkflowStartOrigin = Literal[
|
||||
"user",
|
||||
"api",
|
||||
"schedule",
|
||||
"event",
|
||||
"parent_workflow",
|
||||
"dependency",
|
||||
"retry",
|
||||
"replay",
|
||||
"backfill",
|
||||
]
|
||||
BpmnSupportLevel = Literal[
|
||||
"interchange_only",
|
||||
"native_mapping",
|
||||
"native_execution",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowPosition(BaseModel):
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
@field_validator("x", "y")
|
||||
@classmethod
|
||||
def finite_coordinate(cls, value: float) -> float:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Graph coordinates must be finite.")
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowSize(BaseModel):
|
||||
width: float = Field(default=100, gt=0, le=10_000)
|
||||
height: float = Field(default=80, gt=0, le=10_000)
|
||||
|
||||
|
||||
class WorkflowNode(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
type: str = Field(min_length=1, max_length=120)
|
||||
label: str = Field(default="", max_length=300)
|
||||
position: WorkflowPosition = Field(default_factory=WorkflowPosition)
|
||||
size: WorkflowSize | None = None
|
||||
parent_id: str | None = Field(default=None, max_length=120)
|
||||
process_id: str | None = Field(default=None, max_length=120)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowWaypoint(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
@field_validator("x", "y")
|
||||
@classmethod
|
||||
def finite_coordinate(cls, value: float) -> float:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Edge coordinates must be finite.")
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowEdge(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
type: Literal[
|
||||
"bpmn.sequenceFlow",
|
||||
"bpmn.messageFlow",
|
||||
"bpmn.association",
|
||||
"bpmn.dataInputAssociation",
|
||||
"bpmn.dataOutputAssociation",
|
||||
"bpmn.conversationLink",
|
||||
] = "bpmn.sequenceFlow"
|
||||
label: str = Field(default="", max_length=300)
|
||||
source: str = Field(min_length=1, max_length=120)
|
||||
target: str = Field(min_length=1, max_length=120)
|
||||
source_port: str = Field(default="output", min_length=1, max_length=120)
|
||||
target_port: str = Field(default="input", min_length=1, max_length=120)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
waypoints: list[WorkflowWaypoint] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
class WorkflowGraph(BaseModel):
|
||||
schema_version: Literal[1] = 1
|
||||
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
|
||||
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowGraphValidationRequest(BaseModel):
|
||||
graph: WorkflowGraph
|
||||
|
||||
|
||||
class WorkflowDiagnosticResponse(BaseModel):
|
||||
severity: Literal["error", "warning"]
|
||||
code: str
|
||||
message: str
|
||||
node_id: str | None = None
|
||||
field: str | None = None
|
||||
|
||||
|
||||
class WorkflowGraphValidationResponse(BaseModel):
|
||||
valid: bool
|
||||
diagnostics: list[WorkflowDiagnosticResponse]
|
||||
|
||||
|
||||
class WorkflowPortResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
required: bool
|
||||
multiple: bool
|
||||
minimum_connections: int
|
||||
|
||||
|
||||
class WorkflowConfigFieldResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
required: bool
|
||||
description: str | None
|
||||
options: list[tuple[str, str]]
|
||||
|
||||
|
||||
class WorkflowNodeTypeResponse(BaseModel):
|
||||
type: str
|
||||
category: str
|
||||
category_label: str
|
||||
label: str
|
||||
description: str
|
||||
icon: str
|
||||
input_ports: list[WorkflowPortResponse]
|
||||
output_ports: list[WorkflowPortResponse]
|
||||
config_fields: list[WorkflowConfigFieldResponse]
|
||||
default_config: dict[str, Any]
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowNodeLibraryResponse(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
allows_cycles: bool
|
||||
nodes: list[WorkflowNodeTypeResponse]
|
||||
|
||||
|
||||
class BpmnInspectionRequest(BaseModel):
|
||||
xml: str = Field(min_length=1, max_length=1_048_576)
|
||||
adapter_id: str = Field(
|
||||
default="govoplan.native.bpmn",
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
)
|
||||
adapter_version: str | None = Field(default=None, max_length=40)
|
||||
activation: bool = False
|
||||
|
||||
|
||||
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]
|
||||
adapter_id: str | None = None
|
||||
adapter_version: str | None = None
|
||||
runtime_kind: BpmnRuntimeKind | None = None
|
||||
executable: bool = False
|
||||
activatable: bool = False
|
||||
|
||||
|
||||
class BpmnAdapterProfileResponse(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
label: str
|
||||
description: str
|
||||
conformance: str
|
||||
runtime_kind: BpmnRuntimeKind
|
||||
executable: bool
|
||||
supported_elements: list[str]
|
||||
supported_event_definitions: list[str]
|
||||
requirements: list[str]
|
||||
|
||||
|
||||
class BpmnSupportProfileResponse(BaseModel):
|
||||
specification: str
|
||||
model_namespace: str
|
||||
interchange: str
|
||||
native_runtime: str
|
||||
native_execution_elements: list[str]
|
||||
native_mapping_elements: list[str]
|
||||
adapters: list[BpmnAdapterProfileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BpmnRevisionInput(BaseModel):
|
||||
xml: str = Field(min_length=1, max_length=1_048_576)
|
||||
adapter_id: str = Field(
|
||||
default="govoplan.native.bpmn",
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
)
|
||||
adapter_version: str | None = Field(default=None, max_length=40)
|
||||
|
||||
|
||||
class BpmnRevisionSummaryResponse(BaseModel):
|
||||
format: Literal["bpmn-2.0"] = "bpmn-2.0"
|
||||
content_hash: str
|
||||
adapter_id: str
|
||||
adapter_version: str
|
||||
runtime_kind: BpmnRuntimeKind
|
||||
executable: bool
|
||||
adapter_available: bool
|
||||
|
||||
|
||||
class BpmnRevisionDocumentResponse(BpmnRevisionSummaryResponse):
|
||||
definition_id: str
|
||||
revision: int
|
||||
xml: str
|
||||
inspection: BpmnInspectionResponse
|
||||
|
||||
|
||||
class BpmnCompileRequest(BaseModel):
|
||||
xml: str = Field(min_length=1, max_length=1_048_576)
|
||||
adapter_id: str = Field(
|
||||
default="govoplan.native.bpmn",
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
)
|
||||
adapter_version: str | None = Field(default=None, max_length=40)
|
||||
|
||||
|
||||
class BpmnCompileResponse(BaseModel):
|
||||
adapter: BpmnAdapterProfileResponse
|
||||
graph: WorkflowGraph
|
||||
inspection: BpmnInspectionResponse
|
||||
|
||||
|
||||
class BpmnRenderRequest(BaseModel):
|
||||
graph: WorkflowGraph
|
||||
name: str = Field(default="", max_length=300)
|
||||
|
||||
|
||||
class BpmnRenderResponse(BaseModel):
|
||||
xml: str
|
||||
inspection: BpmnInspectionResponse
|
||||
|
||||
|
||||
class WorkflowDefinitionRevisionResponse(BaseModel):
|
||||
id: str
|
||||
revision: int
|
||||
schema_version: int
|
||||
graph: WorkflowGraph
|
||||
content_hash: str
|
||||
library_id: str
|
||||
library_version: str
|
||||
execution_mode: WorkflowExecutionMode
|
||||
view_id: str | None = None
|
||||
view_revision_id: str | None = None
|
||||
bpmn: BpmnRevisionSummaryResponse | None = None
|
||||
contribution_origin_module_version: str | None = None
|
||||
contribution_schema_version: str | None = None
|
||||
contribution_hash: str | None = None
|
||||
contribution_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_by: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WorkflowStandardProvenanceResponse(BaseModel):
|
||||
kind: Literal["baseline", "override"]
|
||||
origin_module_id: str
|
||||
origin_module_version: str | None = None
|
||||
definition_key: str
|
||||
contribution_schema_version: str | None = None
|
||||
contribution_hash: str | None = None
|
||||
baseline_definition_id: str
|
||||
latest_baseline_revision: int
|
||||
active_baseline_revision: int | None = None
|
||||
pinned_baseline_revision: int | None = None
|
||||
pinned_baseline_hash: str | None = None
|
||||
update_available: bool = False
|
||||
reset_available: bool = False
|
||||
|
||||
|
||||
class WorkflowActionDecisionResponse(BaseModel):
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
source_path: list[dict[str, Any]] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowGovernanceResponse(BaseModel):
|
||||
scope_type: DefinitionScopeType
|
||||
scope_id: str | None
|
||||
definition_kind: DefinitionKind
|
||||
inherit_to_lower_scopes: bool
|
||||
allow_start: bool
|
||||
allow_reuse: bool
|
||||
allow_automation: bool
|
||||
derived_from_definition_id: str | None
|
||||
derived_from_revision: int | None
|
||||
derived_from_hash: str | None
|
||||
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
actions: dict[str, WorkflowActionDecisionResponse]
|
||||
automation_runtime_available: bool = False
|
||||
automation_runtime_reason: str | None = None
|
||||
|
||||
|
||||
class WorkflowDefinitionResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None
|
||||
key: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: WorkflowDefinitionStatus
|
||||
current_revision: int
|
||||
active_revision: int | None
|
||||
metadata: dict[str, Any]
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revision: WorkflowDefinitionRevisionResponse
|
||||
governance: WorkflowGovernanceResponse
|
||||
standard: WorkflowStandardProvenanceResponse | None = None
|
||||
|
||||
|
||||
class WorkflowDefinitionListResponse(BaseModel):
|
||||
definitions: list[WorkflowDefinitionResponse]
|
||||
|
||||
|
||||
class WorkflowDefinitionRevisionListResponse(BaseModel):
|
||||
revisions: list[WorkflowDefinitionRevisionResponse]
|
||||
|
||||
|
||||
class WorkflowDefinitionCreateRequest(BaseModel):
|
||||
key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
graph: WorkflowGraph
|
||||
bpmn: BpmnRevisionInput | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
execution_mode: WorkflowExecutionMode = "hybrid"
|
||||
view_id: str | None = Field(default=None, min_length=1, max_length=36)
|
||||
view_revision_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=36,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_view_pin(self):
|
||||
if self.view_revision_id and not self.view_id:
|
||||
raise ValueError("A pinned View revision requires a View")
|
||||
return self
|
||||
|
||||
|
||||
class WorkflowDefinitionUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
graph: WorkflowGraph
|
||||
bpmn: BpmnRevisionInput | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
expected_revision: int = Field(ge=1)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
execution_mode: WorkflowExecutionMode = "hybrid"
|
||||
view_id: str | None = Field(default=None, min_length=1, max_length=36)
|
||||
view_revision_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=36,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_view_pin(self):
|
||||
if self.view_revision_id and not self.view_id:
|
||||
raise ValueError("A pinned View revision requires a View")
|
||||
return self
|
||||
|
||||
|
||||
class WorkflowDefinitionDeriveRequest(BaseModel):
|
||||
key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
source_revision: int | None = Field(default=None, ge=1)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
execution_mode: WorkflowExecutionMode | None = None
|
||||
view_id: str | None = Field(default=None, min_length=1, max_length=36)
|
||||
view_revision_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=36,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_view_pin(self):
|
||||
if self.view_revision_id and not self.view_id:
|
||||
raise ValueError("A pinned View revision requires a View")
|
||||
return self
|
||||
|
||||
|
||||
class WorkflowDefinitionActivateRequest(BaseModel):
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class WorkflowDefinitionDeleteResponse(BaseModel):
|
||||
deleted: bool
|
||||
definition_id: str
|
||||
|
||||
|
||||
WorkflowInstanceStatus = Literal[
|
||||
"running",
|
||||
"waiting",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]
|
||||
WorkflowStepStatus = Literal[
|
||||
"running",
|
||||
"waiting",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"superseded",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowInstanceStartRequest(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class WorkflowStepActionRequest(BaseModel):
|
||||
action: Literal[
|
||||
"complete",
|
||||
"approve",
|
||||
"changes",
|
||||
"reject",
|
||||
"resume",
|
||||
"retry",
|
||||
"cancel",
|
||||
]
|
||||
output: dict[str, Any] = Field(default_factory=dict)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
comment: str | None = Field(default=None, max_length=4_000)
|
||||
|
||||
|
||||
class WorkflowInstanceStepResponse(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
node_id: str
|
||||
node_type: str
|
||||
status: WorkflowStepStatus
|
||||
attempt: int
|
||||
input: dict[str, Any]
|
||||
output: dict[str, Any]
|
||||
handoff: dict[str, Any]
|
||||
external_ref: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
error: str | None
|
||||
completed_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkflowViewContextResponse(BaseModel):
|
||||
view_id: str
|
||||
revision_id: str | None = None
|
||||
visible_surface_ids: list[str] = Field(default_factory=list)
|
||||
step_id: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowInstanceEventResponse(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
step_id: str | None
|
||||
kind: str
|
||||
actor_id: str | None
|
||||
payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WorkflowInstanceResponse(BaseModel):
|
||||
id: str
|
||||
definition_id: str
|
||||
definition_name: str
|
||||
definition_revision: int
|
||||
definition_hash: str
|
||||
execution_mode: WorkflowExecutionMode
|
||||
start_origin: WorkflowStartOrigin
|
||||
view_context: WorkflowViewContextResponse | None = None
|
||||
status: WorkflowInstanceStatus
|
||||
idempotency_key: str
|
||||
correlation_id: str | None
|
||||
current_step_id: str | None
|
||||
input: dict[str, Any]
|
||||
context: dict[str, Any]
|
||||
output: dict[str, Any]
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
cancellation_requested_at: datetime | None
|
||||
error: str | None
|
||||
created_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[WorkflowInstanceStepResponse]
|
||||
events: list[WorkflowInstanceEventResponse]
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
class WorkflowInstanceListResponse(BaseModel):
|
||||
instances: list[WorkflowInstanceResponse]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.definition_graphs import (
|
||||
DefinitionDiagnostic,
|
||||
DefinitionEdge,
|
||||
DefinitionNode,
|
||||
validate_definition_graph,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.node_library import (
|
||||
WORKFLOW_GRAPH_LIBRARY,
|
||||
WORKFLOW_NODE_TYPES_BY_ID,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import WorkflowGraph
|
||||
|
||||
|
||||
def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ...]:
|
||||
diagnostics = list(
|
||||
validate_definition_graph(
|
||||
WORKFLOW_GRAPH_LIBRARY,
|
||||
nodes=tuple(DefinitionNode(id=node.id, type=node.type) for node in graph.nodes),
|
||||
edges=tuple(
|
||||
DefinitionEdge(
|
||||
id=edge.id,
|
||||
source=edge.source,
|
||||
target=edge.target,
|
||||
source_port=edge.source_port,
|
||||
target_port=edge.target_port,
|
||||
)
|
||||
for edge in graph.edges
|
||||
),
|
||||
)
|
||||
)
|
||||
outgoing = {node.id: 0 for node in graph.nodes}
|
||||
for edge in graph.edges:
|
||||
if edge.type == "bpmn.sequenceFlow" and edge.source in outgoing:
|
||||
outgoing[edge.source] += 1
|
||||
for node in graph.nodes:
|
||||
definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type)
|
||||
if definition is None:
|
||||
continue
|
||||
for field in definition.config_fields:
|
||||
if node.type.startswith("bpmn."):
|
||||
continue
|
||||
if field.required and _empty(node.config.get(field.id)):
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="node.config_required",
|
||||
message=f"{definition.label} requires {field.label.lower()}.",
|
||||
node_id=node.id,
|
||||
field=field.id,
|
||||
)
|
||||
)
|
||||
if _requires_outgoing(node.type) and outgoing[node.id] == 0:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="node.outgoing_required",
|
||||
message=f"{definition.label} must lead to another workflow step.",
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(_bpmn_semantic_diagnostics(graph))
|
||||
diagnostics.extend(_legacy_semantic_diagnostics(graph))
|
||||
return _deduplicate(diagnostics)
|
||||
|
||||
|
||||
def _requires_outgoing(node_type: str) -> bool:
|
||||
if not node_type.startswith("bpmn."):
|
||||
return bool(
|
||||
WORKFLOW_NODE_TYPES_BY_ID.get(node_type)
|
||||
and WORKFLOW_NODE_TYPES_BY_ID[node_type].output_ports
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _bpmn_semantic_diagnostics(
|
||||
graph: WorkflowGraph,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
if not graph.nodes or not any(
|
||||
node.type.startswith("bpmn.") for node in graph.nodes
|
||||
):
|
||||
return []
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
if any(not node.type.startswith("bpmn.") for node in graph.nodes):
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="warning",
|
||||
code="graph.mixed_notation",
|
||||
message=(
|
||||
"A Workflow revision cannot mix canonical BPMN and "
|
||||
"legacy Workflow nodes."
|
||||
),
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
flow_nodes = {
|
||||
node.id
|
||||
for node in graph.nodes
|
||||
if node.type
|
||||
not in {
|
||||
"bpmn.participant",
|
||||
"bpmn.lane",
|
||||
"bpmn.dataObjectReference",
|
||||
"bpmn.dataStoreReference",
|
||||
"bpmn.textAnnotation",
|
||||
"bpmn.group",
|
||||
"bpmn.conversation",
|
||||
"bpmn.callConversation",
|
||||
"bpmn.subConversation",
|
||||
}
|
||||
}
|
||||
starts = [
|
||||
node for node in graph.nodes if node.type == "bpmn.startEvent"
|
||||
]
|
||||
ends = [node for node in graph.nodes if node.type == "bpmn.endEvent"]
|
||||
if not starts:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="warning",
|
||||
code="bpmn.start_event_missing",
|
||||
message="A Workflow process needs at least one BPMN start event.",
|
||||
)
|
||||
)
|
||||
if not ends:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="warning",
|
||||
code="bpmn.end_event_missing",
|
||||
message="A Workflow process needs at least one BPMN end event.",
|
||||
)
|
||||
)
|
||||
node_by_id = {node.id: node for node in graph.nodes}
|
||||
default_flows_by_source: dict[str, list[str]] = {}
|
||||
for edge in graph.edges:
|
||||
source = node_by_id.get(edge.source)
|
||||
target = node_by_id.get(edge.target)
|
||||
if source is None or target is None:
|
||||
continue
|
||||
if edge.type == "bpmn.sequenceFlow" and (
|
||||
edge.source not in flow_nodes or edge.target not in flow_nodes
|
||||
):
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="bpmn.sequence_flow_endpoint",
|
||||
message=(
|
||||
"BPMN sequence flows may only connect process flow "
|
||||
"nodes. Use an association or data association here."
|
||||
),
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
if edge.type == "bpmn.messageFlow":
|
||||
same_process = (
|
||||
source.process_id
|
||||
and target.process_id
|
||||
and source.process_id == target.process_id
|
||||
)
|
||||
if same_process:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="bpmn.message_flow_same_process",
|
||||
message=(
|
||||
"BPMN message flows connect different participants; "
|
||||
"use a sequence flow inside one process."
|
||||
),
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
if edge.config.get("default") is True:
|
||||
if edge.type != "bpmn.sequenceFlow":
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="bpmn.default_flow_type",
|
||||
message="Only a BPMN sequence flow can be a default flow.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
else:
|
||||
default_flows_by_source.setdefault(edge.source, []).append(edge.id)
|
||||
for source_id, edge_ids in default_flows_by_source.items():
|
||||
if len(edge_ids) > 1:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="bpmn.multiple_default_flows",
|
||||
message="A BPMN flow node can have at most one default flow.",
|
||||
node_id=source_id,
|
||||
)
|
||||
)
|
||||
for node in graph.nodes:
|
||||
if node.type != "bpmn.boundaryEvent":
|
||||
continue
|
||||
attached_to = str(node.config.get("attached_to_ref") or "")
|
||||
if attached_to not in node_by_id:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="bpmn.boundary_attachment",
|
||||
message="A boundary event must reference an activity in this graph.",
|
||||
node_id=node.id,
|
||||
field="attached_to_ref",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _legacy_semantic_diagnostics(
|
||||
graph: WorkflowGraph,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
if not graph.nodes or any(
|
||||
node.type.startswith("bpmn.") for node in graph.nodes
|
||||
):
|
||||
return []
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
starts = [
|
||||
node for node in graph.nodes if node.type.startswith("workflow.start.")
|
||||
]
|
||||
outcomes = [
|
||||
node for node in graph.nodes if node.type.startswith("workflow.end.")
|
||||
]
|
||||
if len(starts) != 1:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="graph.trigger_count",
|
||||
message="A definition requires exactly one start node.",
|
||||
)
|
||||
)
|
||||
if not outcomes:
|
||||
diagnostics.append(
|
||||
DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code="graph.outcome_count",
|
||||
message="A definition requires at least one outcome node.",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _empty(value: object) -> bool:
|
||||
return value is None or value == "" or value == [] or value == {}
|
||||
|
||||
|
||||
def _deduplicate(
|
||||
diagnostics: list[DefinitionDiagnostic],
|
||||
) -> tuple[DefinitionDiagnostic, ...]:
|
||||
seen: set[tuple[str, str | None, str | None]] = set()
|
||||
result: list[DefinitionDiagnostic] = []
|
||||
for item in diagnostics:
|
||||
key = (item.code, item.node_id, item.field)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
__all__ = ["validate_workflow_graph"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user