From 8fd87530124a4e7db85a7e11717173ceb835c1ba Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 16:59:02 +0200 Subject: [PATCH] refactor: retain workflow as optional editor --- README.md | 71 +- docs/CONCEPT.md | 14 +- docs/ENGINE_EDITOR_SPLIT.md | 17 + pyproject.toml | 7 +- src/govoplan_workflow/__init__.py | 2 +- src/govoplan_workflow/backend/__init__.py | 2 +- src/govoplan_workflow/backend/bpmn.py | 345 +-- .../backend/bpmn_adapters.py | 722 +---- src/govoplan_workflow/backend/bpmn_graph.py | 1742 +----------- src/govoplan_workflow/backend/db/__init__.py | 16 +- src/govoplan_workflow/backend/db/models.py | 475 +--- src/govoplan_workflow/backend/governance.py | 324 +-- .../backend/instance_service.py | 2374 +---------------- src/govoplan_workflow/backend/manifest.py | 273 +- .../backend/migrations/__init__.py | 1 - .../backend/migrations/versions/__init__.py | 1 - ...a7c4e2f9b1d3_v0114_workflow_definitions.py | 201 -- ...c6d8f1a3e5b7_v0114_governed_definitions.py | 209 -- .../d8f2a5c7e1b4_v0114_workflow_runtime.py | 213 -- ...4c6b8d2f1_v0114_bpmn_revision_artifacts.py | 68 - ...d3e5a9c2_v0114_workflow_modes_and_views.py | 62 - src/govoplan_workflow/backend/node_library.py | 1151 +------- src/govoplan_workflow/backend/router.py | 1388 +--------- src/govoplan_workflow/backend/runtime.py | 12 +- src/govoplan_workflow/backend/schemas.py | 553 +--- src/govoplan_workflow/backend/service.py | 971 +------ src/govoplan_workflow/backend/validation.py | 263 +- tests/test_manifest.py | 52 +- tests/test_migrations.py | 69 +- webui/src/api/workflow.ts | 59 + webui/src/features/workflow/WorkflowPage.tsx | 212 +- webui/src/features/workflow/model.ts | 6 +- webui/src/styles/workflow.css | 58 + 33 files changed, 450 insertions(+), 11483 deletions(-) delete mode 100644 src/govoplan_workflow/backend/migrations/__init__.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/__init__.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py delete mode 100644 src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py diff --git a/README.md b/README.md index baff117..5a26f8e 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,32 @@ -# govoplan-workflow +# GovOPlaN Workflow **Repository type:** module (platform). -`govoplan-workflow` currently owns process orchestration and its editor for -GovOPlaN. The accepted target separates the headless runtime into -`govoplan-workflow-engine`, while this module remains the optional authoring and -inspection interface. +Optional visual authoring and inspection workspace for GovOPlaN Workflow +Engine. -The module should execute configurable state machines and command handoffs -between modules without importing their implementations. It coordinates cases, -tasks, forms, files, templates, mail, appointments, payments, and records -through capabilities, events, commands, and DTOs. +This module owns the Workflow catalogue, native BPMN/graph editor, validation +and activation UI, immutable revision inspection, instance controls, and the +governed module-standard compare/override/reset experience. The headless +`govoplan-workflow-engine` package owns persistence, migrations, API routes, +runtime services, and module integration contracts. -Workflow exposes a workflow-specific node library, a full definition editor, -validation APIs, tenant-isolated definitions, and immutable graph revisions. -Activation pins the exact revision that future instances will execute. It uses -Core's domain-neutral definition graph contract, but applies Workflow -constraints: exactly one trigger, at least one outcome, governed configuration -fields, connected nodes, and permitted loops for correction and retry paths. -Dataflow uses the same graph contract with its own acyclic transformation -library. +For one compatibility release, Python imports below +`govoplan_workflow.backend` re-export their corresponding Workflow Engine +implementations. New module code must use Core's `workflow.*` capabilities or, +for engine implementation code, `govoplan_workflow_engine` directly. -The executable runtime persists revision-pinned instances, append-only -transition evidence, resumable human handoffs, retries, cancellation, and -stable external output references. Dataflow nodes enqueue work through -Dataflow's lifecycle capability; Workflow never imports Dataflow internals. -Core's periodic worker reconciles linked runs after re-resolving the stored -automation principal, while the operator surface exposes progress, review -actions, evidence references, and direct navigation to Dataflow results. +See [the engine/editor split](docs/ENGINE_EDITOR_SPLIT.md) for the durable +ownership boundary. +See [the module concept](docs/CONCEPT.md) and +[BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for the shared +model retained by Workflow Engine. -Definitions can be complete flows or non-runnable templates at system, -tenant, group, or user scope. Policy resolves whether a definition can be -viewed, edited, started, reused, derived, or automated and returns the ordered -source path shown in the editor. Derivation copies an immutable graph revision -and records its hash, node-library version, source scope, actor, Policy -decision, and effective ancestor limits. +## Checks -BPMN 2.0 is Workflow's canonical graph language. The existing native graph -editor models BPMN events, activities, gateways, data, collaborations, and -artifacts directly; there is no separate modeler or browser-side BPMN library. -XML import projects BPMN semantics and DI geometry into that graph, while XML -export renders a deterministic interchange document from the graph. Immutable -revisions pin both representations and the native profile version. Unsupported -runtime semantics remain editable and portable, but activation fails closed. - -The start-node library distinguishes explicit user, API, scheduled, event, and -parent-workflow starts. Manual starts and Dataflow/human handoffs are -operational. The other trigger and generic capability nodes remain explicit -definition contracts until their event/schedule dispatchers and versioned -operation providers are implemented. - -See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept. -See [docs/BPMN_INTEROPERABILITY.md](docs/BPMN_INTEROPERABILITY.md) for the -notation, conformance, and adapter boundary. -See [docs/ENGINE_EDITOR_SPLIT.md](docs/ENGINE_EDITOR_SPLIT.md) for the accepted -runtime/editor extraction and module-owned workflow baseline model. +```bash +/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +cd webui && npm run typecheck +``` diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 24148ae..d0c5d7b 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -2,10 +2,9 @@ ## Purpose -The current `govoplan-workflow` package is the process orchestration module. The -accepted target moves definition/runtime ownership to the headless -`govoplan-workflow-engine` module and keeps `govoplan-workflow` as the optional -authoring and inspection surface. See `ENGINE_EDITOR_SPLIT.md`. +`govoplan-workflow-engine` is the headless process orchestration module. +`govoplan-workflow` is its optional authoring and inspection surface. See +`ENGINE_EDITOR_SPLIT.md`. Workflow does not own business records. A case, task, file, appointment, template, payment, or postbox message remains owned by its domain module. @@ -20,7 +19,7 @@ action/effect contracts. ## Ownership -The module owns: +Workflow Engine owns: - workflow definitions and versions - workflow instances and current state @@ -30,7 +29,10 @@ The module owns: - retry/manual-intervention state for failed command handoffs - action/effect execution records for workflow-triggered automation - workflow audit/event emission -- workflow diagram metadata and WebUI route contributions +- workflow diagram metadata + +Workflow owns visual authoring, catalogue, comparison, activation, inspection, +override, and reset surfaces. It owns no process tables or transition runtime. The module does not own: diff --git a/docs/ENGINE_EDITOR_SPLIT.md b/docs/ENGINE_EDITOR_SPLIT.md index 24d1024..6e5c1ff 100644 --- a/docs/ENGINE_EDITOR_SPLIT.md +++ b/docs/ENGINE_EDITOR_SPLIT.md @@ -118,3 +118,20 @@ but the canonical baseline remains available. The extraction must preserve existing definition IDs, revision IDs, active revision selection, instance foreign keys, idempotency keys, API routes, and audit references. + +## Implemented Boundary + +The split is implemented in the `govoplan-workflow-engine` repository. Engine +owns the unchanged migration chain and `/api/v1/workflow` API, retains the +existing `workflow:*` permission namespace through an explicit manifest +compatibility field, and exposes headless runtime and contribution +capabilities. `govoplan-workflow` now has a hard dependency on runtime module +ID `workflow_engine`, contributes only its WebUI/navigation/editor contract, +and keeps one release line of `govoplan_workflow.backend` import facades. + +Module manifests can announce versioned workflow baselines. Reconciliation is +idempotent, records module/schema/hash provenance, keeps a newly supplied +baseline revision inactive when an older revision is active, and fails closed +when required capabilities or interfaces are absent. Baselines are immutable; +editing derives a pinned local override, and reset archives that override +without removing revision or instance history. diff --git a/pyproject.toml b/pyproject.toml index 9570a62..b517ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,15 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-workflow" version = "0.1.14" -description = "Governed process definitions and orchestration contracts for GovOPlaN." +description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine." readme = "README.md" requires-python = ">=3.12" license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] -dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"] +dependencies = [ + "govoplan-core>=0.1.14", + "govoplan-workflow-engine>=0.1.14", +] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_workflow/__init__.py b/src/govoplan_workflow/__init__.py index cf761b5..f8b6788 100644 --- a/src/govoplan_workflow/__init__.py +++ b/src/govoplan_workflow/__init__.py @@ -1,3 +1,3 @@ -"""GovOPlaN Workflow module.""" +"""Optional GovOPlaN Workflow editor and compatibility package.""" __version__ = "0.1.14" diff --git a/src/govoplan_workflow/backend/__init__.py b/src/govoplan_workflow/backend/__init__.py index da4885c..0d7ada9 100644 --- a/src/govoplan_workflow/backend/__init__.py +++ b/src/govoplan_workflow/backend/__init__.py @@ -1 +1 @@ -"""Workflow backend.""" +"""Compatibility facades for the extracted Workflow Engine backend.""" diff --git a/src/govoplan_workflow/backend/bpmn.py b/src/govoplan_workflow/backend/bpmn.py index 162343c..8129254 100644 --- a/src/govoplan_workflow/backend/bpmn.py +++ b/src/govoplan_workflow/backend/bpmn.py @@ -1,344 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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", -] +from govoplan_workflow_engine.backend.bpmn import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/bpmn_adapters.py b/src/govoplan_workflow/backend/bpmn_adapters.py index 3894304..711cb83 100644 --- a/src/govoplan_workflow/backend/bpmn_adapters.py +++ b/src/govoplan_workflow/backend/bpmn_adapters.py @@ -1,721 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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.backend.bpmn import ( - BPMN_DI_NAMESPACE, - BPMN_MODEL_NAMESPACE, - OMG_DC_NAMESPACE, - BpmnDiagnostic, - BpmnInspection, - inspect_bpmn_xml, - parse_bpmn_xml, -) -from govoplan_workflow.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.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", -] +from govoplan_workflow_engine.backend.bpmn_adapters import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/bpmn_graph.py b/src/govoplan_workflow/backend/bpmn_graph.py index d4cb2de..3f509bc 100644 --- a/src/govoplan_workflow/backend/bpmn_graph.py +++ b/src/govoplan_workflow/backend/bpmn_graph.py @@ -1,1741 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -import hashlib -import json -import re -from collections import defaultdict -from collections.abc import Iterable -from copy import deepcopy -from dataclasses import dataclass -from xml.etree import ElementTree -from xml.etree.ElementTree import Element - -from govoplan_workflow.backend.bpmn import ( - BPMN_DI_NAMESPACE, - BPMN_MODEL_NAMESPACE, - OMG_DC_NAMESPACE, - OMG_DI_NAMESPACE, - BpmnDiagnostic, - inspect_bpmn_xml, - parse_bpmn_xml, -) -from govoplan_workflow.backend.schemas import ( - WorkflowEdge, - WorkflowGraph, - WorkflowNode, - WorkflowPosition, - WorkflowSize, - WorkflowWaypoint, -) - - -NATIVE_BPMN_ADAPTER_ID = "govoplan.native.bpmn" -NATIVE_BPMN_ADAPTER_VERSION = "1.0.0" -GOVOPLAN_EXTENSION_NAMESPACE = "https://govoplan.add-ideas.de/ns/workflow" -XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" - -BPMN_NODE_LOCAL_NAMES = frozenset( - { - "startEvent", - "intermediateCatchEvent", - "intermediateThrowEvent", - "boundaryEvent", - "endEvent", - "task", - "userTask", - "manualTask", - "serviceTask", - "scriptTask", - "businessRuleTask", - "sendTask", - "receiveTask", - "callActivity", - "subProcess", - "transaction", - "adHocSubProcess", - "exclusiveGateway", - "parallelGateway", - "inclusiveGateway", - "eventBasedGateway", - "complexGateway", - "dataObjectReference", - "dataStoreReference", - "participant", - "lane", - "textAnnotation", - "group", - "choreographyTask", - "callChoreography", - "subChoreography", - "conversation", - "callConversation", - "subConversation", - } -) -BPMN_EDGE_LOCAL_NAMES = frozenset( - { - "sequenceFlow", - "messageFlow", - "association", - "dataInputAssociation", - "dataOutputAssociation", - "conversationLink", - } -) -BPMN_EVENT_DEFINITION_LOCAL_NAMES = frozenset( - { - "messageEventDefinition", - "timerEventDefinition", - "conditionalEventDefinition", - "signalEventDefinition", - "errorEventDefinition", - "escalationEventDefinition", - "compensateEventDefinition", - "linkEventDefinition", - "cancelEventDefinition", - "terminateEventDefinition", - } -) -_EVENT_DEFINITION_NAMES = { - "messageEventDefinition": "message", - "timerEventDefinition": "timer", - "conditionalEventDefinition": "conditional", - "signalEventDefinition": "signal", - "errorEventDefinition": "error", - "escalationEventDefinition": "escalation", - "compensateEventDefinition": "compensation", - "linkEventDefinition": "link", - "cancelEventDefinition": "cancel", - "terminateEventDefinition": "terminate", -} -_EVENT_DEFINITION_ELEMENTS = { - value: key for key, value in _EVENT_DEFINITION_NAMES.items() -} -_DEFAULT_NODE_SIZE = { - "startEvent": (36.0, 36.0), - "intermediateCatchEvent": (36.0, 36.0), - "intermediateThrowEvent": (36.0, 36.0), - "boundaryEvent": (36.0, 36.0), - "endEvent": (36.0, 36.0), - "exclusiveGateway": (50.0, 50.0), - "parallelGateway": (50.0, 50.0), - "inclusiveGateway": (50.0, 50.0), - "eventBasedGateway": (50.0, 50.0), - "complexGateway": (50.0, 50.0), - "participant": (600.0, 180.0), - "lane": (560.0, 140.0), - "textAnnotation": (120.0, 60.0), - "group": (300.0, 180.0), - "dataObjectReference": (36.0, 50.0), - "dataStoreReference": (50.0, 50.0), -} -_MODEL_ATTRIBUTES_EXCLUDED = { - "id", - "name", - "sourceRef", - "targetRef", - "default", - "attachedToRef", - "processRef", -} -_FLOW_NODE_NAMES = BPMN_NODE_LOCAL_NAMES - { - "participant", - "lane", - "textAnnotation", - "group", - "conversation", - "callConversation", - "subConversation", -} -_RUNTIME_NODE_TYPES = frozenset( - { - "bpmn.startEvent", - "bpmn.endEvent", - "bpmn.task", - "bpmn.userTask", - "bpmn.manualTask", - "bpmn.serviceTask", - "bpmn.sendTask", - "bpmn.receiveTask", - "bpmn.intermediateCatchEvent", - "bpmn.exclusiveGateway", - } -) - - -class BpmnGraphError(ValueError): - def __init__(self, diagnostics: Iterable[BpmnDiagnostic]) -> None: - self.diagnostics = tuple(diagnostics) - first = next( - (item for item in self.diagnostics if item.severity == "error"), - self.diagnostics[0] if self.diagnostics else None, - ) - super().__init__( - first.message if first is not None else "BPMN graph conversion failed." - ) - - -@dataclass(frozen=True, slots=True) -class _DiagramShape: - position: WorkflowPosition - size: WorkflowSize - - -def is_native_bpmn_graph(graph: WorkflowGraph) -> bool: - return bool(graph.nodes) and all( - node.type.startswith("bpmn.") for node in graph.nodes - ) - - -def canonical_bpmn_graph(graph: WorkflowGraph) -> WorkflowGraph: - if not graph.nodes or is_native_bpmn_graph(graph): - return graph.model_copy(deep=True) - if any(node.type.startswith("bpmn.") for node in graph.nodes): - raise BpmnGraphError( - ( - BpmnDiagnostic( - severity="error", - code="graph.mixed_notation", - message=( - "A Workflow revision cannot mix legacy workflow nodes " - "with canonical BPMN nodes." - ), - ), - ) - ) - return legacy_graph_to_bpmn(graph) - - -def legacy_graph_to_bpmn(graph: WorkflowGraph) -> WorkflowGraph: - nodes: list[WorkflowNode] = [] - for node in graph.nodes: - config = deepcopy(node.config) - node_type = node.type - if node_type.startswith("workflow.start."): - start_kind = node_type.removeprefix("workflow.start.") - if start_kind == "workflow": - start_kind = "parent_workflow" - config["start_kind"] = start_kind - config.setdefault("event_definition", "none") - bpmn_type = "bpmn.startEvent" - elif node_type == "workflow.activity": - config.setdefault("task_mode", "activity") - bpmn_type = "bpmn.userTask" - elif node_type == "workflow.review": - config["task_mode"] = "review" - bpmn_type = "bpmn.userTask" - elif node_type == "workflow.wait": - config["wait_mode"] = config.pop("mode", "manual") - config.setdefault("event_definition", "none") - bpmn_type = "bpmn.intermediateCatchEvent" - elif node_type == "workflow.capability": - config["implementation"] = "capability" - bpmn_type = "bpmn.serviceTask" - elif node_type == "workflow.dataflow": - config["implementation"] = "dataflow" - bpmn_type = "bpmn.serviceTask" - elif node_type == "workflow.decision": - bpmn_type = "bpmn.exclusiveGateway" - elif node_type == "workflow.end.completed": - config["outcome"] = "completed" - config.setdefault("event_definition", "none") - bpmn_type = "bpmn.endEvent" - elif node_type == "workflow.end.cancelled": - config["outcome"] = "cancelled" - config.setdefault("event_definition", "cancel") - bpmn_type = "bpmn.endEvent" - else: - raise BpmnGraphError( - ( - BpmnDiagnostic( - severity="error", - code="graph.legacy_node_unsupported", - message=f"Legacy node type {node_type!r} cannot be projected to BPMN.", - element_id=node.id, - ), - ) - ) - config.setdefault("documentation", "") - config.setdefault("bpmn_id", node.id) - nodes.append( - WorkflowNode( - id=node.id, - type=bpmn_type, - label=node.label, - position=node.position, - size=node.size or _default_size(bpmn_type.removeprefix("bpmn.")), - parent_id=node.parent_id, - process_id=node.process_id or "Process_1", - config=config, - ) - ) - - edges: list[WorkflowEdge] = [] - by_id = {node.id: node for node in graph.nodes} - for edge in graph.edges: - source_type = by_id.get(edge.source).type if edge.source in by_id else "" - config = deepcopy(edge.config) - if source_type == "workflow.decision": - if edge.source_port == "true": - decision = by_id[edge.source] - config.setdefault("condition", decision.config.get("expression", "")) - elif edge.source_port == "false": - config.setdefault("default", True) - elif edge.source_port not in {"output", "outgoing"}: - config.setdefault("outcome", edge.source_port) - config.setdefault("bpmn_id", edge.id) - edges.append( - WorkflowEdge( - id=edge.id, - type="bpmn.sequenceFlow", - label=edge.label, - source=edge.source, - target=edge.target, - source_port="outgoing", - target_port="incoming", - config=config, - waypoints=edge.waypoints, - ) - ) - - metadata = deepcopy(graph.metadata) - metadata.setdefault( - "bpmn", - { - "definitions_id": "Definitions_1", - "target_namespace": "urn:govoplan:workflow", - "processes": [ - { - "id": "Process_1", - "name": "", - "is_executable": True, - "attributes": {}, - } - ], - "collaborations": [], - "choreographies": [], - "root_elements_xml": [], - }, - ) - metadata["notation"] = "bpmn-2.0" - return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) - - -def import_bpmn_graph(xml: str) -> WorkflowGraph: - inspection = inspect_bpmn_xml(xml) - errors = tuple( - item for item in inspection.diagnostics if item.severity == "error" - ) - if errors: - raise BpmnGraphError(errors) - root = parse_bpmn_xml(xml) - shape_by_id, waypoints_by_id = _diagram_geometry(root) - - original_node_ids = [ - item.attrib["id"] - for item in root.iter() - if _qualified_name(item.tag) - == (BPMN_MODEL_NAMESPACE, _qualified_name(item.tag)[1]) - and _qualified_name(item.tag)[1] in BPMN_NODE_LOCAL_NAMES - and item.attrib.get("id") - ] - id_map = _graph_id_map(original_node_ids) - nodes: list[WorkflowNode] = [] - edge_elements: list[tuple[Element, str | None, str | None]] = [] - processes: list[dict[str, object]] = [] - collaborations: list[dict[str, object]] = [] - choreographies: list[dict[str, object]] = [] - process_elements_xml: dict[str, list[str]] = defaultdict(list) - root_elements_xml: list[str] = [] - - def walk( - parent: Element, - *, - process_id: str | None = None, - parent_node_id: str | None = None, - container_kind: str | None = None, - ) -> None: - for child in parent: - namespace, local_name = _qualified_name(child.tag) - if namespace != BPMN_MODEL_NAMESPACE: - continue - if local_name == "process": - child_process_id = child.attrib.get("id") or f"Process_{len(processes) + 1}" - processes.append(_container_metadata(child, child_process_id)) - walk( - child, - process_id=child_process_id, - container_kind="process", - ) - continue - if local_name == "collaboration": - collaboration_id = child.attrib.get("id") or ( - f"Collaboration_{len(collaborations) + 1}" - ) - collaborations.append(_container_metadata(child, collaboration_id)) - walk(child, container_kind="collaboration") - continue - if local_name == "choreography": - choreography_id = child.attrib.get("id") or ( - f"Choreography_{len(choreographies) + 1}" - ) - choreographies.append(_container_metadata(child, choreography_id)) - walk( - child, - process_id=choreography_id, - container_kind="choreography", - ) - continue - if local_name in BPMN_NODE_LOCAL_NAMES and child.attrib.get("id"): - original_id = child.attrib["id"] - graph_id = id_map[original_id] - effective_process_id = ( - child.attrib.get("processRef") - if local_name == "participant" - else process_id - ) - shape = shape_by_id.get(original_id) - if shape is None: - shape = _fallback_shape(local_name, len(nodes)) - nodes.append( - WorkflowNode( - id=graph_id, - type=f"bpmn.{local_name}", - label=child.attrib.get("name", "").strip(), - position=shape.position, - size=shape.size, - parent_id=parent_node_id, - process_id=effective_process_id, - config=_node_config(child, original_id), - ) - ) - for nested in child: - nested_namespace, nested_name = _qualified_name(nested.tag) - if ( - nested_namespace == BPMN_MODEL_NAMESPACE - and nested_name - in {"dataInputAssociation", "dataOutputAssociation"} - and nested.attrib.get("id") - ): - edge_elements.append((nested, process_id, graph_id)) - if local_name in { - "subProcess", - "transaction", - "adHocSubProcess", - "subChoreography", - "subConversation", - }: - walk( - child, - process_id=process_id, - parent_node_id=graph_id, - container_kind=container_kind, - ) - continue - if local_name in BPMN_EDGE_LOCAL_NAMES and child.attrib.get("id"): - edge_elements.append((child, process_id, parent_node_id)) - continue - if local_name in {"laneSet", "childLaneSet"}: - walk( - child, - process_id=process_id, - parent_node_id=parent_node_id, - container_kind=container_kind, - ) - continue - if local_name in { - "documentation", - "extensionElements", - "incoming", - "outgoing", - }: - continue - if container_kind == "process" and process_id: - process_elements_xml[process_id].append(_serialize_element(child)) - elif parent is root: - root_elements_xml.append(_serialize_element(child)) - - walk(root) - edge_id_map = _graph_id_map( - element.attrib["id"] - for element, _process_id, _parent_node_id in edge_elements - ) - normalized_nodes: list[WorkflowNode] = [] - for node in nodes: - config = deepcopy(node.config) - if node.type == "bpmn.boundaryEvent": - attached_to_ref = str(config.get("attached_to_ref") or "") - if attached_to_ref: - config["attached_to_ref"] = id_map.get( - attached_to_ref, - _graph_id(attached_to_ref), - ) - if node.type == "bpmn.lane": - config["flow_node_refs"] = [ - id_map.get(str(item), _graph_id(str(item))) - for item in config.get("flow_node_refs") or () - ] - default_flow_ref = str(config.get("default_flow_ref") or "") - if default_flow_ref: - config["default_flow_ref"] = edge_id_map.get( - default_flow_ref, - _graph_id(default_flow_ref), - ) - normalized_nodes.append(node.model_copy(update={"config": config})) - nodes = normalized_nodes - node_ids = {node.id for node in nodes} - edges: list[WorkflowEdge] = [] - for element, process_id, parent_node_id in edge_elements: - local_name = _qualified_name(element.tag)[1] - original_id = element.attrib["id"] - source_ref, target_ref = _edge_references(element, local_name) - if local_name == "dataInputAssociation" and parent_node_id: - target_ref = parent_node_id - elif local_name == "dataOutputAssociation" and parent_node_id: - source_ref = parent_node_id - source = id_map.get(source_ref or "", _graph_id(source_ref or "")) - target = id_map.get(target_ref or "", _graph_id(target_ref or "")) - if source not in node_ids or target not in node_ids: - continue - config = _edge_config(element, original_id, process_id, parent_node_id) - edges.append( - WorkflowEdge( - id=edge_id_map[original_id], - type=f"bpmn.{local_name}", - label=element.attrib.get("name", "").strip(), - source=source, - target=target, - source_port="outgoing", - target_port="incoming", - config=config, - waypoints=waypoints_by_id.get(original_id, []), - ) - ) - default_flow_by_source = { - node.id: str(node.config.get("default_flow_ref")) - for node in nodes - if node.config.get("default_flow_ref") - } - edges = [ - edge.model_copy( - update={ - "config": { - **edge.config, - **( - {"default": True} - if default_flow_by_source.get(edge.source) == edge.id - else {} - ), - } - } - ) - for edge in edges - ] - - metadata = { - "notation": "bpmn-2.0", - "bpmn": { - "definitions_id": root.attrib.get("id") or "Definitions_1", - "target_namespace": ( - root.attrib.get("targetNamespace") or "urn:govoplan:workflow" - ), - "definitions_attributes": _extra_attributes( - root, - excluded={"id", "targetNamespace"}, - ), - "extension_elements_xml": _foreign_extension_xml(root), - "processes": processes, - "collaborations": collaborations, - "choreographies": choreographies, - "root_elements_xml": root_elements_xml, - "process_elements_xml": dict(process_elements_xml), - }, - } - return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) - - -def export_bpmn_graph(graph: WorkflowGraph, *, name: str = "") -> str: - canonical = canonical_bpmn_graph(graph) - ElementTree.register_namespace("bpmn", BPMN_MODEL_NAMESPACE) - ElementTree.register_namespace("bpmndi", BPMN_DI_NAMESPACE) - ElementTree.register_namespace("dc", OMG_DC_NAMESPACE) - ElementTree.register_namespace("di", OMG_DI_NAMESPACE) - ElementTree.register_namespace("xsi", XSI_NAMESPACE) - ElementTree.register_namespace("govoplan", GOVOPLAN_EXTENSION_NAMESPACE) - - bpmn_metadata = dict(canonical.metadata.get("bpmn") or {}) - root = Element( - _tag(BPMN_MODEL_NAMESPACE, "definitions"), - { - "id": str(bpmn_metadata.get("definitions_id") or "Definitions_1"), - "targetNamespace": str( - bpmn_metadata.get("target_namespace") - or "urn:govoplan:workflow" - ), - }, - ) - _apply_attributes( - root, - bpmn_metadata.get("definitions_attributes"), - excluded={"id", "targetNamespace"}, - ) - _append_extension_elements( - root, - bpmn_metadata.get("extension_elements_xml"), - ) - _append_preserved_elements(root, bpmn_metadata.get("root_elements_xml")) - - process_metadata = { - str(item.get("id")): dict(item) - for item in bpmn_metadata.get("processes") or () - if isinstance(item, dict) and item.get("id") - } - process_ids = { - node.process_id - for node in canonical.nodes - if node.process_id and node.type != "bpmn.participant" - } - process_ids.update(process_metadata) - choreography_ids = { - str(item.get("id")) - for item in bpmn_metadata.get("choreographies") or () - if isinstance(item, dict) and item.get("id") - } - process_ids.difference_update(choreography_ids) - if not process_ids: - process_ids.add("Process_1") - process_by_id: dict[str, Element] = {} - for process_id in sorted(process_ids): - metadata = process_metadata.get(process_id, {}) - attributes = { - "id": process_id, - "isExecutable": ( - "true" if metadata.get("is_executable", True) else "false" - ), - } - process_name = str(metadata.get("name") or "") - if process_name: - attributes["name"] = process_name - process = Element(_tag(BPMN_MODEL_NAMESPACE, "process"), attributes) - _apply_attributes( - process, - metadata.get("attributes"), - excluded={"id", "name", "isExecutable"}, - ) - _append_extension_elements( - process, - metadata.get("extension_elements_xml"), - ) - root.append(process) - process_by_id[process_id] = process - - collaboration_metadata = [ - dict(item) - for item in bpmn_metadata.get("collaborations") or () - if isinstance(item, dict) - ] - needs_collaboration = any( - node.type == "bpmn.participant" for node in canonical.nodes - ) or any(edge.type == "bpmn.messageFlow" for edge in canonical.edges) - collaboration: Element | None = None - if collaboration_metadata or needs_collaboration: - metadata = collaboration_metadata[0] if collaboration_metadata else {} - collaboration = Element( - _tag(BPMN_MODEL_NAMESPACE, "collaboration"), - {"id": str(metadata.get("id") or "Collaboration_1")}, - ) - if metadata.get("name"): - collaboration.set("name", str(metadata["name"])) - _apply_attributes( - collaboration, - metadata.get("attributes"), - excluded={"id", "name"}, - ) - _append_extension_elements( - collaboration, - metadata.get("extension_elements_xml"), - ) - root.append(collaboration) - - choreography_metadata = [ - dict(item) - for item in bpmn_metadata.get("choreographies") or () - if isinstance(item, dict) - ] - choreography_by_id: dict[str, Element] = {} - for index, metadata in enumerate(choreography_metadata, start=1): - choreography_id = str(metadata.get("id") or f"Choreography_{index}") - choreography = Element( - _tag(BPMN_MODEL_NAMESPACE, "choreography"), - {"id": choreography_id}, - ) - if metadata.get("name"): - choreography.set("name", str(metadata["name"])) - root.append(choreography) - choreography_by_id[choreography_id] = choreography - - node_xml_ids = _xml_id_map(canonical.nodes, prefix="Node") - edge_xml_ids = _xml_id_map(canonical.edges, prefix="Flow") - default_edge_by_source = { - edge.source: edge.id - for edge in canonical.edges - if edge.type == "bpmn.sequenceFlow" - and edge.config.get("default") is True - } - node_element_by_id: dict[str, Element] = {} - lane_nodes: list[WorkflowNode] = [] - for node in _nodes_parent_first(canonical.nodes): - local_name = node.type.removeprefix("bpmn.") - if local_name == "lane": - lane_nodes.append(node) - continue - parent: Element - if local_name == "participant": - if collaboration is None: - collaboration = Element( - _tag(BPMN_MODEL_NAMESPACE, "collaboration"), - {"id": "Collaboration_1"}, - ) - root.append(collaboration) - parent = collaboration - elif node.process_id in choreography_by_id: - parent = choreography_by_id[node.process_id] - elif local_name in { - "choreographyTask", - "callChoreography", - "subChoreography", - }: - parent = choreography_by_id.get(node.process_id or "") - if parent is None: - choreography_id = node.process_id or "Choreography_1" - parent = Element( - _tag(BPMN_MODEL_NAMESPACE, "choreography"), - {"id": choreography_id}, - ) - root.append(parent) - choreography_by_id[choreography_id] = parent - elif node.parent_id and node.parent_id in node_element_by_id: - parent = node_element_by_id[node.parent_id] - else: - parent = process_by_id.get(node.process_id or "") - if parent is None: - parent = next(iter(process_by_id.values())) - node_config = deepcopy(node.config) - node_config.pop("default_flow_ref", None) - if node.id in default_edge_by_source: - node_config["default_flow_ref"] = default_edge_by_source[node.id] - export_node = node.model_copy(update={"config": node_config}) - element = _node_element( - export_node, - node_xml_ids[node.id], - edge_xml_ids, - ) - parent.append(element) - node_element_by_id[node.id] = element - - lanes_by_process: dict[str, list[WorkflowNode]] = defaultdict(list) - for lane in lane_nodes: - lanes_by_process[lane.process_id or next(iter(process_by_id))].append(lane) - for process_id, lanes in lanes_by_process.items(): - process = process_by_id.get(process_id) - if process is None: - process = next(iter(process_by_id.values())) - lane_set = Element( - _tag(BPMN_MODEL_NAMESPACE, "laneSet"), - {"id": _xml_id("LaneSet", f"LaneSet_{process_id}")}, - ) - process.insert(0, lane_set) - for lane in lanes: - lane_element = _node_element( - lane, - node_xml_ids[lane.id], - edge_xml_ids, - ) - for flow_node_ref in lane.config.get("flow_node_refs") or (): - target_id = node_xml_ids.get(str(flow_node_ref), str(flow_node_ref)) - ref_element = Element(_tag(BPMN_MODEL_NAMESPACE, "flowNodeRef")) - ref_element.text = target_id - lane_element.append(ref_element) - lane_set.append(lane_element) - node_element_by_id[lane.id] = lane_element - - for edge in canonical.edges: - local_name = edge.type.removeprefix("bpmn.") - element = _edge_element( - edge, - edge_xml_ids[edge.id], - node_xml_ids, - ) - if local_name in {"messageFlow", "conversationLink"}: - if collaboration is None: - collaboration = Element( - _tag(BPMN_MODEL_NAMESPACE, "collaboration"), - {"id": "Collaboration_1"}, - ) - root.append(collaboration) - collaboration.append(element) - elif local_name == "dataInputAssociation": - node_element_by_id.get(edge.target, next(iter(process_by_id.values()))).append( - element - ) - elif local_name == "dataOutputAssociation": - node_element_by_id.get(edge.source, next(iter(process_by_id.values()))).append( - element - ) - else: - parent_node_id = str(edge.config.get("parent_node_id") or "") - if parent_node_id in node_element_by_id: - node_element_by_id[parent_node_id].append(element) - continue - process_id = _edge_process_id(edge, canonical.nodes) - if process_id in choreography_by_id: - choreography_by_id[process_id].append(element) - else: - process = process_by_id.get(process_id) - if process is None: - process = next(iter(process_by_id.values())) - process.append(element) - - process_extras = bpmn_metadata.get("process_elements_xml") - if isinstance(process_extras, dict): - for process_id, snippets in process_extras.items(): - process = process_by_id.get(str(process_id)) - if process is not None: - _append_preserved_elements(process, snippets) - - diagram = Element( - _tag(BPMN_DI_NAMESPACE, "BPMNDiagram"), - {"id": "BPMNDiagram_1", "name": name or "GovOPlaN Workflow"}, - ) - plane_target = ( - collaboration.attrib["id"] - if collaboration is not None - else next(iter(process_by_id)) - ) - plane = Element( - _tag(BPMN_DI_NAMESPACE, "BPMNPlane"), - {"id": "BPMNPlane_1", "bpmnElement": plane_target}, - ) - diagram.append(plane) - root.append(diagram) - for node in canonical.nodes: - shape = Element( - _tag(BPMN_DI_NAMESPACE, "BPMNShape"), - { - "id": _xml_id("Shape", f"Shape_{node_xml_ids[node.id]}"), - "bpmnElement": node_xml_ids[node.id], - }, - ) - if node.type == "bpmn.participant": - shape.set("isHorizontal", "true") - size = node.size or _default_size(node.type.removeprefix("bpmn.")) - shape.append( - Element( - _tag(OMG_DC_NAMESPACE, "Bounds"), - { - "x": _number(node.position.x), - "y": _number(node.position.y), - "width": _number(size.width), - "height": _number(size.height), - }, - ) - ) - plane.append(shape) - node_by_id = {node.id: node for node in canonical.nodes} - for edge in canonical.edges: - edge_element = Element( - _tag(BPMN_DI_NAMESPACE, "BPMNEdge"), - { - "id": _xml_id("Edge", f"Edge_{edge_xml_ids[edge.id]}"), - "bpmnElement": edge_xml_ids[edge.id], - }, - ) - waypoints = edge.waypoints or _fallback_waypoints( - node_by_id.get(edge.source), - node_by_id.get(edge.target), - ) - for waypoint in waypoints: - edge_element.append( - Element( - _tag(OMG_DI_NAMESPACE, "waypoint"), - {"x": _number(waypoint.x), "y": _number(waypoint.y)}, - ) - ) - plane.append(edge_element) - - ElementTree.indent(root, space=" ") - return ( - '\n' - + ElementTree.tostring(root, encoding="unicode") - ) - - -def materialize_runtime_graph(graph: WorkflowGraph) -> WorkflowGraph: - canonical = canonical_bpmn_graph(graph) - diagnostics: list[BpmnDiagnostic] = [] - runtime_nodes: list[WorkflowNode] = [] - ignored_node_ids = { - node.id - for node in canonical.nodes - if node.type.removeprefix("bpmn.") - in { - "participant", - "lane", - "textAnnotation", - "group", - "dataObjectReference", - "dataStoreReference", - "conversation", - "callConversation", - "subConversation", - } - } - for node in canonical.nodes: - if node.id in ignored_node_ids: - continue - if node.type not in _RUNTIME_NODE_TYPES: - diagnostics.append( - BpmnDiagnostic( - severity="error", - code="runtime.unsupported_bpmn_element", - message=( - f"{node.type.removeprefix('bpmn.')} is valid BPMN " - "notation but is not executable by the native runtime." - ), - element_id=node.id, - ) - ) - continue - runtime_nodes.append(_runtime_node(node)) - - runtime_node_ids = {node.id for node in runtime_nodes} - runtime_edges: list[WorkflowEdge] = [] - for edge in canonical.edges: - if edge.type != "bpmn.sequenceFlow": - continue - if edge.source not in runtime_node_ids or edge.target not in runtime_node_ids: - continue - source_node = next(node for node in runtime_nodes if node.id == edge.source) - if source_node.type == "workflow.decision": - condition = str(edge.config.get("condition") or "") - if condition: - source_node.config["expression"] = condition - runtime_edges.append( - WorkflowEdge( - id=edge.id, - source=edge.source, - target=edge.target, - source_port=_runtime_source_port(source_node, edge), - target_port="input", - ) - ) - - starts = [node for node in runtime_nodes if node.type.startswith("workflow.start.")] - if len(starts) != 1: - diagnostics.append( - BpmnDiagnostic( - severity="error", - code="runtime.start_event_count", - message=( - "The current native runtime requires exactly one executable " - "start event per Workflow revision." - ), - ) - ) - if not any(node.type.startswith("workflow.end.") for node in runtime_nodes): - diagnostics.append( - BpmnDiagnostic( - severity="error", - code="runtime.end_event_missing", - message="The executable process requires at least one end event.", - ) - ) - for node in runtime_nodes: - if node.type != "workflow.decision": - continue - outgoing = [edge for edge in runtime_edges if edge.source == node.id] - if ( - len(outgoing) != 2 - or sum(edge.source_port == "true" for edge in outgoing) != 1 - or sum(edge.source_port == "false" for edge in outgoing) != 1 - ): - diagnostics.append( - BpmnDiagnostic( - severity="error", - code="runtime.exclusive_gateway_shape", - message=( - "The current native runtime supports an exclusive " - "gateway with one conditional and one default flow." - ), - element_id=node.id, - ) - ) - if diagnostics: - raise BpmnGraphError(diagnostics) - return WorkflowGraph(nodes=runtime_nodes, edges=runtime_edges) - - -def runtime_diagnostics(graph: WorkflowGraph) -> tuple[BpmnDiagnostic, ...]: - try: - materialize_runtime_graph(graph) - except BpmnGraphError as exc: - return exc.diagnostics - return () - - -def _runtime_node(node: WorkflowNode) -> WorkflowNode: - config = deepcopy(node.config) - if node.type == "bpmn.startEvent": - start_kind = str(config.get("start_kind") or "manual") - if start_kind == "parent_workflow": - start_kind = "workflow" - node_type = f"workflow.start.{start_kind}" - elif node.type == "bpmn.endEvent": - outcome = str(config.get("outcome") or "completed") - node_type = ( - "workflow.end.cancelled" - if outcome == "cancelled" - else "workflow.end.completed" - ) - elif node.type == "bpmn.userTask" and config.get("task_mode") == "review": - node_type = "workflow.review" - elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}: - node_type = "workflow.activity" - elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}: - node_type = "workflow.wait" - config["mode"] = config.get("wait_mode") or "event" - elif node.type in {"bpmn.serviceTask", "bpmn.sendTask"}: - implementation = str(config.get("implementation") or "capability") - node_type = ( - "workflow.dataflow" - if implementation == "dataflow" - else "workflow.capability" - ) - elif node.type == "bpmn.exclusiveGateway": - node_type = "workflow.decision" - config.setdefault("expression", "") - else: - raise AssertionError(f"Unsupported runtime node {node.type}") - return WorkflowNode( - id=node.id, - type=node_type, - label=node.label, - position=node.position, - config=config, - ) - - -def _runtime_source_port(node: WorkflowNode, edge: WorkflowEdge) -> str: - configured = str(edge.config.get("outcome") or "") - if configured: - return configured - if node.type in {"workflow.capability", "workflow.dataflow"}: - return "success" - if node.type == "workflow.review": - return "approved" - if node.type == "workflow.wait": - return "resumed" - if node.type == "workflow.decision": - return "false" if edge.config.get("default") is True else "true" - return "output" - - -def _node_config(element: Element, original_id: str) -> dict[str, object]: - local_name = _qualified_name(element.tag)[1] - config: dict[str, object] = { - "bpmn_id": original_id, - "bpmn_attributes": _extra_attributes( - element, - excluded=_MODEL_ATTRIBUTES_EXCLUDED, - ), - "documentation": _documentation(element), - } - extension_xml = _foreign_extension_xml(element) - if extension_xml: - config["bpmn_extension_elements_xml"] = extension_xml - event_definition = next( - ( - child - for child in element - if _qualified_name(child.tag)[0] == BPMN_MODEL_NAMESPACE - and _qualified_name(child.tag)[1] in BPMN_EVENT_DEFINITION_LOCAL_NAMES - ), - None, - ) - if event_definition is not None: - event_name = _qualified_name(event_definition.tag)[1] - config["event_definition"] = _EVENT_DEFINITION_NAMES.get( - event_name, - event_name.removesuffix("EventDefinition"), - ) - config["event_definition_xml"] = _serialize_element(event_definition) - elif local_name.endswith("Event"): - config["event_definition"] = "none" - if local_name == "startEvent": - config["start_kind"] = _start_kind(element, config) - elif local_name == "boundaryEvent": - config["attached_to_ref"] = element.attrib.get("attachedToRef", "") - config["cancel_activity"] = element.attrib.get("cancelActivity", "true") - elif local_name == "participant": - config["process_ref"] = element.attrib.get("processRef", "") - elif local_name == "lane": - config["flow_node_refs"] = [ - (child.text or "").strip() - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "flowNodeRef") - and (child.text or "").strip() - ] - elif local_name == "textAnnotation": - text_element = next( - ( - child - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "text") - ), - None, - ) - config["text"] = (text_element.text or "") if text_element is not None else "" - config["text_format"] = element.attrib.get("textFormat", "text/plain") - elif local_name == "dataObjectReference": - config["data_object_ref"] = element.attrib.get("dataObjectRef", "") - elif local_name == "dataStoreReference": - config["data_store_ref"] = element.attrib.get("dataStoreRef", "") - elif local_name == "callActivity": - config["called_element"] = element.attrib.get("calledElement", "") - if element.attrib.get("default"): - config["default_flow_ref"] = element.attrib["default"] - if local_name in { - "task", - "userTask", - "manualTask", - "serviceTask", - "scriptTask", - "businessRuleTask", - "sendTask", - "receiveTask", - }: - config.setdefault("title", element.attrib.get("name", "").strip()) - config.setdefault("instructions", "") - govoplan_extension = next( - ( - child - for extension in element - if _qualified_name(extension.tag) - == (BPMN_MODEL_NAMESPACE, "extensionElements") - for child in extension - if _qualified_name(child.tag)[0] == GOVOPLAN_EXTENSION_NAMESPACE - and _qualified_name(child.tag)[1] == "config" - ), - None, - ) - if govoplan_extension is not None: - for item in govoplan_extension: - if _qualified_name(item.tag)[0] != GOVOPLAN_EXTENSION_NAMESPACE: - continue - key = item.attrib.get("key") - if key: - raw_value = item.text or "" - if item.attrib.get("format") == "json": - try: - config[key] = json.loads(raw_value) - except (TypeError, ValueError): - config[key] = raw_value - else: - config[key] = raw_value - preserved = [ - _serialize_element(child) - for child in element - if not _known_node_child(child) - ] - if preserved: - config["bpmn_child_elements_xml"] = preserved - return config - - -def _edge_config( - element: Element, - original_id: str, - process_id: str | None, - parent_node_id: str | None, -) -> dict[str, object]: - config: dict[str, object] = { - "bpmn_id": original_id, - "process_id": process_id or "", - "parent_node_id": parent_node_id or "", - "bpmn_attributes": _extra_attributes( - element, - excluded=_MODEL_ATTRIBUTES_EXCLUDED, - ), - } - local_name = _qualified_name(element.tag)[1] - if local_name == "sequenceFlow": - condition = next( - ( - child - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "conditionExpression") - ), - None, - ) - if condition is not None: - config["condition"] = condition.text or "" - config["condition_attributes"] = dict(condition.attrib) - return config - - -def _node_element( - node: WorkflowNode, - xml_id: str, - edge_xml_ids: dict[str, str], -) -> Element: - local_name = node.type.removeprefix("bpmn.") - attributes = {"id": xml_id} - if node.label: - attributes["name"] = node.label - if local_name == "boundaryEvent": - attached = str(node.config.get("attached_to_ref") or "") - if attached: - attributes["attachedToRef"] = attached - attributes["cancelActivity"] = str( - node.config.get("cancel_activity") or "true" - ).lower() - elif local_name == "participant": - process_ref = str(node.config.get("process_ref") or node.process_id or "") - if process_ref: - attributes["processRef"] = process_ref - elif local_name == "textAnnotation": - attributes["textFormat"] = str( - node.config.get("text_format") or "text/plain" - ) - elif local_name == "dataObjectReference" and node.config.get("data_object_ref"): - attributes["dataObjectRef"] = str(node.config["data_object_ref"]) - elif local_name == "dataStoreReference" and node.config.get("data_store_ref"): - attributes["dataStoreRef"] = str(node.config["data_store_ref"]) - elif local_name == "callActivity" and node.config.get("called_element"): - attributes["calledElement"] = str(node.config["called_element"]) - if node.config.get("default_flow_ref"): - attributes["default"] = edge_xml_ids.get( - str(node.config["default_flow_ref"]), - str(node.config["default_flow_ref"]), - ) - element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) - _apply_attributes( - element, - node.config.get("bpmn_attributes"), - excluded=set(attributes), - ) - documentation = str(node.config.get("documentation") or "") - if documentation: - documentation_element = Element( - _tag(BPMN_MODEL_NAMESPACE, "documentation") - ) - documentation_element.text = documentation - element.append(documentation_element) - if local_name == "textAnnotation": - text = Element(_tag(BPMN_MODEL_NAMESPACE, "text")) - text.text = str(node.config.get("text") or "") - element.append(text) - _append_event_definition(element, node.config) - _append_govoplan_config(element, node.config) - _append_extension_elements( - element, - node.config.get("bpmn_extension_elements_xml"), - ) - _append_preserved_elements( - element, - node.config.get("bpmn_child_elements_xml"), - ) - return element - - -def _edge_element( - edge: WorkflowEdge, - xml_id: str, - node_xml_ids: dict[str, str], -) -> Element: - local_name = edge.type.removeprefix("bpmn.") - attributes = {"id": xml_id} - if edge.label: - attributes["name"] = edge.label - source_ref = node_xml_ids[edge.source] - target_ref = node_xml_ids[edge.target] - if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: - attributes["sourceRef"] = source_ref - attributes["targetRef"] = target_ref - element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) - _apply_attributes( - element, - edge.config.get("bpmn_attributes"), - excluded=set(attributes), - ) - if local_name in {"dataInputAssociation", "dataOutputAssociation"}: - source = Element(_tag(BPMN_MODEL_NAMESPACE, "sourceRef")) - source.text = source_ref - target = Element(_tag(BPMN_MODEL_NAMESPACE, "targetRef")) - target.text = target_ref - element.extend((source, target)) - condition = str(edge.config.get("condition") or "") - if local_name == "sequenceFlow" and condition: - condition_element = Element( - _tag(BPMN_MODEL_NAMESPACE, "conditionExpression"), - {_tag(XSI_NAMESPACE, "type"): "bpmn:tFormalExpression"}, - ) - _apply_attributes( - condition_element, - edge.config.get("condition_attributes"), - excluded={_tag(XSI_NAMESPACE, "type")}, - ) - condition_element.text = condition - element.append(condition_element) - return element - - -def _append_govoplan_config(element: Element, config: dict[str, object]) -> None: - excluded = { - "bpmn_id", - "bpmn_attributes", - "bpmn_child_elements_xml", - "bpmn_extension_elements_xml", - "documentation", - "event_definition", - "event_definition_xml", - "attached_to_ref", - "cancel_activity", - "process_ref", - "flow_node_refs", - "text", - "text_format", - "data_object_ref", - "data_store_ref", - "called_element", - "default_flow_ref", - } - values = { - key: value - for key, value in config.items() - if key not in excluded - and value is not None - and value != "" - } - if not values: - return - extension = next( - ( - child - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "extensionElements") - ), - None, - ) - if extension is None: - extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) - element.append(extension) - root = Element(_tag(GOVOPLAN_EXTENSION_NAMESPACE, "config")) - for key, value in sorted(values.items()): - item = Element( - _tag(GOVOPLAN_EXTENSION_NAMESPACE, "property"), - {"key": key, "format": "json"}, - ) - item.text = json.dumps( - value, - sort_keys=True, - separators=(",", ":"), - ) - root.append(item) - extension.append(root) - - -def _append_event_definition(element: Element, config: dict[str, object]) -> None: - event_definition = str(config.get("event_definition") or "none") - if event_definition == "none": - return - preserved = config.get("event_definition_xml") - if isinstance(preserved, str): - try: - preserved_element = parse_bpmn_xml( - '{preserved}' - ) - if len(preserved_element): - element.append(deepcopy(preserved_element[0])) - return - except Exception: - pass - local_name = _EVENT_DEFINITION_ELEMENTS.get(event_definition) - if local_name: - element.append(Element(_tag(BPMN_MODEL_NAMESPACE, local_name))) - - -def _append_preserved_elements(parent: Element, snippets: object) -> None: - if not isinstance(snippets, (list, tuple)): - return - for snippet in snippets: - if not isinstance(snippet, str) or not snippet.strip(): - continue - try: - wrapper = parse_bpmn_xml( - '' - f"{snippet}" - ) - except Exception: - continue - for child in wrapper: - parent.append(deepcopy(child)) - - -def _append_extension_elements(parent: Element, snippets: object) -> None: - if not isinstance(snippets, (list, tuple)) or not snippets: - return - extension = next( - ( - child - for child in parent - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "extensionElements") - ), - None, - ) - if extension is None: - extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) - parent.insert(0, extension) - wrapper = Element(_tag(BPMN_MODEL_NAMESPACE, "definitions")) - _append_preserved_elements(wrapper, snippets) - for child in wrapper: - extension.append(deepcopy(child)) - - -def _diagram_geometry( - root: Element, -) -> tuple[dict[str, _DiagramShape], dict[str, list[WorkflowWaypoint]]]: - shapes: dict[str, _DiagramShape] = {} - waypoints: dict[str, list[WorkflowWaypoint]] = {} - for item in root.iter(): - namespace, local_name = _qualified_name(item.tag) - if namespace == BPMN_DI_NAMESPACE and local_name == "BPMNShape": - element_id = item.attrib.get("bpmnElement") - bounds = next( - ( - child - for child in item - if _qualified_name(child.tag) - == (OMG_DC_NAMESPACE, "Bounds") - ), - None, - ) - if not element_id or bounds is None: - continue - try: - shapes[element_id] = _DiagramShape( - position=WorkflowPosition( - x=float(bounds.attrib.get("x", "0")), - y=float(bounds.attrib.get("y", "0")), - ), - size=WorkflowSize( - width=float(bounds.attrib.get("width", "100")), - height=float(bounds.attrib.get("height", "80")), - ), - ) - except ValueError: - continue - elif namespace == BPMN_DI_NAMESPACE and local_name == "BPMNEdge": - element_id = item.attrib.get("bpmnElement") - if not element_id: - continue - points: list[WorkflowWaypoint] = [] - for child in item: - if _qualified_name(child.tag) != (OMG_DI_NAMESPACE, "waypoint"): - continue - try: - points.append( - WorkflowWaypoint( - x=float(child.attrib.get("x", "0")), - y=float(child.attrib.get("y", "0")), - ) - ) - except ValueError: - continue - if points: - waypoints[element_id] = points - return shapes, waypoints - - -def _container_metadata(element: Element, fallback_id: str) -> dict[str, object]: - return { - "id": element.attrib.get("id") or fallback_id, - "name": element.attrib.get("name", ""), - "is_executable": element.attrib.get("isExecutable", "false").lower() - == "true", - "attributes": _extra_attributes( - element, - excluded={"id", "name", "isExecutable"}, - ), - "documentation": _documentation(element), - "extension_elements_xml": _foreign_extension_xml(element), - } - - -def _extra_attributes( - element: Element, - *, - excluded: set[str], -) -> dict[str, str]: - return { - key: value - for key, value in element.attrib.items() - if key not in excluded - } - - -def _apply_attributes( - element: Element, - values: object, - *, - excluded: set[str], -) -> None: - if not isinstance(values, dict): - return - for key, value in values.items(): - if str(key) not in excluded and value is not None: - element.set(str(key), str(value)) - - -def _documentation(element: Element) -> str: - return "\n".join( - (child.text or "").strip() - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "documentation") - and (child.text or "").strip() - ) - - -def _known_node_child(element: Element) -> bool: - namespace, local_name = _qualified_name(element.tag) - return namespace == BPMN_MODEL_NAMESPACE and ( - local_name - in { - "documentation", - "extensionElements", - "incoming", - "outgoing", - "flowNodeRef", - "text", - *BPMN_EDGE_LOCAL_NAMES, - *BPMN_NODE_LOCAL_NAMES, - } - or local_name in BPMN_EVENT_DEFINITION_LOCAL_NAMES - ) - - -def _foreign_extension_xml(element: Element) -> list[str]: - result: list[str] = [] - for extension in element: - if _qualified_name(extension.tag) != ( - BPMN_MODEL_NAMESPACE, - "extensionElements", - ): - continue - for child in extension: - if _qualified_name(child.tag) == ( - GOVOPLAN_EXTENSION_NAMESPACE, - "config", - ): - continue - result.append(_serialize_element(child)) - return result - - -def _start_kind(element: Element, config: dict[str, object]) -> str: - event_definition = str(config.get("event_definition") or "none") - if event_definition == "timer": - return "schedule" - if event_definition in {"message", "signal", "conditional"}: - return "event" - for child in element: - if _qualified_name(child.tag) != ( - BPMN_MODEL_NAMESPACE, - "extensionElements", - ): - continue - for extension in child.iter(): - if _qualified_name(extension.tag) == ( - GOVOPLAN_EXTENSION_NAMESPACE, - "property", - ) and extension.attrib.get("key") == "start_kind": - return (extension.text or "manual").strip() - return "manual" - - -def _edge_references( - element: Element, - local_name: str, -) -> tuple[str | None, str | None]: - if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: - return element.attrib.get("sourceRef"), element.attrib.get("targetRef") - source_ref = next( - ( - (child.text or "").strip() - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "sourceRef") - and (child.text or "").strip() - ), - None, - ) - target_ref = next( - ( - (child.text or "").strip() - for child in element - if _qualified_name(child.tag) - == (BPMN_MODEL_NAMESPACE, "targetRef") - and (child.text or "").strip() - ), - None, - ) - return source_ref, target_ref - - -def _edge_process_id(edge: WorkflowEdge, nodes: list[WorkflowNode]) -> str: - configured = str(edge.config.get("process_id") or "") - if configured: - return configured - source = next((node for node in nodes if node.id == edge.source), None) - target = next((node for node in nodes if node.id == edge.target), None) - return (source and source.process_id) or (target and target.process_id) or "Process_1" - - -def _nodes_parent_first(nodes: list[WorkflowNode]) -> list[WorkflowNode]: - remaining = list(nodes) - ordered: list[WorkflowNode] = [] - emitted: set[str] = set() - while remaining: - progressed = False - for node in list(remaining): - if not node.parent_id or node.parent_id in emitted: - ordered.append(node) - emitted.add(node.id) - remaining.remove(node) - progressed = True - if not progressed: - ordered.extend(remaining) - break - return ordered - - -def _fallback_shape(local_name: str, index: int) -> _DiagramShape: - size = _default_size(local_name) - return _DiagramShape( - position=WorkflowPosition( - x=80 + (index % 5) * 190, - y=100 + (index // 5) * 140, - ), - size=size, - ) - - -def _default_size(local_name: str) -> WorkflowSize: - width, height = _DEFAULT_NODE_SIZE.get(local_name, (120.0, 80.0)) - return WorkflowSize(width=width, height=height) - - -def _fallback_waypoints( - source: WorkflowNode | None, - target: WorkflowNode | None, -) -> list[WorkflowWaypoint]: - if source is None or target is None: - return [] - source_size = source.size or _default_size( - source.type.removeprefix("bpmn.") - ) - target_size = target.size or _default_size( - target.type.removeprefix("bpmn.") - ) - return [ - WorkflowWaypoint( - x=source.position.x + source_size.width, - y=source.position.y + source_size.height / 2, - ), - WorkflowWaypoint( - x=target.position.x, - y=target.position.y + target_size.height / 2, - ), - ] - - -def _graph_id_map(values: Iterable[str]) -> dict[str, str]: - return {value: _graph_id(value) for value in values} - - -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 _xml_id_map( - values: Iterable[WorkflowNode] | Iterable[WorkflowEdge], - *, - prefix: str, -) -> dict[str, str]: - result: dict[str, str] = {} - used: set[str] = set() - for item in values: - configured = str(item.config.get("bpmn_id") or item.id) - candidate = _xml_id(prefix, configured) - if candidate in used: - candidate = _xml_id( - prefix, - f"{candidate}_{hashlib.sha256(item.id.encode()).hexdigest()[:8]}", - ) - used.add(candidate) - result[item.id] = candidate - return result - - -def _xml_id(prefix: str, value: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", value.strip()) - if not cleaned or not re.match(r"[A-Za-z_]", cleaned): - cleaned = f"{prefix}_{cleaned}" - return cleaned - - -def _serialize_element(element: Element) -> str: - return ElementTree.tostring(element, encoding="unicode") - - -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 _tag(namespace: str, local_name: str) -> str: - return f"{{{namespace}}}{local_name}" - - -def _number(value: float) -> str: - return f"{value:.3f}".rstrip("0").rstrip(".") - - -__all__ = [ - "BPMN_EDGE_LOCAL_NAMES", - "BPMN_NODE_LOCAL_NAMES", - "BpmnGraphError", - "NATIVE_BPMN_ADAPTER_ID", - "NATIVE_BPMN_ADAPTER_VERSION", - "canonical_bpmn_graph", - "export_bpmn_graph", - "import_bpmn_graph", - "is_native_bpmn_graph", - "legacy_graph_to_bpmn", - "materialize_runtime_graph", - "runtime_diagnostics", -] +from govoplan_workflow_engine.backend.bpmn_graph import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/db/__init__.py b/src/govoplan_workflow/backend/db/__init__.py index 4d74ee9..2b92a36 100644 --- a/src/govoplan_workflow/backend/db/__init__.py +++ b/src/govoplan_workflow/backend/db/__init__.py @@ -1,15 +1,3 @@ -from govoplan_workflow.backend.db.models import ( - WorkflowDefinition, - WorkflowDefinitionRevision, - WorkflowInstance, - WorkflowInstanceEvent, - WorkflowInstanceStep, -) +"""Compatibility facade for the extracted Workflow Engine backend.""" -__all__ = [ - "WorkflowDefinition", - "WorkflowDefinitionRevision", - "WorkflowInstance", - "WorkflowInstanceEvent", - "WorkflowInstanceStep", -] +from govoplan_workflow_engine.backend.db import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/db/models.py b/src/govoplan_workflow/backend/db/models.py index cfa2c3f..02622a8 100644 --- a/src/govoplan_workflow/backend/db/models.py +++ b/src/govoplan_workflow/backend/db/models.py @@ -1,474 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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"), - ) - - 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, - ) - 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", - ), - ) - - 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, - ) - 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", -] +from govoplan_workflow_engine.backend.db.models import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/governance.py b/src/govoplan_workflow/backend/governance.py index ab03d02..6f9d746 100644 --- a/src/govoplan_workflow/backend/governance.py +++ b/src/govoplan_workflow/backend/governance.py @@ -1,323 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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.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", -] +from govoplan_workflow_engine.backend.governance import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/instance_service.py b/src/govoplan_workflow/backend/instance_service.py index 0fedc79..8a801f9 100644 --- a/src/govoplan_workflow/backend/instance_service.py +++ b/src/govoplan_workflow/backend/instance_service.py @@ -1,2373 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -from collections.abc import Mapping -from datetime import datetime -import hashlib -import logging - -from sqlalchemy import func, select -from sqlalchemy.orm import Session, selectinload - -from govoplan_core.auth import ApiPrincipal, has_scope -from govoplan_core.core.automation import ( - ActionDefinition, - ActionExecutionRequest, - ActionExecutionResult, - ActionPreview, - AutomationInvocation, - AutomationPrincipalRequest, - action_effect_provider, - automation_principal_provider, -) -from govoplan_core.core.dataflows import ( - DataflowPublicationTarget, - DataflowRunDescriptor, - DataflowRunRequest, - dataflow_run_lifecycle, -) -from govoplan_core.core.notifications import ( - NotificationDispatchRequest, - notification_dispatch_provider, -) -from govoplan_core.db.base import utcnow -from govoplan_workflow.backend.db.models import ( - WorkflowDefinition, - WorkflowDefinitionRevision, - WorkflowInstance, - WorkflowInstanceEvent, - WorkflowInstanceStep, -) -from govoplan_workflow.backend.governance import require_definition_action -from govoplan_workflow.backend.bpmn_graph import ( - BpmnGraphError, - materialize_runtime_graph, -) -from govoplan_workflow.backend.schemas import ( - WorkflowGraph, - WorkflowInstanceEventResponse, - WorkflowInstanceResponse, - WorkflowInstanceStartRequest, - WorkflowInstanceStepResponse, - WorkflowNode, - WorkflowStepActionRequest, - WorkflowViewContextResponse, -) -from govoplan_workflow.backend.service import ( - WorkflowConflictError, - WorkflowNotFoundError, - get_definition, - get_definition_revision, -) - - -INSTANCE_START_SCOPE = "workflow:instance:start" -INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition" -DATAFLOW_RUN_SCOPE = "dataflow:pipeline:run" -MAX_INSTANCE_TRANSITIONS = 100 -logger = logging.getLogger(__name__) - - -def list_instances( - session: Session, - *, - tenant_id: str, - definition_id: str | None = None, - limit: int = 100, -) -> list[WorkflowInstance]: - statement = ( - select(WorkflowInstance) - .where(WorkflowInstance.tenant_id == tenant_id) - .options( - selectinload(WorkflowInstance.steps), - selectinload(WorkflowInstance.events), - selectinload(WorkflowInstance.definition), - ) - .order_by( - WorkflowInstance.updated_at.desc(), - WorkflowInstance.id.desc(), - ) - .limit(max(1, min(int(limit), 200))) - ) - if definition_id: - statement = statement.where( - WorkflowInstance.definition_id == definition_id - ) - return list(session.scalars(statement)) - - -def get_instance( - session: Session, - *, - tenant_id: str, - instance_id: str, - for_update: bool = False, -) -> WorkflowInstance: - statement = ( - select(WorkflowInstance) - .where( - WorkflowInstance.id == instance_id, - WorkflowInstance.tenant_id == tenant_id, - ) - .options( - selectinload(WorkflowInstance.steps), - selectinload(WorkflowInstance.events), - selectinload(WorkflowInstance.definition), - ) - ) - if for_update: - statement = statement.with_for_update() - instance = session.scalar(statement) - if instance is None: - raise WorkflowNotFoundError("Workflow instance not found.") - return instance - - -def start_instance( - session: Session, - *, - tenant_id: str, - definition_id: str, - actor_id: str | None, - principal: ApiPrincipal, - registry: object | None, - payload: WorkflowInstanceStartRequest, - start_origin: str = "user", -) -> tuple[WorkflowInstance, bool]: - definition = get_definition( - session, - tenant_id=tenant_id, - definition_id=definition_id, - ) - if definition.definition_kind == "template": - raise WorkflowConflictError("Workflow templates cannot be started.") - if definition.status != "active" or definition.active_revision is None: - raise WorkflowConflictError( - "Activate a Workflow revision before starting an instance." - ) - try: - require_definition_action( - definition, - principal=principal, - registry=registry, - action="start", - ) - except PermissionError as exc: - raise WorkflowConflictError(str(exc)) from exc - revision = get_definition_revision( - session, - definition=definition, - revision=definition.active_revision, - ) - normalized_origin = _normalize_start_origin(start_origin) - if normalized_origin != "user" and not definition.allow_automation: - raise WorkflowConflictError( - "This Workflow does not allow automated starts." - ) - if revision.execution_mode == "guided" and normalized_origin != "user": - raise WorkflowConflictError( - "Guided workflows must be started by a user; use hybrid mode " - "for triggered workflows with human handoffs." - ) - graph = _runtime_graph(revision) - _require_runtime_dependencies( - graph, - principal=principal, - registry=registry, - ) - key = payload.idempotency_key.strip() - existing = session.scalar( - select(WorkflowInstance).where( - WorkflowInstance.tenant_id == tenant_id, - WorkflowInstance.definition_id == definition.id, - WorkflowInstance.idempotency_key == key, - ) - ) - if existing is not None: - if ( - dict(existing.input_) != dict(payload.input) - or existing.correlation_id != payload.correlation_id - or existing.start_origin != normalized_origin - ): - raise WorkflowConflictError( - "The Workflow idempotency key was already used with " - "different input." - ) - return get_instance( - session, - tenant_id=tenant_id, - instance_id=existing.id, - ), True - start_node = _start_node( - graph, - kind=_start_kind_for_origin(normalized_origin), - ) - now = utcnow() - instance = WorkflowInstance( - tenant_id=tenant_id, - definition_id=definition.id, - definition_revision_id=revision.id, - status="running", - start_origin=normalized_origin, - idempotency_key=key, - correlation_id=payload.correlation_id, - input_=dict(payload.input), - context_={"input": dict(payload.input), "steps": {}}, - output_={}, - authorization_=_authorization_payload( - principal, - graph=graph, - registry=registry, - ), - started_at=now, - created_by=actor_id, - ) - session.add(instance) - session.flush() - instance.authorization_ = { - **dict(instance.authorization_), - "authorization_ref": f"workflow-instance:{instance.id}", - } - _record_event( - session, - instance, - kind="workflow.instance.started", - actor_id=actor_id, - payload={ - "definition_ref": f"workflow-definition:{definition.id}", - "revision": revision.revision, - "definition_hash": revision.content_hash, - "execution_mode": revision.execution_mode, - "start_origin": normalized_origin, - "input": dict(payload.input), - }, - ) - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=start_node.id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - session.flush() - return instance, False - - -def reconcile_instance( - session: Session, - *, - instance: WorkflowInstance, - principal: ApiPrincipal, - registry: object | None, - actor_id: str | None = None, -) -> bool: - if instance.status not in {"running", "waiting"}: - return False - step = _current_step(session, instance) - if step is None: - return False - revision = session.get( - WorkflowDefinitionRevision, - instance.definition_revision_id, - ) - if revision is None: - _fail_instance( - session, - instance, - message="Pinned Workflow revision no longer exists.", - ) - return True - graph = _runtime_graph(revision) - node = _node(graph, step.node_id) - if step.node_type == "workflow.capability": - if str(step.handoff.get("state") or "") not in { - "", - "pending", - "running", - "retrying", - }: - return False - return _execute_capability_step( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - if step.node_type != "workflow.dataflow": - return False - if not step.external_ref: - _start_dataflow_step( - session, - instance=instance, - step=step, - node=node, - principal=principal, - registry=registry, - graph=graph, - actor_id=actor_id, - ) - return True - provider = dataflow_run_lifecycle(registry) - if provider is None: - _set_dependency_handoff( - session, - instance=instance, - step=step, - message="The Dataflow module is not available.", - ) - return False - try: - descriptor = provider.get_run( - session, - principal, - run_ref=step.external_ref, - ) - except ValueError as exc: - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message=str(exc), - ) - return True - if descriptor is None: - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message="The linked Dataflow run no longer exists.", - ) - return True - if descriptor.status in {"queued", "retrying", "running"}: - step.handoff = { - **dict(step.handoff), - "state": descriptor.status, - "progress_percent": int( - descriptor.metadata.get("progress_percent") or 0 - ), - "progress_phase": str( - descriptor.metadata.get("progress_phase") or descriptor.status - ), - } - return False - if descriptor.status == "succeeded": - return _handle_dataflow_success( - session, - instance=instance, - step=step, - node=node, - graph=graph, - descriptor=descriptor, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - if descriptor.status == "cancelled": - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message="The linked Dataflow run was cancelled.", - state="cancelled", - ) - return True - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message=descriptor.error or "The linked Dataflow run failed.", - ) - return True - - -def resolve_step( - session: Session, - *, - tenant_id: str, - instance_id: str, - step_id: str, - actor_id: str | None, - principal: ApiPrincipal, - registry: object | None, - payload: WorkflowStepActionRequest, -) -> WorkflowInstance: - instance = get_instance( - session, - tenant_id=tenant_id, - instance_id=instance_id, - for_update=True, - ) - if instance.status != "waiting" or instance.current_step_id != step_id: - raise WorkflowConflictError( - "Only the current waiting Workflow step can be resolved." - ) - step = session.get(WorkflowInstanceStep, step_id) - if step is None or step.instance_id != instance.id: - raise WorkflowNotFoundError("Workflow step not found.") - revision = session.get( - WorkflowDefinitionRevision, - instance.definition_revision_id, - ) - if revision is None: - raise WorkflowConflictError( - "Pinned Workflow revision no longer exists." - ) - graph = _runtime_graph(revision) - node = _node(graph, step.node_id) - allowed_actions = { - str(action) - for action in step.handoff.get("allowed_actions") or () - } - if payload.action not in allowed_actions: - raise WorkflowConflictError( - f"Action {payload.action!r} is not available for this handoff." - ) - _record_event( - session, - instance, - step=step, - kind="workflow.step.action", - actor_id=actor_id, - payload={ - "action": payload.action, - "comment": payload.comment, - "evidence": list(payload.evidence), - "output": dict(payload.output), - }, - ) - if payload.action == "cancel": - return cancel_instance( - session, - tenant_id=tenant_id, - instance_id=instance_id, - actor_id=actor_id, - principal=principal, - registry=registry, - ) - if payload.action == "changes": - step.handoff = { - **dict(step.handoff), - "state": "changes_requested", - "last_comment": payload.comment, - } - return instance - if payload.action == "retry": - if step.node_type == "workflow.capability": - step.status = "running" - step.error = None - step.handoff = { - **dict(step.handoff), - "state": "retrying", - } - instance.status = "running" - instance.error = None - _execute_capability_step( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return instance - if step.node_type != "workflow.dataflow": - raise WorkflowConflictError( - "Only failed module-action or Dataflow handoffs can be retried." - ) - step.status = "superseded" - step.finished_at = utcnow() - step.completed_by = actor_id - instance.status = "running" - instance.current_step_id = None - instance.error = None - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=node.id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return instance - port = _action_port(step, payload.action) - output = { - **dict(step.output_), - **dict(payload.output), - "decision": payload.action, - "comment": payload.comment, - "evidence": list(payload.evidence), - } - next_node_id = _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port=port, - output=output, - actor_id=actor_id, - ) - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=next_node_id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return instance - - -def cancel_instance( - session: Session, - *, - tenant_id: str, - instance_id: str, - actor_id: str | None, - principal: ApiPrincipal, - registry: object | None, -) -> WorkflowInstance: - instance = get_instance( - session, - tenant_id=tenant_id, - instance_id=instance_id, - for_update=True, - ) - if instance.status in {"completed", "failed", "cancelled"}: - raise WorkflowConflictError( - f"Workflow instance is already {instance.status}." - ) - now = utcnow() - instance.cancellation_requested_at = now - step = _current_step(session, instance) - if step is not None and step.external_ref: - provider = dataflow_run_lifecycle(registry) - if provider is not None: - try: - provider.cancel_run( - session, - principal, - run_ref=step.external_ref, - ) - except ValueError as exc: - logger.info( - "Linked Dataflow run could not be cancelled for " - "Workflow instance %s: %s", - instance.id, - exc, - ) - step.status = "cancelled" - step.finished_at = now - step.completed_by = actor_id - instance.status = "cancelled" - instance.finished_at = now - instance.current_step_id = None - instance.error = "Cancelled by request." - _record_event( - session, - instance, - step=step, - kind="workflow.instance.cancelled", - actor_id=actor_id, - payload={"external_ref": step.external_ref if step else None}, - ) - return instance - - -def reconcile_pending_instances( - session: Session, - *, - registry: object | None, - limit: int = 50, -) -> dict[str, object]: - instances = list( - session.scalars( - select(WorkflowInstance) - .where( - WorkflowInstance.status.in_(("running", "waiting")), - WorkflowInstance.current_step_id.is_not(None), - ) - .order_by(WorkflowInstance.updated_at, WorkflowInstance.id) - .limit(max(1, min(int(limit), 200))) - .with_for_update(skip_locked=True) - ) - ) - summary: dict[str, object] = { - "inspected": len(instances), - "advanced": 0, - "waiting": 0, - "failed": 0, - "skipped": 0, - } - for instance in instances: - step = _current_step(session, instance) - if step is None or step.node_type not in { - "workflow.capability", - "workflow.dataflow", - }: - summary["waiting"] = int(summary["waiting"]) + 1 - continue - principal = _resolve_instance_principal( - session, - instance=instance, - registry=registry, - ) - if principal is None: - summary["skipped"] = int(summary["skipped"]) + 1 - continue - changed = reconcile_instance( - session, - instance=instance, - principal=principal, - registry=registry, - ) - if instance.status == "failed": - summary["failed"] = int(summary["failed"]) + 1 - elif changed: - summary["advanced"] = int(summary["advanced"]) + 1 - else: - summary["waiting"] = int(summary["waiting"]) + 1 - session.flush() - return summary - - -def instance_response( - session: Session, - instance: WorkflowInstance, - *, - replayed: bool = False, -) -> WorkflowInstanceResponse: - revision = session.get( - WorkflowDefinitionRevision, - instance.definition_revision_id, - ) - definition = session.get(WorkflowDefinition, instance.definition_id) - if revision is None or definition is None: - raise WorkflowNotFoundError( - "Workflow instance definition evidence is incomplete." - ) - view_context = _instance_view_context(instance, revision) - return WorkflowInstanceResponse( - id=instance.id, - definition_id=instance.definition_id, - definition_name=definition.name, - definition_revision=revision.revision, - definition_hash=revision.content_hash, - execution_mode=revision.execution_mode, # type: ignore[arg-type] - start_origin=instance.start_origin, # type: ignore[arg-type] - view_context=view_context, - status=instance.status, # type: ignore[arg-type] - idempotency_key=instance.idempotency_key, - correlation_id=instance.correlation_id, - current_step_id=instance.current_step_id, - input=dict(instance.input_), - context=dict(instance.context_), - output=dict(instance.output_), - started_at=instance.started_at, - finished_at=instance.finished_at, - cancellation_requested_at=instance.cancellation_requested_at, - error=instance.error, - created_by=instance.created_by, - created_at=instance.created_at, - updated_at=instance.updated_at, - steps=[ - WorkflowInstanceStepResponse( - id=step.id, - sequence=step.sequence, - node_id=step.node_id, - node_type=step.node_type, - status=step.status, # type: ignore[arg-type] - attempt=step.attempt, - input=dict(step.input_), - output=dict(step.output_), - handoff=dict(step.handoff), - external_ref=step.external_ref, - started_at=step.started_at, - finished_at=step.finished_at, - error=step.error, - completed_by=step.completed_by, - created_at=step.created_at, - updated_at=step.updated_at, - ) - for step in instance.steps - ], - events=[ - WorkflowInstanceEventResponse( - id=event.id, - sequence=event.sequence, - step_id=event.step_id, - kind=event.kind, - actor_id=event.actor_id, - payload=dict(event.payload), - created_at=event.created_at, - ) - for event in instance.events - ], - replayed=replayed, - ) - - -def _drive_instance( - session: Session, - *, - instance: WorkflowInstance, - graph: WorkflowGraph, - next_node_id: str | None, - principal: ApiPrincipal, - registry: object | None, - actor_id: str | None, -) -> None: - transitions = 0 - while ( - next_node_id is not None - and instance.status == "running" - and transitions < MAX_INSTANCE_TRANSITIONS - ): - transitions += 1 - node = _node(graph, next_node_id) - step = _new_step( - session, - instance=instance, - node=node, - ) - instance.current_step_id = step.id - _record_event( - session, - instance, - step=step, - kind="workflow.step.started", - actor_id=actor_id, - payload={"node_id": node.id, "node_type": node.type}, - ) - if node.type.startswith("workflow.start."): - next_node_id = _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port="output", - output={"input": dict(instance.input_)}, - actor_id=actor_id, - ) - continue - if node.type == "workflow.dataflow": - _start_dataflow_step( - session, - instance=instance, - step=step, - node=node, - principal=principal, - registry=registry, - graph=graph, - actor_id=actor_id, - ) - return - if node.type == "workflow.capability": - _execute_capability_step( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return - if node.type in { - "workflow.activity", - "workflow.review", - "workflow.wait", - }: - _set_human_handoff( - session, - instance=instance, - step=step, - node=node, - registry=registry, - ) - return - if node.type == "workflow.end.completed": - _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port="output", - output=dict(instance.context_), - actor_id=actor_id, - ) - instance.status = "completed" - instance.finished_at = utcnow() - instance.current_step_id = None - instance.output_ = dict(instance.context_) - _record_event( - session, - instance, - step=step, - kind="workflow.instance.completed", - actor_id=actor_id, - payload={"output": dict(instance.output_)}, - ) - return - if node.type == "workflow.end.cancelled": - step.status = "completed" - step.finished_at = utcnow() - instance.status = "cancelled" - instance.finished_at = utcnow() - instance.current_step_id = None - _record_event( - session, - instance, - step=step, - kind="workflow.instance.cancelled", - actor_id=actor_id, - payload={"reason": node.config.get("reason")}, - ) - return - _set_dependency_handoff( - session, - instance=instance, - step=step, - message=( - f"Runtime support for {node.type} requires an explicit " - "operator transition." - ), - ) - return - if next_node_id is None and instance.status == "running": - _fail_instance( - session, - instance, - message="Workflow reached a step without a configured transition.", - ) - return - if transitions >= MAX_INSTANCE_TRANSITIONS: - _fail_instance( - session, - instance, - message="Workflow exceeded the bounded transition limit.", - ) - - -def _execute_capability_step( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - node: WorkflowNode, - graph: WorkflowGraph, - principal: ApiPrincipal, - registry: object | None, - actor_id: str | None, -) -> bool: - try: - provider, definition = _capability_action_definition( - node, - principal=principal, - registry=registry, - ) - execution_context = { - **dict(instance.context_), - "instance": { - "id": instance.id, - "definition_id": instance.definition_id, - "correlation_id": instance.correlation_id, - }, - "step": { - "id": step.id, - "node_id": step.node_id, - "sequence": step.sequence, - "attempt": step.attempt, - }, - } - action_input = _mapped_action_input( - node.config.get("input_mapping"), - execution_context, - ) - request = ActionExecutionRequest( - tenant_id=instance.tenant_id, - action_key=definition.action_key, - input=action_input, - idempotency_key=_action_idempotency_key( - node, - step=step, - capability_name=str(node.config.get("capability") or ""), - action_key=definition.action_key, - context=execution_context, - ), - invocation=AutomationInvocation( - kind="workflow", - trigger_ref=f"workflow-instance:{instance.id}", - correlation_id=instance.correlation_id, - causation_id=f"workflow-step:{step.id}", - requested_by=instance.created_by, - metadata={ - "workflow_definition_ref": ( - f"workflow-definition:{instance.definition_id}" - ), - "workflow_node_id": node.id, - }, - ), - actor_ref=instance.created_by, - metadata={ - "workflow_instance_ref": f"workflow-instance:{instance.id}", - "workflow_step_ref": f"workflow-step:{step.id}", - }, - ) - preview = provider.preview_action( - session, - principal, - request=request, - ) - except WorkflowConflictError as exc: - _set_action_handoff( - session, - instance=instance, - step=step, - state="blocked", - message=str(exc), - action_key=str(node.config.get("operation") or ""), - capability_name=str(node.config.get("capability") or ""), - registry=registry, - ) - return True - except ValueError as exc: - _set_action_handoff( - session, - instance=instance, - step=step, - state="blocked", - message=str(exc), - action_key=str(node.config.get("operation") or ""), - capability_name=str(node.config.get("capability") or ""), - registry=registry, - ) - return True - except Exception as exc: - logger.exception( - "Workflow action preview failed for instance %s step %s", - instance.id, - step.id, - ) - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message=( - "The module action preview failed unexpectedly. No action " - "execution was attempted." - ), - action_key=str(node.config.get("operation") or ""), - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={"error_type": type(exc).__name__}, - ) - return True - if not isinstance(preview, ActionPreview): - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message="The action provider returned an invalid preview.", - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - ) - return True - preview_payload = _action_preview_payload(preview) - if preview.action_key != definition.action_key: - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message="The action provider returned a preview for another action.", - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={"preview": preview_payload}, - ) - return True - if not preview.allowed: - _set_action_handoff( - session, - instance=instance, - step=step, - state="blocked", - message=preview.summary or "The module action is not allowed.", - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={"preview": preview_payload}, - ) - return True - request = ActionExecutionRequest( - tenant_id=request.tenant_id, - action_key=request.action_key, - input=request.input, - idempotency_key=request.idempotency_key, - invocation=request.invocation, - actor_ref=request.actor_ref, - preview_ref=preview.preview_ref, - metadata=request.metadata, - ) - try: - result = provider.execute_action( - session, - principal, - request=request, - ) - except Exception as exc: - logger.exception( - "Workflow action execution failed for instance %s step %s", - instance.id, - step.id, - ) - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message=( - "The module action outcome is unknown. Inspect the provider " - "before retrying to avoid a duplicate effect." - ), - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={ - "preview": preview_payload, - "error_type": type(exc).__name__, - "outcome_unknown": True, - }, - ) - return True - if not isinstance(result, ActionExecutionResult): - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message="The action provider returned an invalid execution result.", - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={"preview": preview_payload}, - ) - return True - allowed_states = { - "pending", - "running", - "completed", - "blocked", - "retryable", - "quarantined", - "manual_required", - "compensation_required", - } - announced_effects = { - item.effect_key for item in provider.effect_definitions() - } - unknown_effects = sorted( - { - effect.effect_key - for effect in result.observed_effects - if effect.effect_key not in announced_effects - } - ) - if result.state not in allowed_states or unknown_effects: - _set_action_handoff( - session, - instance=instance, - step=step, - state="quarantined", - message=( - "The action provider returned an unsupported state." - if result.state not in allowed_states - else "The action provider reported unannounced effects." - ), - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={ - "state": result.state, - "unknown_effects": unknown_effects, - }, - ) - return True - result_payload = _action_result_payload(result) - step.output_ = { - "action_key": definition.action_key, - "capability": str(node.config.get("capability") or ""), - "idempotency_key": request.idempotency_key, - "preview": preview_payload, - "execution": result_payload, - } - if result.state != "completed": - _set_action_handoff( - session, - instance=instance, - step=step, - state=result.state, - message=( - result.error - or result.manual_instructions - or f"Module action is {result.state.replace('_', ' ')}." - ), - action_key=definition.action_key, - capability_name=str(node.config.get("capability") or ""), - registry=registry, - details={ - "preview": preview_payload, - "execution": result_payload, - }, - ) - return True - _record_event( - session, - instance, - step=step, - kind="workflow.action.completed", - actor_id=actor_id, - payload={ - "action_key": definition.action_key, - "capability": str(node.config.get("capability") or ""), - "idempotency_key": request.idempotency_key, - "observed_effects": result_payload["observed_effects"], - "audit_event_refs": result_payload["audit_event_refs"], - }, - ) - suggested_port = str(result.output.get("outcome") or "success") - if suggested_port not in {"success", "warning"}: - suggested_port = "success" - next_node_id = _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port=suggested_port, - output=dict(step.output_), - actor_id=actor_id, - ) - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=next_node_id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return True - - -def _capability_action_definition( - node: WorkflowNode, - *, - principal: ApiPrincipal, - registry: object | None, -) -> tuple[object, ActionDefinition]: - capability_name = str(node.config.get("capability") or "").strip() - action_key = str(node.config.get("operation") or "").strip() - if not capability_name or not action_key: - raise WorkflowConflictError( - "Module-action steps require a capability and operation." - ) - provider = action_effect_provider(registry, capability_name) - if provider is None: - raise WorkflowConflictError( - f"Action capability {capability_name!r} is not available." - ) - definitions = [ - item - for item in provider.action_definitions() - if item.action_key == action_key - ] - if len(definitions) != 1: - raise WorkflowConflictError( - f"Action {action_key!r} is not uniquely announced by " - f"{capability_name!r}." - ) - definition = definitions[0] - missing_scopes = [ - scope - for scope in definition.required_scopes - if not has_scope(principal, scope) - ] - if missing_scopes: - raise WorkflowConflictError( - "Module action requires scopes: " - + ", ".join(sorted(missing_scopes)) - ) - missing_capabilities = [ - capability - for capability in definition.required_capabilities - if ( - registry is None - or not hasattr(registry, "has_capability") - or not registry.has_capability(capability) - ) - ] - if missing_capabilities: - raise WorkflowConflictError( - "Module action requires capabilities: " - + ", ".join(sorted(missing_capabilities)) - ) - effect_keys = { - item.effect_key for item in provider.effect_definitions() - } - missing_effects = sorted( - set(definition.expected_effect_keys) - effect_keys - ) - if missing_effects: - raise WorkflowConflictError( - "Action provider does not define its expected effects: " - + ", ".join(missing_effects) - ) - return provider, definition - - -def _mapped_action_input( - raw_mapping: object, - context: Mapping[str, object], -) -> dict[str, object]: - if raw_mapping is None or raw_mapping == "": - return dict(context) - if not isinstance(raw_mapping, Mapping): - raise WorkflowConflictError( - "Module-action input mapping must be an object." - ) - return { - str(key): _resolve_action_value(value, context, depth=0) - for key, value in raw_mapping.items() - if str(key).strip() - } - - -def _resolve_action_value( - value: object, - context: Mapping[str, object], - *, - depth: int, -) -> object: - if depth > 10: - raise WorkflowConflictError( - "Module-action input mapping is nested too deeply." - ) - if isinstance(value, str) and value.startswith("$"): - path = value[1:].lstrip(".") - current: object = context - if not path: - return dict(context) - for segment in path.split("."): - if not isinstance(current, Mapping) or segment not in current: - raise WorkflowConflictError( - f"Module-action input path {value!r} is unavailable." - ) - current = current[segment] - return current - if isinstance(value, Mapping): - return { - str(key): _resolve_action_value( - nested, - context, - depth=depth + 1, - ) - for key, nested in value.items() - } - if isinstance(value, list): - return [ - _resolve_action_value(item, context, depth=depth + 1) - for item in value - ] - return value - - -def _action_idempotency_key( - node: WorkflowNode, - *, - step: WorkflowInstanceStep, - capability_name: str, - action_key: str, - context: Mapping[str, object], -) -> str: - expression = str( - node.config.get("idempotency_key") or "workflow-step" - ).strip() - if expression == "workflow-step": - return step.idempotency_key - resolved = _resolve_action_value(expression, context, depth=0) - key = f"{capability_name}:{action_key}:{resolved}" - if len(key) <= 255: - return key - digest = hashlib.sha256(key.encode("utf-8")).hexdigest() - return f"{capability_name[:80]}:{action_key[:80]}:{digest}" - - -def _action_preview_payload(preview: object) -> dict[str, object]: - return { - "action_key": str(getattr(preview, "action_key", "")), - "allowed": bool(getattr(preview, "allowed", False)), - "summary": str(getattr(preview, "summary", "")), - "risk_level": str(getattr(preview, "risk_level", "")), - "reversibility": str(getattr(preview, "reversibility", "")), - "preview_ref": getattr(preview, "preview_ref", None), - "blockers": list(getattr(preview, "blockers", ()) or ()), - "policy_provenance": [ - dict(item) - for item in getattr(preview, "policy_provenance", ()) or () - ], - "effects": [ - { - "effect_key": item.effect_key, - "summary": item.summary, - "resource_refs": list(item.resource_refs), - "external_system_refs": list(item.external_system_refs), - } - for item in getattr(preview, "effects", ()) or () - ], - } - - -def _action_result_payload( - result: ActionExecutionResult, -) -> dict[str, object]: - return { - "state": result.state, - "output": dict(result.output), - "observed_effects": [ - { - "effect_key": effect.effect_key, - "operation": effect.operation, - "resource_ref": effect.resource_ref, - "external_system_ref": effect.external_system_ref, - "audit_event_ref": effect.audit_event_ref, - "summary": effect.summary, - "metadata": dict(effect.metadata), - } - for effect in result.observed_effects - ], - "error": result.error, - "retry_after": ( - result.retry_after.isoformat() - if result.retry_after is not None - else None - ), - "manual_instructions": result.manual_instructions, - "compensation_action_key": result.compensation_action_key, - "audit_event_refs": list(result.audit_event_refs), - } - - -def _set_action_handoff( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - state: str, - message: str, - action_key: str, - capability_name: str, - registry: object | None, - details: Mapping[str, object] | None = None, -) -> None: - allowed_actions = ( - ["cancel"] - if state in {"pending", "running"} - else ["retry", "reject", "cancel"] - ) - previous = dict(step.handoff) - step.status = "waiting" - step.error = message if state not in {"pending", "running"} else None - step.handoff = { - "kind": "module_action", - "state": state, - "message": message, - "action_key": action_key, - "capability": capability_name, - "allowed_actions": allowed_actions, - "suggested_port": "failure", - **dict(details or {}), - } - instance.status = "waiting" - instance.error = step.error - if ( - previous.get("state") != state - or previous.get("message") != message - ): - _record_event( - session, - instance, - step=step, - kind=f"workflow.action.{state}", - actor_id=None, - payload=dict(step.handoff), - ) - if state not in {"pending", "running"}: - _notify_handoff( - session, - registry=registry, - instance=instance, - step=step, - subject=f"Workflow action requires attention: {action_key}", - ) - - -def _start_dataflow_step( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - node: WorkflowNode, - principal: ApiPrincipal, - registry: object | None, - graph: WorkflowGraph, - actor_id: str | None, -) -> None: - provider = dataflow_run_lifecycle(registry) - if provider is None: - _set_dependency_handoff( - session, - instance=instance, - step=step, - message="Enable Dataflow to execute this Workflow step.", - ) - return - pipeline_ref = str(node.config.get("pipeline_ref") or "").strip() - try: - revision = int(node.config.get("revision") or 0) - row_limit = max( - 1, - min(int(node.config.get("row_limit") or 500), 10_000), - ) - except (TypeError, ValueError): - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message="Dataflow revision and row limit must be integers.", - ) - return - if not pipeline_ref or revision < 1: - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message="Dataflow steps require a pipeline and pinned revision.", - ) - return - target_ref = str( - node.config.get("publication_target_ref") or "" - ).strip() - try: - run = provider.start_run( - session, - principal, - request=DataflowRunRequest( - pipeline_ref=pipeline_ref, - revision=revision, - idempotency_key=step.idempotency_key, - row_limit=row_limit, - environment=str( - node.config.get("environment") or "development" - ), - publication=( - DataflowPublicationTarget( - target_datasource_ref=target_ref - ) - if target_ref - else None - ), - invocation=AutomationInvocation( - kind="workflow", - correlation_id=instance.correlation_id, - causation_id=f"workflow-step:{step.id}", - requested_by=instance.created_by, - metadata={ - "workflow_instance_ref": ( - f"workflow-instance:{instance.id}" - ), - "workflow_step_ref": f"workflow-step:{step.id}", - }, - ), - ), - ) - except ValueError as exc: - _handle_dataflow_failure( - session, - instance=instance, - step=step, - node=node, - graph=graph, - principal=principal, - registry=registry, - actor_id=actor_id, - message=str(exc), - ) - return - step.external_ref = run.ref - step.status = "waiting" - step.output_ = _dataflow_output(run) - step.handoff = { - "kind": "dataflow_run", - "state": run.status, - "run_ref": run.ref, - "pipeline_ref": pipeline_ref, - "pipeline_revision": revision, - "action_url": _dataflow_action_url(pipeline_ref, run.ref), - "allowed_actions": ["cancel"], - "progress_percent": int( - run.metadata.get("progress_percent") or 0 - ), - "progress_phase": str( - run.metadata.get("progress_phase") or run.status - ), - } - instance.status = "waiting" - _record_event( - session, - instance, - step=step, - kind="workflow.dataflow.started", - actor_id=instance.created_by, - payload=dict(step.handoff), - ) - - -def _handle_dataflow_success( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - node: WorkflowNode, - graph: WorkflowGraph, - descriptor: DataflowRunDescriptor, - principal: ApiPrincipal, - registry: object | None, - actor_id: str | None, -) -> bool: - diagnostics = descriptor.metadata.get("diagnostics") - items = diagnostics if isinstance(diagnostics, list) else [] - warnings = [ - dict(item) - for item in items - if isinstance(item, Mapping) - and str(item.get("severity") or "") == "warning" - ] - explicit_review = any( - str(item.get("code") or "") in { - "review.required", - "reconciliation.review_required", - } - for item in items - if isinstance(item, Mapping) - ) - output = _dataflow_output(descriptor) - step.output_ = output - if explicit_review or ( - warnings - and str(node.config.get("warning_policy") or "review") == "review" - ): - step.status = "waiting" - step.handoff = { - "kind": "dataflow_review", - "state": "review_required", - "run_ref": descriptor.ref, - "pipeline_ref": descriptor.pipeline_ref, - "action_url": _dataflow_action_url( - descriptor.pipeline_ref, - descriptor.ref, - ), - "allowed_actions": [ - "approve", - "changes", - "reject", - "retry", - "cancel", - ], - "suggested_port": ( - "review_required" if explicit_review else "warning" - ), - "warnings": warnings, - "output": output, - } - instance.status = "waiting" - _record_event( - session, - instance, - step=step, - kind="workflow.dataflow.review_required", - actor_id=actor_id, - payload=dict(step.handoff), - ) - _notify_handoff( - session, - registry=registry, - instance=instance, - step=step, - subject="Workflow Dataflow review required", - ) - return True - port = "warning" if warnings else "success" - if port == "warning" and _next_node_id(graph, node.id, port) is None: - port = "success" - next_node_id = _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port=port, - output=output, - actor_id=actor_id, - ) - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=next_node_id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return True - - -def _complete_step( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - graph: WorkflowGraph, - port: str, - output: Mapping[str, object], - actor_id: str | None, -) -> str | None: - step.status = "completed" - step.output_ = dict(output) - step.finished_at = utcnow() - step.completed_by = actor_id - step.handoff = {} - context = dict(instance.context_) - step_values = dict(context.get("steps") or {}) - step_values[step.node_id] = dict(output) - context["steps"] = step_values - instance.context_ = context - instance.status = "running" - instance.current_step_id = None - _record_event( - session, - instance, - step=step, - kind="workflow.step.completed", - actor_id=actor_id, - payload={"port": port, "output": dict(output)}, - ) - return _next_node_id(graph, step.node_id, port) - - -def _set_human_handoff( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - node: WorkflowNode, - registry: object | None, -) -> None: - if node.type == "workflow.review": - actions = ["approve", "changes", "reject", "cancel"] - kind = "review" - elif node.type == "workflow.wait": - actions = ["resume", "cancel"] - kind = "wait" - else: - actions = ["complete", "cancel"] - kind = "activity" - step.status = "waiting" - step.handoff = { - "kind": kind, - "state": "waiting", - "title": str(node.config.get("title") or node.label or node.type), - "instructions": str(node.config.get("instructions") or ""), - "assignee": node.config.get("reviewer") - or node.config.get("assignee"), - "required_evidence": list( - node.config.get("required_evidence") or [] - ), - "allowed_actions": actions, - } - instance.status = "waiting" - _record_event( - session, - instance, - step=step, - kind="workflow.handoff.created", - actor_id=instance.created_by, - payload=dict(step.handoff), - ) - _notify_handoff( - session, - registry=registry, - instance=instance, - step=step, - subject=str(step.handoff["title"]), - ) - - -def _set_dependency_handoff( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - message: str, -) -> None: - step.status = "waiting" - step.error = message - step.handoff = { - "kind": "dependency", - "state": "blocked", - "message": message, - "allowed_actions": ["retry", "cancel"], - } - instance.status = "waiting" - instance.error = message - _record_event( - session, - instance, - step=step, - kind="workflow.step.blocked", - actor_id=None, - payload=dict(step.handoff), - ) - - -def _handle_dataflow_failure( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - node: WorkflowNode, - graph: WorkflowGraph, - principal: ApiPrincipal, - registry: object | None, - actor_id: str | None, - message: str, - state: str = "failed", -) -> None: - policy = str(node.config.get("failure_policy") or "manual") - if policy == "manual": - _set_failure_handoff( - session, - instance=instance, - step=step, - message=message, - state=state, - ) - return - step.error = message - step.output_ = { - **dict(step.output_), - "status": state, - "error": message, - } - if policy == "continue": - _record_event( - session, - instance, - step=step, - kind="workflow.dataflow.failure_continued", - actor_id=actor_id, - payload={ - "error": message, - "external_ref": step.external_ref, - }, - ) - next_node_id = _complete_step( - session, - instance=instance, - step=step, - graph=graph, - port="failure", - output=dict(step.output_), - actor_id=actor_id, - ) - _drive_instance( - session, - instance=instance, - graph=graph, - next_node_id=next_node_id, - principal=principal, - registry=registry, - actor_id=actor_id, - ) - return - step.status = "failed" - step.finished_at = utcnow() - _record_event( - session, - instance, - step=step, - kind="workflow.dataflow.failed", - actor_id=actor_id, - payload={ - "error": message, - "external_ref": step.external_ref, - "failure_policy": "fail", - }, - ) - _fail_instance(session, instance, message=message) - - -def _set_failure_handoff( - session: Session, - *, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - message: str, - state: str = "failed", -) -> None: - step.status = "waiting" - step.error = message - step.handoff = { - "kind": "dataflow_failure", - "state": state, - "message": message, - "run_ref": step.external_ref, - "allowed_actions": ["retry", "reject", "cancel"], - "suggested_port": "failure", - } - instance.status = "waiting" - instance.error = message - _record_event( - session, - instance, - step=step, - kind="workflow.dataflow.failed", - actor_id=None, - payload=dict(step.handoff), - ) - - -def _fail_instance( - session: Session, - instance: WorkflowInstance, - *, - message: str, -) -> None: - instance.status = "failed" - instance.finished_at = utcnow() - instance.error = message - instance.current_step_id = None - _record_event( - session, - instance, - kind="workflow.instance.failed", - actor_id=None, - payload={"error": message}, - ) - - -def _new_step( - session: Session, - *, - instance: WorkflowInstance, - node: WorkflowNode, -) -> WorkflowInstanceStep: - sequence = int( - session.scalar( - select(func.max(WorkflowInstanceStep.sequence)).where( - WorkflowInstanceStep.instance_id == instance.id - ) - ) - or 0 - ) + 1 - attempt = int( - session.scalar( - select(func.count()) - .select_from(WorkflowInstanceStep) - .where( - WorkflowInstanceStep.instance_id == instance.id, - WorkflowInstanceStep.node_id == node.id, - ) - ) - or 0 - ) + 1 - step = WorkflowInstanceStep( - tenant_id=instance.tenant_id, - instance=instance, - sequence=sequence, - node_id=node.id, - node_type=node.type, - status="running", - attempt=attempt, - idempotency_key=( - f"workflow:{instance.id}:node:{node.id}:attempt:{attempt}" - ), - input_=dict(instance.context_), - output_={}, - handoff={}, - started_at=utcnow(), - ) - session.add(step) - session.flush() - return step - - -def _record_event( - session: Session, - instance: WorkflowInstance, - *, - kind: str, - actor_id: str | None, - payload: Mapping[str, object], - step: WorkflowInstanceStep | None = None, -) -> None: - sequence = int( - session.scalar( - select(func.max(WorkflowInstanceEvent.sequence)).where( - WorkflowInstanceEvent.instance_id == instance.id - ) - ) - or 0 - ) + 1 - event = WorkflowInstanceEvent( - tenant_id=instance.tenant_id, - instance=instance, - step_id=step.id if step else None, - sequence=sequence, - kind=kind, - actor_id=actor_id, - payload=dict(payload), - created_at=utcnow(), - ) - session.add(event) - session.flush() - - -def _current_step( - session: Session, - instance: WorkflowInstance, -) -> WorkflowInstanceStep | None: - if not instance.current_step_id: - return None - return session.get(WorkflowInstanceStep, instance.current_step_id) - - -def _start_node(graph: WorkflowGraph, *, kind: str) -> WorkflowNode: - expected = f"workflow.start.{kind}" - node = next((item for item in graph.nodes if item.type == expected), None) - if node is None: - starts = [ - item for item in graph.nodes - if item.type.startswith("workflow.start.") - ] - if len(starts) == 1: - node = starts[0] - if node is None: - raise WorkflowConflictError( - f"Workflow has no {kind} start node." - ) - return node - - -def _normalize_start_origin(value: str) -> str: - normalized = value.strip().lower() - allowed = { - "user", - "api", - "schedule", - "event", - "parent_workflow", - "dependency", - "retry", - "replay", - "backfill", - } - if normalized not in allowed: - raise WorkflowConflictError( - f"Unsupported Workflow start origin {value!r}." - ) - return normalized - - -def _start_kind_for_origin(origin: str) -> str: - return { - "user": "manual", - "api": "api", - "schedule": "schedule", - "event": "event", - "parent_workflow": "workflow", - "dependency": "workflow", - "retry": "api", - "replay": "api", - "backfill": "api", - }[origin] - - -def _instance_view_context( - instance: WorkflowInstance, - revision: WorkflowDefinitionRevision, -) -> WorkflowViewContextResponse | None: - if ( - not revision.view_id - or instance.status not in {"running", "waiting"} - ): - return None - step = next( - ( - item - for item in instance.steps - if item.id == instance.current_step_id - ), - None, - ) - node = None - if step is not None: - graph = _runtime_graph(revision) - node = next( - (item for item in graph.nodes if item.id == step.node_id), - None, - ) - surface_ids = ( - [ - str(surface_id).strip() - for surface_id in node.config.get("view_surface_ids") or () - if str(surface_id).strip() - ] - if node is not None - else [] - ) - return WorkflowViewContextResponse( - view_id=revision.view_id, - revision_id=revision.view_revision_id, - visible_surface_ids=list(dict.fromkeys(surface_ids)), - step_id=step.id if step is not None else None, - node_id=node.id if node is not None else None, - ) - - -def _node(graph: WorkflowGraph, node_id: str) -> WorkflowNode: - node = next((item for item in graph.nodes if item.id == node_id), None) - if node is None: - raise WorkflowConflictError( - f"Workflow node {node_id!r} no longer exists." - ) - return node - - -def _runtime_graph(revision: WorkflowDefinitionRevision) -> WorkflowGraph: - try: - return materialize_runtime_graph( - WorkflowGraph.model_validate(revision.graph) - ) - except BpmnGraphError as exc: - raise WorkflowConflictError(str(exc)) from exc - - -def _next_node_id( - graph: WorkflowGraph, - source_id: str, - port: str, -) -> str | None: - outgoing = [edge for edge in graph.edges if edge.source == source_id] - exact = [edge for edge in outgoing if edge.source_port == port] - if len(exact) == 1: - return exact[0].target - if not exact and len(outgoing) == 1: - return outgoing[0].target - if not exact: - return None - raise WorkflowConflictError( - f"Workflow node {source_id!r} has multiple {port!r} transitions." - ) - - -def _action_port( - step: WorkflowInstanceStep, - action: str, -) -> str: - if step.node_type == "workflow.review": - return { - "approve": "approved", - "complete": "approved", - "reject": "rejected", - }.get(action, "changes") - if step.node_type == "workflow.wait": - return "resumed" - if step.node_type == "workflow.dataflow": - if action == "reject": - return "failure" - return str(step.handoff.get("suggested_port") or "success") - if step.node_type == "workflow.capability": - if action == "reject": - return "failure" - return str(step.handoff.get("suggested_port") or "success") - return "output" - - -def _dataflow_output( - descriptor: DataflowRunDescriptor, -) -> dict[str, object]: - return { - "run_ref": descriptor.ref, - "status": descriptor.status, - "definition_hash": descriptor.definition_hash, - "output_publication_ref": descriptor.output_publication_ref, - "output_datasource_ref": descriptor.output_datasource_ref, - "output_materialization_ref": descriptor.output_materialization_ref, - "input_row_count": descriptor.input_row_count, - "output_row_count": descriptor.output_row_count, - "diagnostics": list( - descriptor.metadata.get("diagnostics") or [] - ), - } - - -def _dataflow_action_url(pipeline_ref: str, run_ref: str) -> str: - pipeline_id = pipeline_ref.removeprefix("pipeline:") - return f"/dataflow?pipelineId={pipeline_id}&runRef={run_ref}" - - -def _require_runtime_dependencies( - graph: WorkflowGraph, - *, - principal: ApiPrincipal, - registry: object | None, -) -> None: - if any(node.type == "workflow.dataflow" for node in graph.nodes): - if dataflow_run_lifecycle(registry) is None: - raise WorkflowConflictError( - "This Workflow requires the optional Dataflow module." - ) - if not has_scope(principal, DATAFLOW_RUN_SCOPE): - raise WorkflowConflictError( - "Starting this Workflow requires dataflow:pipeline:run." - ) - for node in graph.nodes: - if node.type == "workflow.capability": - _capability_action_definition( - node, - principal=principal, - registry=registry, - ) - - -def _authorization_payload( - principal: ApiPrincipal, - *, - graph: WorkflowGraph, - registry: object | None, -) -> dict[str, object]: - principal_ref = principal.to_platform_principal() - scopes = {INSTANCE_START_SCOPE} - if any(node.type == "workflow.dataflow" for node in graph.nodes): - scopes.add(DATAFLOW_RUN_SCOPE) - for node in graph.nodes: - if node.type != "workflow.capability": - continue - _provider, definition = _capability_action_definition( - node, - principal=principal, - registry=registry, - ) - scopes.update(definition.required_scopes) - return { - "contract_version": "1", - "subject_kind": ( - "service_account" - if principal_ref.service_account_id - else "delegated_user" - ), - "account_id": principal_ref.account_id, - "membership_id": principal_ref.membership_id, - "service_account_id": principal_ref.service_account_id, - "grant_scopes": sorted(scopes), - "authorization_ref": None, - } - - -def _resolve_instance_principal( - session: Session, - *, - instance: WorkflowInstance, - registry: object | None, -) -> ApiPrincipal | None: - provider = automation_principal_provider(registry) - if provider is None: - return None - value = dict(instance.authorization_) - common = { - "tenant_id": instance.tenant_id, - "authorization_ref": str( - value.get("authorization_ref") - or f"workflow-instance:{instance.id}" - ), - "grant_scopes": tuple( - str(scope) for scope in value.get("grant_scopes") or () - ), - "context": { - "workflow_instance_ref": f"workflow-instance:{instance.id}", - "definition_ref": ( - f"workflow-definition:{instance.definition_id}" - ), - }, - } - try: - if value.get("subject_kind") == "service_account": - request = AutomationPrincipalRequest.service_account( - service_account_id=str( - value.get("service_account_id") or "" - ), - **common, - ) - else: - request = AutomationPrincipalRequest.delegated_user( - account_id=str(value.get("account_id") or ""), - membership_id=str(value.get("membership_id") or ""), - **common, - ) - except ValueError as exc: - instance.authorization_ = { - **value, - "last_resolution": { - "allowed": False, - "reason": str(exc), - }, - "resolved_at": utcnow().isoformat(), - } - return None - resolution = provider.resolve_automation_principal( - session, - request=request, - ) - instance.authorization_ = { - **value, - "last_resolution": dict(resolution.provenance), - "resolved_at": utcnow().isoformat(), - } - return ( - resolution.principal - if resolution.allowed - and isinstance(resolution.principal, ApiPrincipal) - else None - ) - - -def _notify_handoff( - session: Session, - *, - registry: object | None, - instance: WorkflowInstance, - step: WorkflowInstanceStep, - subject: str, -) -> None: - provider = notification_dispatch_provider(registry) - account_id = str( - instance.authorization_.get("account_id") or "" - ).strip() - if provider is None or not account_id: - return - try: - provider.enqueue_notification( - session, - NotificationDispatchRequest( - tenant_id=instance.tenant_id, - source_module="workflow", - source_resource_type="workflow_instance", - source_resource_id=instance.id, - event_kind="workflow.handoff.required", - recipient_type="account", - recipient_id=account_id, - subject=subject, - action_url=( - "/workflow?" - f"definition={instance.definition_id}" - f"&run={instance.id}" - ), - payload={ - "instance_id": instance.id, - "step_id": step.id, - "handoff": dict(step.handoff), - }, - ), - ) - except Exception: - logger.warning( - "Workflow handoff notification enqueue failed for instance %s", - instance.id, - exc_info=True, - ) - - -class SqlWorkflowRuntimeWorker: - def __init__(self, *, registry: object | None = None) -> None: - self._registry = registry - - def reconcile_pending( - self, - session: object, - *, - now: datetime | None = None, - limit: int = 50, - ) -> Mapping[str, object]: - del now - if not isinstance(session, Session): - raise TypeError("Workflow reconciliation requires a Session.") - return reconcile_pending_instances( - session, - registry=self._registry, - limit=limit, - ) - - -__all__ = [ - "SqlWorkflowRuntimeWorker", - "cancel_instance", - "get_instance", - "instance_response", - "list_instances", - "reconcile_instance", - "reconcile_pending_instances", - "resolve_step", - "start_instance", -] +from govoplan_workflow_engine.backend.instance_service import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/manifest.py b/src/govoplan_workflow/backend/manifest.py index 6b997ef..2d49d53 100644 --- a/src/govoplan_workflow/backend/manifest.py +++ b/src/govoplan_workflow/backend/manifest.py @@ -1,221 +1,55 @@ 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, FrontendModule, FrontendRoute, - MigrationSpec, - ModuleContext, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, NavItem, - PermissionDefinition, - RoleTemplate, ViewSurface, ) -from govoplan_core.core.policy import ( - CAPABILITY_POLICY_DEFINITION_GOVERNANCE, +from govoplan_workflow_engine.backend.manifest import ( + ADMIN_SCOPE, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + INSTANCE_READ_SCOPE, + INSTANCE_START_SCOPE, + INSTANCE_TRANSITION_SCOPE, ) -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_RUNTIME_WORKER, -) -from govoplan_core.db.base import Base -from govoplan_workflow.backend.db import models as workflow_models MODULE_ID = "workflow" MODULE_NAME = "Workflow" 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.backend.runtime import configure_runtime - - configure_runtime(registry=context.registry, settings=context.settings) - from govoplan_workflow.backend.router import router - - return router - - -def _runtime_worker(context: ModuleContext): - from govoplan_workflow.backend.instance_service import ( - SqlWorkflowRuntimeWorker, - ) - - return SqlWorkflowRuntimeWorker(registry=context.registry) - manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, version=MODULE_VERSION, - 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, - ), + dependencies=("workflow_engine",), 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.runtime_worker", version=MODULE_VERSION), - ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"), - ModuleInterfaceProvider( - name="workflow.bpmn_execution_adapters", - version="1.0.0", - ), + ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION), ), 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", + name="workflow.definition_graph", version_min="0.1.0", version_max_exclusive="1.0.0", - optional=True, ), ModuleInterfaceRequirement( - name=CAPABILITY_NOTIFICATIONS_DISPATCH, + name="workflow.definition_catalogue", 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, + name="workflow.bpmn_interchange", + version_min="1.0.0", + version_max_exclusive="2.0.0", ), ), - permissions=PERMISSIONS, - role_templates=ROLE_TEMPLATES, nav_items=( NavItem( path="/workflow", @@ -255,83 +89,26 @@ manifest = ModuleManifest( ), ), ), - route_factory=_router, - capability_factories={ - 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 built on the shared definition editor contract.", - body=( - "Workflow provides its own 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", + id="workflow.editor", + title="Workflow editor and inspection workspace", summary=( - "Lossless BPMN 2.0 revisions with explicit, fail-closed " - "execution conformance." + "Optional authoring, validation, revision inspection, and " + "operator controls for Workflow Engine." ), 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 never " - "imports a concrete engine module." + "Workflow adds the visual BPMN/native graph editor, definition " + "catalogue, immutable revision comparison, activation controls, " + "instance inspection, and governed override/reset experience. " + "Definitions and instances remain owned and executed by the " + "headless Workflow Engine module." ), layer="available", documentation_types=("admin", "user"), audience=("operator", "module_admin", "power_user", "product_owner"), - related_modules=("audit", "policy", "views"), - order=77, + related_modules=("workflow_engine", "views", "policy", "audit"), + order=76, ), ), ) diff --git a/src/govoplan_workflow/backend/migrations/__init__.py b/src/govoplan_workflow/backend/migrations/__init__.py deleted file mode 100644 index 863565f..0000000 --- a/src/govoplan_workflow/backend/migrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Workflow database migrations.""" diff --git a/src/govoplan_workflow/backend/migrations/versions/__init__.py b/src/govoplan_workflow/backend/migrations/versions/__init__.py deleted file mode 100644 index 7dd88e5..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Workflow Alembic revisions.""" diff --git a/src/govoplan_workflow/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py b/src/govoplan_workflow/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py deleted file mode 100644 index bfa46c8..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py +++ /dev/null @@ -1,201 +0,0 @@ -"""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") diff --git a/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py b/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py deleted file mode 100644 index 245275d..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py +++ /dev/null @@ -1,209 +0,0 @@ -"""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, - ) diff --git a/src/govoplan_workflow/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py b/src/govoplan_workflow/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py deleted file mode 100644 index a7479a9..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py +++ /dev/null @@ -1,213 +0,0 @@ -"""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") diff --git a/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py b/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py deleted file mode 100644 index 27ccdcf..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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") diff --git a/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py b/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py deleted file mode 100644 index f0ccbc5..0000000 --- a/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py +++ /dev/null @@ -1,62 +0,0 @@ -"""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") diff --git a/src/govoplan_workflow/backend/node_library.py b/src/govoplan_workflow/backend/node_library.py index 92ce1c3..38653c6 100644 --- a/src/govoplan_workflow/backend/node_library.py +++ b/src/govoplan_workflow/backend/node_library.py @@ -1,1150 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -from govoplan_core.core.definition_graphs import ( - DefinitionConfigField, - DefinitionGraphConstraints, - DefinitionGraphLibrary, - DefinitionNodeType, - DefinitionPort, -) - - -CATEGORY_LABELS = { - "bpmn_event": "Events", - "bpmn_activity": "Activities", - "bpmn_gateway": "Gateways", - "bpmn_data": "Data", - "bpmn_collaboration": "Collaboration", - "bpmn_artifact": "Artifacts", - "trigger": "Start", - "activity": "Activities", - "decision": "Decisions", - "wait": "Wait", - "integration": "Integrations", - "outcome": "Outcomes", -} - -FOCUSED_VIEW_SURFACES_FIELD = DefinitionConfigField( - id="view_surface_ids", - label="Focused View surfaces", - kind="view_surfaces", - description=( - "Optionally narrow the workflow View while this step requires " - "attention. Administrator limits and protected surfaces still apply." - ), -) - -LEGACY_WORKFLOW_NODE_TYPES = ( - DefinitionNodeType( - type="workflow.start.manual", - category="trigger", - label="Manual start", - description="Start an instance through an explicit user action.", - icon="circle-play", - default_config={"input_schema_ref": ""}, - config_fields=( - DefinitionConfigField( - id="input_schema_ref", - label="Input schema", - kind="text", - description="Optional schema contract for instance input.", - ), - ), - ), - DefinitionNodeType( - type="workflow.start.api", - category="trigger", - label="API start", - description=( - "Start through an authenticated API request with an explicit " - "input contract." - ), - icon="braces", - default_config={ - "input_schema_ref": "", - "authorization_policy_ref": "", - }, - config_fields=( - DefinitionConfigField( - id="input_schema_ref", - label="Input schema", - kind="text", - required=True, - ), - DefinitionConfigField( - id="authorization_policy_ref", - label="Authorization policy", - kind="text", - required=True, - ), - ), - ), - DefinitionNodeType( - type="workflow.start.workflow", - category="trigger", - label="Parent workflow", - description=( - "Start as a pinned child or dependency of another Workflow " - "instance." - ), - icon="git-branch", - default_config={ - "parent_definition_ref": "", - "parent_outcome": "completed", - "input_mapping": {}, - }, - config_fields=( - DefinitionConfigField( - id="parent_definition_ref", - label="Parent definition", - kind="text", - required=True, - ), - DefinitionConfigField( - id="parent_outcome", - label="Parent outcome", - kind="text", - required=True, - ), - DefinitionConfigField( - id="input_mapping", - label="Input mapping", - kind="mapping", - ), - ), - ), - DefinitionNodeType( - type="workflow.start.event", - category="trigger", - label="Event start", - description="Start when a matching platform event is received.", - icon="radio", - config_fields=( - DefinitionConfigField( - id="event_type", - label="Event type", - kind="text", - required=True, - ), - DefinitionConfigField( - id="filter", - label="Event filter", - kind="expression", - description="A constrained expression evaluated against the event envelope.", - ), - ), - default_config={"event_type": "", "filter": ""}, - ), - DefinitionNodeType( - type="workflow.start.schedule", - category="trigger", - label="Scheduled start", - description="Start according to a governed schedule.", - icon="calendar-clock", - config_fields=( - DefinitionConfigField( - id="schedule", - label="Schedule", - kind="schedule", - required=True, - ), - DefinitionConfigField( - id="timezone", - label="Time zone", - kind="timezone", - required=True, - ), - ), - default_config={"schedule": "", "timezone": "Europe/Berlin"}, - ), - DefinitionNodeType( - type="workflow.activity", - category="activity", - label="Activity", - description="Record and complete a governed unit of work.", - icon="square-check-big", - input_ports=(DefinitionPort(id="input", label="Input"),), - config_fields=( - DefinitionConfigField(id="title", label="Title", kind="text", required=True), - DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), - DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), - DefinitionConfigField(id="due_after", label="Due after", kind="duration"), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "title": "", - "instructions": "", - "assignee": "", - "due_after": "", - "view_surface_ids": [], - }, - ), - DefinitionNodeType( - type="workflow.review", - category="activity", - label="Review", - description="Pause for a human review with explicit outcomes.", - icon="clipboard-check", - input_ports=(DefinitionPort(id="input", label="Input"),), - output_ports=( - DefinitionPort(id="approved", label="Approved", required=False), - DefinitionPort(id="changes", label="Changes requested", required=False), - DefinitionPort(id="rejected", label="Rejected", required=False), - ), - config_fields=( - DefinitionConfigField(id="title", label="Title", kind="text", required=True), - DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"), - DefinitionConfigField( - id="required_evidence", - label="Required evidence", - kind="string_list", - ), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "title": "", - "reviewer": "", - "required_evidence": [], - "view_surface_ids": [], - }, - ), - DefinitionNodeType( - type="workflow.decision", - category="decision", - label="Decision", - description="Choose a path using a constrained expression.", - icon="split", - input_ports=(DefinitionPort(id="input", label="Input"),), - output_ports=( - DefinitionPort(id="true", label="Matches", required=False), - DefinitionPort(id="false", label="Does not match", required=False), - ), - config_fields=( - DefinitionConfigField( - id="expression", - label="Expression", - kind="expression", - required=True, - ), - ), - default_config={"expression": ""}, - ), - DefinitionNodeType( - type="workflow.wait", - category="wait", - label="Wait", - description="Wait for a duration, deadline, event, or explicit resume action.", - icon="timer", - input_ports=(DefinitionPort(id="input", label="Input"),), - output_ports=( - DefinitionPort(id="resumed", label="Resumed", required=False), - DefinitionPort(id="timed_out", label="Timed out", required=False), - ), - config_fields=( - DefinitionConfigField( - id="mode", - label="Wait for", - kind="select", - required=True, - options=( - ("duration", "Duration"), - ("deadline", "Deadline"), - ("event", "Event"), - ("manual", "Manual resume"), - ), - ), - DefinitionConfigField(id="value", label="Value", kind="text"), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "mode": "manual", - "value": "", - "view_surface_ids": [], - }, - ), - DefinitionNodeType( - type="workflow.capability", - category="integration", - label="Module action", - description="Invoke a versioned module capability without importing its implementation.", - icon="plug-zap", - input_ports=(DefinitionPort(id="input", label="Input"),), - output_ports=( - DefinitionPort(id="success", label="Success", required=False), - DefinitionPort(id="warning", label="Warning", required=False), - DefinitionPort( - id="review_required", - label="Review required", - required=False, - ), - DefinitionPort(id="failure", label="Failure", required=False), - ), - config_fields=( - DefinitionConfigField( - id="capability", - label="Capability", - kind="capability", - required=True, - ), - DefinitionConfigField( - id="operation", - label="Operation", - kind="text", - required=True, - ), - DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), - DefinitionConfigField( - id="idempotency_key", - label="Idempotency key", - kind="expression", - required=True, - ), - DefinitionConfigField( - id="failure_policy", - label="On failure", - kind="select", - required=True, - options=( - ("retry", "Retry"), - ("manual", "Require intervention"), - ("continue", "Continue on failure"), - ), - ), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "capability": "", - "operation": "", - "input_mapping": {}, - "idempotency_key": "workflow-step", - "failure_policy": "manual", - "view_surface_ids": [], - }, - ), - DefinitionNodeType( - type="workflow.dataflow", - category="integration", - label="Run dataflow", - description="Run a published Dataflow pipeline and retain its output reference.", - icon="waypoints", - input_ports=(DefinitionPort(id="input", label="Input"),), - output_ports=( - DefinitionPort(id="success", label="Success", required=False), - DefinitionPort(id="warning", label="Warning", required=False), - DefinitionPort( - id="review_required", - label="Review required", - required=False, - ), - DefinitionPort(id="failure", label="Failure", required=False), - ), - config_fields=( - DefinitionConfigField( - id="pipeline_ref", - label="Pipeline", - kind="dataflow", - required=True, - ), - DefinitionConfigField( - id="revision", - label="Pinned revision", - kind="number", - required=True, - ), - DefinitionConfigField( - id="environment", - label="Environment", - kind="select", - required=True, - options=( - ("development", "Development"), - ("staging", "Staging"), - ("production", "Production"), - ), - ), - DefinitionConfigField( - id="row_limit", - label="Output row limit", - kind="number", - required=True, - ), - DefinitionConfigField( - id="publication_target_ref", - label="Publication datasource", - kind="text", - description=( - "Optional stable Datasource target for materialized " - "output." - ), - ), - DefinitionConfigField( - id="warning_policy", - label="Warnings", - kind="select", - required=True, - options=( - ("review", "Require review"), - ("continue", "Continue"), - ), - ), - DefinitionConfigField( - id="failure_policy", - label="Failures", - kind="select", - options=( - ("manual", "Require intervention"), - ("fail", "Fail the workflow"), - ("continue", "Follow failure path"), - ), - ), - DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "pipeline_ref": "", - "revision": 1, - "environment": "development", - "row_limit": 500, - "publication_target_ref": "", - "warning_policy": "review", - "failure_policy": "manual", - "input_mapping": {}, - "view_surface_ids": [], - }, - ), - DefinitionNodeType( - type="workflow.end.completed", - category="outcome", - label="Completed", - description="Complete the workflow successfully.", - icon="circle-check-big", - input_ports=( - DefinitionPort( - id="input", - label="Input", - multiple=True, - minimum_connections=1, - ), - ), - output_ports=(), - config_fields=( - DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"), - ), - default_config={"output_mapping": {}}, - ), - DefinitionNodeType( - type="workflow.end.cancelled", - category="outcome", - label="Cancelled", - description="End the workflow without a successful outcome.", - icon="circle-x", - input_ports=( - DefinitionPort( - id="input", - label="Input", - multiple=True, - minimum_connections=1, - ), - ), - output_ports=(), - config_fields=( - DefinitionConfigField(id="reason", label="Reason", kind="text"), - ), - default_config={"reason": ""}, - ), -) - -_INCOMING = ( - DefinitionPort( - id="incoming", - label="Incoming", - required=False, - multiple=True, - minimum_connections=0, - ), -) -_OPTIONAL_INCOMING = ( - DefinitionPort( - id="incoming", - label="Incoming", - required=False, - multiple=True, - minimum_connections=0, - ), -) -_OUTGOING = ( - DefinitionPort( - id="outgoing", - label="Outgoing", - required=False, - multiple=True, - minimum_connections=0, - ), -) -_DOCUMENTATION_FIELD = DefinitionConfigField( - id="documentation", - label="Documentation", - kind="textarea", -) -_EVENT_DEFINITION_FIELD = DefinitionConfigField( - id="event_definition", - label="Event definition", - kind="select", - options=( - ("none", "None"), - ("message", "Message"), - ("timer", "Timer"), - ("conditional", "Conditional"), - ("signal", "Signal"), - ("error", "Error"), - ("escalation", "Escalation"), - ("compensation", "Compensation"), - ("link", "Link"), - ("cancel", "Cancel"), - ("terminate", "Terminate"), - ("multiple", "Multiple"), - ("parallelMultiple", "Parallel multiple"), - ), -) - - -def _bpmn_node( - *, - type_id: str, - category: str, - label: str, - description: str, - icon: str, - shape: str, - input_ports: tuple[DefinitionPort, ...] = _INCOMING, - output_ports: tuple[DefinitionPort, ...] = _OUTGOING, - config_fields: tuple[DefinitionConfigField, ...] = (), - default_config: dict[str, object] | None = None, - runtime_support: str = "model_only", -) -> DefinitionNodeType: - return DefinitionNodeType( - type=type_id, - category=category, - label=label, - description=description, - icon=icon, - input_ports=input_ports, - output_ports=output_ports, - config_fields=(*config_fields, _DOCUMENTATION_FIELD), - default_config={ - **(default_config or {}), - "documentation": "", - }, - metadata={ - "notation": "bpmn-2.0", - "shape": shape, - "runtime_support": runtime_support, - }, - ) - - -_TASK_FIELDS = ( - DefinitionConfigField(id="title", label="Task title", kind="text"), - DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), -) -_HUMAN_TASK_FIELDS = ( - *_TASK_FIELDS, - DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), - DefinitionConfigField(id="due_after", label="Due after", kind="duration"), - FOCUSED_VIEW_SURFACES_FIELD, -) -_SERVICE_TASK_FIELDS = ( - *_TASK_FIELDS, - DefinitionConfigField( - id="implementation", - label="Implementation", - kind="select", - options=( - ("capability", "Module capability"), - ("dataflow", "Dataflow"), - ), - ), - DefinitionConfigField(id="capability", label="Capability", kind="capability"), - DefinitionConfigField(id="operation", label="Operation", kind="text"), - DefinitionConfigField(id="pipeline_ref", label="Dataflow", kind="dataflow"), - DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), - DefinitionConfigField( - id="idempotency_key", - label="Idempotency key", - kind="expression", - ), - DefinitionConfigField( - id="failure_policy", - label="On failure", - kind="select", - options=( - ("retry", "Retry"), - ("manual", "Require intervention"), - ("continue", "Continue"), - ("fail", "Fail the workflow"), - ), - ), - FOCUSED_VIEW_SURFACES_FIELD, -) - -BPMN_NODE_TYPES = ( - _bpmn_node( - type_id="bpmn.startEvent", - category="bpmn_event", - label="Start event", - description="Start a BPMN process from a user, API, schedule, event, or parent flow.", - icon="circle-play", - shape="event-start", - input_ports=(), - config_fields=( - _EVENT_DEFINITION_FIELD, - DefinitionConfigField( - id="start_kind", - label="GovOPlaN start", - kind="select", - required=True, - options=( - ("manual", "Manual"), - ("api", "API"), - ("schedule", "Schedule"), - ("event", "Platform event"), - ("parent_workflow", "Parent workflow"), - ), - ), - DefinitionConfigField( - id="input_schema_ref", - label="Input schema", - kind="text", - ), - DefinitionConfigField( - id="authorization_policy_ref", - label="Authorization policy", - kind="text", - ), - DefinitionConfigField(id="schedule", label="Schedule", kind="schedule"), - DefinitionConfigField(id="timezone", label="Time zone", kind="timezone"), - DefinitionConfigField(id="event_type", label="Event type", kind="text"), - DefinitionConfigField(id="filter", label="Event filter", kind="expression"), - ), - default_config={ - "event_definition": "none", - "start_kind": "manual", - "input_schema_ref": "", - "authorization_policy_ref": "", - "schedule": "", - "timezone": "Europe/Berlin", - "event_type": "", - "filter": "", - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.intermediateCatchEvent", - category="bpmn_event", - label="Intermediate catch event", - description="Wait until the configured BPMN event is caught.", - icon="circle-dot", - shape="event-intermediate-catch", - config_fields=( - _EVENT_DEFINITION_FIELD, - DefinitionConfigField( - id="wait_mode", - label="Wait for", - kind="select", - options=( - ("duration", "Duration"), - ("deadline", "Deadline"), - ("event", "Event"), - ("manual", "Manual resume"), - ), - ), - DefinitionConfigField(id="value", label="Value", kind="text"), - DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "event_definition": "none", - "wait_mode": "manual", - "value": "", - "event_ref": "", - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.intermediateThrowEvent", - category="bpmn_event", - label="Intermediate throw event", - description="Emit an intermediate BPMN event.", - icon="circle-dot-dashed", - shape="event-intermediate-throw", - config_fields=( - _EVENT_DEFINITION_FIELD, - DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), - ), - default_config={"event_definition": "none", "event_ref": ""}, - ), - _bpmn_node( - type_id="bpmn.boundaryEvent", - category="bpmn_event", - label="Boundary event", - description="Catch an interrupting or non-interrupting event attached to an activity.", - icon="circle-dashed", - shape="event-boundary", - input_ports=(), - config_fields=( - _EVENT_DEFINITION_FIELD, - DefinitionConfigField( - id="attached_to_ref", - label="Attached activity", - kind="text", - required=True, - ), - DefinitionConfigField( - id="cancel_activity", - label="Activity behavior", - kind="select", - options=( - ("true", "Interrupt activity"), - ("false", "Keep activity running"), - ), - ), - ), - default_config={ - "event_definition": "none", - "attached_to_ref": "", - "cancel_activity": "true", - }, - ), - _bpmn_node( - type_id="bpmn.endEvent", - category="bpmn_event", - label="End event", - description="End a BPMN process with an optional result event.", - icon="circle-stop", - shape="event-end", - output_ports=(), - config_fields=( - _EVENT_DEFINITION_FIELD, - DefinitionConfigField( - id="outcome", - label="GovOPlaN outcome", - kind="select", - options=( - ("completed", "Completed"), - ("cancelled", "Cancelled"), - ), - ), - DefinitionConfigField( - id="output_mapping", - label="Output mapping", - kind="mapping", - ), - ), - default_config={ - "event_definition": "none", - "outcome": "completed", - "output_mapping": {}, - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.task", - category="bpmn_activity", - label="Task", - description="A generic BPMN task completed as a governed human activity.", - icon="square", - shape="activity", - config_fields=_HUMAN_TASK_FIELDS, - default_config={ - "title": "", - "instructions": "", - "assignee": "", - "due_after": "", - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.userTask", - category="bpmn_activity", - label="User task", - description="A task requiring a user action, review, or evidence.", - icon="user-round-check", - shape="activity", - config_fields=( - *_HUMAN_TASK_FIELDS, - DefinitionConfigField( - id="task_mode", - label="Task mode", - kind="select", - options=( - ("activity", "Activity"), - ("review", "Review"), - ), - ), - DefinitionConfigField( - id="required_evidence", - label="Required evidence", - kind="string_list", - ), - ), - default_config={ - "title": "", - "instructions": "", - "assignee": "", - "due_after": "", - "task_mode": "activity", - "required_evidence": [], - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.manualTask", - category="bpmn_activity", - label="Manual task", - description="A task performed outside the automated runtime.", - icon="hand", - shape="activity", - config_fields=_HUMAN_TASK_FIELDS, - default_config={ - "title": "", - "instructions": "", - "assignee": "", - "due_after": "", - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.serviceTask", - category="bpmn_activity", - label="Service task", - description="Invoke a versioned module capability or published Dataflow.", - icon="cog", - shape="activity", - config_fields=_SERVICE_TASK_FIELDS, - default_config={ - "title": "", - "instructions": "", - "implementation": "capability", - "capability": "", - "operation": "", - "pipeline_ref": "", - "input_mapping": {}, - "idempotency_key": "workflow-step", - "failure_policy": "manual", - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.sendTask", - category="bpmn_activity", - label="Send task", - description="Send a message through a configured implementation.", - icon="send", - shape="activity", - config_fields=_SERVICE_TASK_FIELDS, - default_config={ - "title": "", - "instructions": "", - "implementation": "capability", - "capability": "", - "operation": "", - "pipeline_ref": "", - "input_mapping": {}, - "idempotency_key": "workflow-step", - "failure_policy": "manual", - "view_surface_ids": [], - }, - runtime_support="native", - ), - _bpmn_node( - type_id="bpmn.receiveTask", - category="bpmn_activity", - label="Receive task", - description="Wait until a matching message is received.", - icon="inbox", - shape="activity", - config_fields=( - *_TASK_FIELDS, - DefinitionConfigField(id="message_ref", label="Message reference", kind="text"), - FOCUSED_VIEW_SURFACES_FIELD, - ), - default_config={ - "title": "", - "instructions": "", - "message_ref": "", - "view_surface_ids": [], - }, - runtime_support="native", - ), - *( - _bpmn_node( - type_id=f"bpmn.{type_name}", - category="bpmn_activity", - label=label, - description=description, - icon=icon, - shape="activity", - config_fields=fields, - default_config=defaults, - ) - for type_name, label, description, icon, fields, defaults in ( - ( - "scriptTask", - "Script task", - "A BPMN script task retained as notation; arbitrary scripts are not executed.", - "file-code-2", - (*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")), - {"title": "", "instructions": "", "script_format": "", "script": ""}, - ), - ( - "businessRuleTask", - "Business rule task", - "Evaluate a governed business-rule implementation.", - "scale", - (*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")), - {"title": "", "instructions": "", "implementation_ref": ""}, - ), - ( - "callActivity", - "Call activity", - "Call another reusable BPMN process or GovOPlaN workflow.", - "external-link", - (*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)), - {"title": "", "instructions": "", "called_element": ""}, - ), - ( - "subProcess", - "Sub-process", - "Contain a nested BPMN process.", - "box-select", - _TASK_FIELDS, - {"title": "", "instructions": ""}, - ), - ( - "transaction", - "Transaction", - "Contain transaction-scoped BPMN activity semantics.", - "badge-dollar-sign", - _TASK_FIELDS, - {"title": "", "instructions": ""}, - ), - ( - "adHocSubProcess", - "Ad-hoc sub-process", - "Contain activities whose order is governed at runtime.", - "shuffle", - _TASK_FIELDS, - {"title": "", "instructions": ""}, - ), - ) - ), - *( - _bpmn_node( - type_id=f"bpmn.{type_name}", - category="bpmn_gateway", - label=label, - description=description, - icon=icon, - shape="gateway", - config_fields=(), - default_config={}, - runtime_support=runtime_support, - ) - for type_name, label, description, icon, runtime_support in ( - ("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"), - ("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"), - ("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"), - ("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"), - ("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"), - ) - ), - _bpmn_node( - type_id="bpmn.dataObjectReference", - category="bpmn_data", - label="Data object", - description="Reference data produced or consumed by an activity.", - icon="file", - shape="data-object", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"), - DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), - ), - default_config={"data_object_ref": "", "item_subject_ref": ""}, - ), - _bpmn_node( - type_id="bpmn.dataStoreReference", - category="bpmn_data", - label="Data store", - description="Reference persistent data used by the process.", - icon="database", - shape="data-store", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"), - DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), - ), - default_config={"data_store_ref": "", "item_subject_ref": ""}, - ), - _bpmn_node( - type_id="bpmn.participant", - category="bpmn_collaboration", - label="Participant / pool", - description="Represent a BPMN collaboration participant and its process.", - icon="rectangle-horizontal", - shape="participant", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="process_ref", label="Process reference", kind="text"), - ), - default_config={"process_ref": ""}, - ), - _bpmn_node( - type_id="bpmn.lane", - category="bpmn_collaboration", - label="Lane", - description="Group flow nodes by role or responsibility.", - icon="rows-3", - shape="lane", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"), - ), - default_config={"flow_node_refs": []}, - ), - _bpmn_node( - type_id="bpmn.textAnnotation", - category="bpmn_artifact", - label="Text annotation", - description="Attach explanatory text without changing process execution.", - icon="text-quote", - shape="text-annotation", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="text", label="Text", kind="textarea"), - DefinitionConfigField(id="text_format", label="Text format", kind="text"), - ), - default_config={"text": "", "text_format": "text/plain"}, - ), - _bpmn_node( - type_id="bpmn.group", - category="bpmn_artifact", - label="Group", - description="Visually group BPMN elements without changing execution.", - icon="box-select", - shape="group", - input_ports=_OPTIONAL_INCOMING, - config_fields=( - DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"), - ), - default_config={"category_value_ref": ""}, - ), - *( - _bpmn_node( - type_id=f"bpmn.{type_name}", - category="bpmn_collaboration", - label=label, - description=description, - icon=icon, - shape=shape, - input_ports=ports, - config_fields=( - DefinitionConfigField( - id="participant_refs", - label="Participants", - kind="string_list", - ), - DefinitionConfigField( - id="called_element", - label="Called element", - kind="text", - ), - ), - default_config={"participant_refs": [], "called_element": ""}, - ) - for type_name, label, description, icon, shape, ports in ( - ( - "choreographyTask", - "Choreography task", - "Model a message exchange between two or more participants.", - "messages-square", - "choreography", - _INCOMING, - ), - ( - "callChoreography", - "Call choreography", - "Call a reusable choreography.", - "external-link", - "choreography", - _INCOMING, - ), - ( - "subChoreography", - "Sub-choreography", - "Contain a nested choreography.", - "box-select", - "choreography", - _INCOMING, - ), - ( - "conversation", - "Conversation", - "Group logically related message exchanges.", - "messages-square", - "conversation", - _OPTIONAL_INCOMING, - ), - ( - "callConversation", - "Call conversation", - "Call a reusable global conversation.", - "external-link", - "conversation", - _OPTIONAL_INCOMING, - ), - ( - "subConversation", - "Sub-conversation", - "Contain a nested conversation.", - "box-select", - "conversation", - _OPTIONAL_INCOMING, - ), - ) - ), -) - -WORKFLOW_NODE_TYPES = (*BPMN_NODE_TYPES, *LEGACY_WORKFLOW_NODE_TYPES) - -WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary( - id="workflow", - version="1.0.0", - category_labels=CATEGORY_LABELS, - node_types=WORKFLOW_NODE_TYPES, - constraints=DefinitionGraphConstraints( - max_nodes=150, - max_edges=300, - allow_cycles=True, - require_connected=False, - ), -) - -WORKFLOW_NODE_TYPES_BY_ID = { - definition.type: definition for definition in WORKFLOW_NODE_TYPES -} - - -__all__ = [ - "BPMN_NODE_TYPES", - "CATEGORY_LABELS", - "LEGACY_WORKFLOW_NODE_TYPES", - "WORKFLOW_GRAPH_LIBRARY", - "WORKFLOW_NODE_TYPES", - "WORKFLOW_NODE_TYPES_BY_ID", -] +from govoplan_workflow_engine.backend.node_library import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py index bc4ae14..cf902ed 100644 --- a/src/govoplan_workflow/backend/router.py +++ b/src/govoplan_workflow/backend/router.py @@ -1,1387 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from govoplan_core.api.v1.schemas import ( - ReferenceOptionListResponse, - ReferenceOptionResponse, -) -from govoplan_core.audit.logging import audit_event -from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope -from govoplan_core.core.references import ( - access_scope_reference_page, - access_scope_reference_provider_available, - validate_access_scope_reference, -) -from govoplan_core.db.session import get_session -from govoplan_workflow.backend.governance import ( - definition_decision, - normalize_definition_scope, - require_definition_action, -) -from govoplan_workflow.backend.manifest import ( - ADMIN_SCOPE, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - INSTANCE_READ_SCOPE, - INSTANCE_START_SCOPE, - INSTANCE_TRANSITION_SCOPE, -) -from govoplan_workflow.backend.node_library import ( - BPMN_NODE_TYPES, - WORKFLOW_GRAPH_LIBRARY, -) -from govoplan_workflow.backend.bpmn import ( - BPMN_MODEL_NAMESPACE, - BpmnDiagnostic, - BpmnInspectionError, - NATIVE_EXECUTION_ELEMENTS, - NATIVE_MAPPING_ELEMENTS, - inspect_bpmn_xml, -) -from govoplan_workflow.backend.bpmn_adapters import ( - BpmnAdapterError, - bpmn_adapter_registry, - compile_bpmn_to_graph, -) -from govoplan_workflow.backend.bpmn_graph import ( - BpmnGraphError, - NATIVE_BPMN_ADAPTER_ID, - NATIVE_BPMN_ADAPTER_VERSION, - canonical_bpmn_graph, - export_bpmn_graph, -) -from govoplan_workflow.backend.schemas import ( - BpmnAdapterProfileResponse, - BpmnCompileRequest, - BpmnCompileResponse, - BpmnDiagnosticResponse, - BpmnElementSupportResponse, - BpmnInspectionRequest, - BpmnInspectionResponse, - BpmnRevisionDocumentResponse, - BpmnRenderRequest, - BpmnRenderResponse, - BpmnSupportProfileResponse, - WorkflowConfigFieldResponse, - WorkflowDefinitionActivateRequest, - WorkflowDefinitionCreateRequest, - WorkflowDefinitionDeleteResponse, - WorkflowDefinitionDeriveRequest, - WorkflowDefinitionListResponse, - WorkflowDefinitionResponse, - WorkflowDefinitionRevisionListResponse, - WorkflowDefinitionRevisionResponse, - WorkflowDefinitionUpdateRequest, - WorkflowDiagnosticResponse, - WorkflowGraphValidationRequest, - WorkflowGraphValidationResponse, - WorkflowInstanceListResponse, - WorkflowInstanceResponse, - WorkflowInstanceStartRequest, - WorkflowNodeLibraryResponse, - WorkflowNodeTypeResponse, - WorkflowPortResponse, - WorkflowStepActionRequest, -) -from govoplan_workflow.backend.instance_service import ( - cancel_instance, - get_instance, - instance_response, - list_instances, - reconcile_instance, - resolve_step, - start_instance, -) -from govoplan_workflow.backend.runtime import get_registry -from govoplan_workflow.backend.service import ( - WorkflowBpmnValidationError, - WorkflowConflictError, - WorkflowError, - WorkflowNotFoundError, - WorkflowValidationError, - activate_definition, - archive_definition, - create_definition, - definition_response, - delete_definition, - derive_definition, - get_definition, - get_definition_revision, - list_definition_revisions, - list_definitions, - revision_response, - revision_bpmn_summary, - update_definition, -) -from govoplan_workflow.backend.validation import validate_workflow_graph - - -router = APIRouter(prefix="/workflow", tags=["workflow"]) - - -def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: - if any(has_scope(principal, scope) for scope in scopes): - return - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Missing one of the required scopes: {', '.join(scopes)}", - ) - - -def _http_error(exc: WorkflowError) -> HTTPException: - if isinstance(exc, WorkflowNotFoundError): - return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) - if isinstance(exc, WorkflowConflictError): - return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) - if isinstance(exc, WorkflowBpmnValidationError): - return HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "message": str(exc), - "diagnostics": [ - { - "severity": item.severity, - "code": item.code, - "message": item.message, - "element_id": item.element_id, - } - for item in exc.diagnostics - ], - }, - ) - if isinstance(exc, WorkflowValidationError): - return HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "message": str(exc), - "diagnostics": [ - { - "severity": item.severity, - "code": item.code, - "message": item.message, - "node_id": item.node_id, - "field": item.field, - } - for item in exc.diagnostics - ], - }, - ) - return HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) - - -def _governance_http_error( - exc: PermissionError | ValueError, -) -> HTTPException: - return HTTPException( - status_code=( - status.HTTP_403_FORBIDDEN - if isinstance(exc, PermissionError) - else status.HTTP_422_UNPROCESSABLE_CONTENT - ), - detail=str(exc), - ) - - -def _definition_response( - session: Session, - definition, - principal: ApiPrincipal, - *, - revision: int | None = None, -) -> WorkflowDefinitionResponse: - return definition_response( - session, - definition, - principal=principal, - registry=get_registry(), - revision=revision, - ) - - -def _actor_id(principal: ApiPrincipal) -> str | None: - return principal.account_id or principal.membership_id or principal.identity_id - - -def _audit( - session: Session, - principal: ApiPrincipal, - *, - action: str, - definition_id: str, - details: dict[str, object], -) -> None: - audit_event( - session, - tenant_id=principal.tenant_id, - user_id=getattr(principal.user, "id", None), - api_key_id=principal.api_key_id, - action=action, - object_type="workflow_definition", - object_id=definition_id, - details=details, - ) - - -def _audit_instance( - session: Session, - principal: ApiPrincipal, - *, - action: str, - instance_id: str, - details: dict[str, object], -) -> None: - audit_event( - session, - tenant_id=principal.tenant_id, - user_id=getattr(principal.user, "id", None), - api_key_id=principal.api_key_id, - action=action, - object_type="workflow_instance", - object_id=instance_id, - details=details, - ) - - -def _require_instance_view(instance, principal: ApiPrincipal) -> None: - require_definition_action( - instance.definition, - principal=principal, - registry=get_registry(), - action="view", - ) - - -def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse: - return BpmnAdapterProfileResponse( - id=profile.id, - version=profile.version, - label=profile.label, - description=profile.description, - conformance=profile.conformance, - runtime_kind=profile.runtime_kind, - executable=profile.executable, - supported_elements=list(profile.supported_elements), - supported_event_definitions=list(profile.supported_event_definitions), - requirements=list(profile.requirements), - ) - - -def _bpmn_inspection_response( - xml: str, - *, - adapter_id: str, - adapter_version: str | None = None, - activation: bool, -) -> BpmnInspectionResponse: - result = inspect_bpmn_xml(xml) - adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) - adapter_diagnostics = ( - adapter.diagnostics(xml, result, activation=activation) - if adapter is not None - else () - ) - diagnostics = list(result.diagnostics) - diagnostics.extend(adapter_diagnostics) - if adapter is None: - suffix = f"@{adapter_version}" if adapter_version else "" - diagnostics.append( - BpmnDiagnostic( - severity="error", - code="adapter.unavailable", - message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.", - ) - ) - deduplicated = { - (item.code, item.element_id, item.message): item - for item in diagnostics - }.values() - diagnostic_items = list(deduplicated) - return BpmnInspectionResponse( - valid_xml=result.valid_xml, - definitions_id=result.definitions_id, - target_namespace=result.target_namespace, - process_count=result.process_count, - executable_process_count=result.executable_process_count, - collaboration_count=result.collaboration_count, - choreography_count=result.choreography_count, - element_counts=result.element_counts, - support_counts=result.support_counts, - elements=[ - BpmnElementSupportResponse( - element_type=item.element_type, - element_id=item.element_id, - name=item.name, - parent_type=item.parent_type, - parent_id=item.parent_id, - support_level=item.support_level, - ) - for item in result.elements - ], - diagnostics=[ - BpmnDiagnosticResponse( - severity=item.severity, - code=item.code, - message=item.message, - element_id=item.element_id, - ) - for item in diagnostic_items - ], - adapter_id=adapter.profile.id if adapter else adapter_id, - adapter_version=( - adapter.profile.version if adapter else adapter_version - ), - runtime_kind=adapter.profile.runtime_kind if adapter else None, - executable=bool(adapter and adapter.profile.executable), - activatable=bool( - activation - and adapter - and adapter.profile.executable - and not any(item.severity == "error" for item in diagnostic_items) - ), - ) - - -@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) -def api_bpmn_support_profile( - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnSupportProfileResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - return BpmnSupportProfileResponse( - specification="BPMN 2.0.2", - model_namespace=BPMN_MODEL_NAMESPACE, - interchange=( - "BPMN 2.0 is the native Workflow graph language. XML import and " - "export preserve standard notation and diagram geometry without " - "a browser-side BPMN modeler." - ), - native_runtime=( - "Activation is fail-closed and requires a pinned adapter whose " - "declared conformance profile covers the complete document." - ), - native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), - native_mapping_elements=sorted( - NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS - ), - adapters=[ - _adapter_profile_response(profile) - for profile in bpmn_adapter_registry().profiles() - ], - ) - - -@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) -def api_inspect_bpmn( - payload: BpmnInspectionRequest, - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnInspectionResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - try: - return _bpmn_inspection_response( - payload.xml, - adapter_id=payload.adapter_id, - adapter_version=payload.adapter_version, - activation=payload.activation, - ) - except (BpmnAdapterError, BpmnInspectionError) as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc - - -@router.post("/bpmn/compile", response_model=BpmnCompileResponse) -def api_compile_bpmn( - payload: BpmnCompileRequest, - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnCompileResponse: - _require_any_scope( - principal, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - try: - adapter, _inspection, graph = compile_bpmn_to_graph( - payload.xml, - adapter_id=payload.adapter_id, - adapter_version=payload.adapter_version, - ) - if graph is None: - raise BpmnAdapterError( - ( - BpmnDiagnostic( - severity="error", - code="adapter.no_runtime_materialization", - message="The selected adapter does not compile to a native graph.", - ), - ) - ) - inspection_response = _bpmn_inspection_response( - payload.xml, - adapter_id=adapter.profile.id, - adapter_version=adapter.profile.version, - activation=False, - ) - except (BpmnAdapterError, BpmnInspectionError) as exc: - diagnostics = getattr(exc, "diagnostics", ()) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "message": str(exc), - "diagnostics": [ - { - "severity": item.severity, - "code": item.code, - "message": item.message, - "element_id": item.element_id, - } - for item in diagnostics - ], - }, - ) from exc - return BpmnCompileResponse( - adapter=_adapter_profile_response(adapter.profile), - graph=graph, - inspection=inspection_response, - ) - - -@router.post("/bpmn/render", response_model=BpmnRenderResponse) -def api_render_bpmn( - payload: BpmnRenderRequest, - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnRenderResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - try: - xml = export_bpmn_graph( - canonical_bpmn_graph(payload.graph), - name=payload.name, - ) - inspection = _bpmn_inspection_response( - xml, - adapter_id=NATIVE_BPMN_ADAPTER_ID, - adapter_version=NATIVE_BPMN_ADAPTER_VERSION, - activation=False, - ) - except (BpmnGraphError, BpmnInspectionError) as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc - return BpmnRenderResponse(xml=xml, inspection=inspection) - - -@router.get("/node-types", response_model=WorkflowNodeLibraryResponse) -def api_node_types( - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowNodeLibraryResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - return WorkflowNodeLibraryResponse( - id=WORKFLOW_GRAPH_LIBRARY.id, - version=WORKFLOW_GRAPH_LIBRARY.version, - allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles, - nodes=[ - WorkflowNodeTypeResponse( - type=definition.type, - category=definition.category, - category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category], - label=definition.label, - description=definition.description, - icon=definition.icon, - input_ports=[ - WorkflowPortResponse( - id=port.id, - label=port.label, - required=port.required, - multiple=port.multiple, - minimum_connections=port.minimum_connections, - ) - for port in definition.input_ports - ], - output_ports=[ - WorkflowPortResponse( - id=port.id, - label=port.label, - required=port.required, - multiple=port.multiple, - minimum_connections=port.minimum_connections, - ) - for port in definition.output_ports - ], - config_fields=[ - WorkflowConfigFieldResponse( - id=field.id, - label=field.label, - kind=field.kind, - required=field.required, - description=field.description, - options=list(field.options), - ) - for field in definition.config_fields - ], - default_config=dict(definition.default_config), - metadata=dict(definition.metadata), - ) - for definition in BPMN_NODE_TYPES - ], - ) - - -@router.get("/scope-targets", response_model=ReferenceOptionListResponse) -def api_scope_targets( - scope_type: str, - q: str = "", - selected: list[str] = Query(default=[]), - limit: int = Query(default=50, ge=1, le=200), - cursor: str | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> ReferenceOptionListResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - registry = get_registry() - try: - page = access_scope_reference_page( - registry, - principal, - scope_type=scope_type, - query=q, - selected_values=selected, - limit=limit, - cursor=cursor, - administrative=has_scope(principal, ADMIN_SCOPE), - session=session, - ) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc - return ReferenceOptionListResponse( - options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options], - provider_available=access_scope_reference_provider_available(registry), - next_cursor=page.next_cursor, - has_more=page.has_more, - ) - - -@router.post( - "/definitions/validate", - response_model=WorkflowGraphValidationResponse, -) -def api_validate_definition( - payload: WorkflowGraphValidationRequest, - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowGraphValidationResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - diagnostics = validate_workflow_graph(payload.graph) - return WorkflowGraphValidationResponse( - valid=not any(item.severity == "error" for item in diagnostics), - diagnostics=[ - WorkflowDiagnosticResponse( - severity=item.severity, - code=item.code, - message=item.message, - node_id=item.node_id, - field=item.field, - ) - for item in diagnostics - ], - ) - - -@router.get("/definitions", response_model=WorkflowDefinitionListResponse) -def api_list_definitions( - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionListResponse: - _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) - registry = get_registry() - definitions = [ - definition - for definition in list_definitions( - session, - tenant_id=principal.tenant_id, - ) - if definition_decision( - definition, - principal=principal, - registry=registry, - action="view", - ).allowed - ] - return WorkflowDefinitionListResponse( - definitions=[ - definition_response( - session, - definition, - principal=principal, - registry=registry, - ) - for definition in definitions - ] - ) - - -@router.get("/instances", response_model=WorkflowInstanceListResponse) -def api_list_instances( - definition_id: str | None = None, - limit: int = Query(default=100, ge=1, le=200), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceListResponse: - _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) - try: - instances = [ - instance - for instance in list_instances( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - limit=limit, - ) - if definition_decision( - instance.definition, - principal=principal, - registry=get_registry(), - action="view", - ).allowed - ] - return WorkflowInstanceListResponse( - instances=[ - instance_response(session, instance) - for instance in instances - ] - ) - except WorkflowError as exc: - raise _http_error(exc) from exc - - -@router.post( - "/definitions/{definition_id}/instances", - response_model=WorkflowInstanceResponse, - status_code=status.HTTP_201_CREATED, -) -def api_start_instance( - definition_id: str, - payload: WorkflowInstanceStartRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceResponse: - _require_any_scope(principal, INSTANCE_START_SCOPE, ADMIN_SCOPE) - try: - instance, replayed = start_instance( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - actor_id=_actor_id(principal), - principal=principal, - registry=get_registry(), - payload=payload, - start_origin=( - "user" if principal.auth_method == "session" else "api" - ), - ) - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit_instance( - session, - principal, - action=( - "workflow.instance.replayed" - if replayed - else "workflow.instance.started" - ), - instance_id=instance.id, - details={ - "definition_id": instance.definition_id, - "definition_revision_id": instance.definition_revision_id, - "idempotency_key": instance.idempotency_key, - "start_origin": instance.start_origin, - }, - ) - response = instance_response(session, instance, replayed=replayed) - session.commit() - return response - - -@router.get( - "/instances/{instance_id}", - response_model=WorkflowInstanceResponse, -) -def api_get_instance( - instance_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceResponse: - _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) - try: - instance = get_instance( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - ) - _require_instance_view(instance, principal) - return instance_response(session, instance) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - - -@router.post( - "/instances/{instance_id}/reconcile", - response_model=WorkflowInstanceResponse, -) -def api_reconcile_instance( - instance_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceResponse: - _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) - try: - instance = get_instance( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - for_update=True, - ) - _require_instance_view(instance, principal) - changed = reconcile_instance( - session, - instance=instance, - principal=principal, - registry=get_registry(), - actor_id=_actor_id(principal), - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - if changed: - _audit_instance( - session, - principal, - action="workflow.instance.reconciled", - instance_id=instance.id, - details={ - "status": instance.status, - "current_step_id": instance.current_step_id, - }, - ) - response = instance_response(session, instance) - session.commit() - return response - - -@router.post( - "/instances/{instance_id}/steps/{step_id}/actions", - response_model=WorkflowInstanceResponse, -) -def api_resolve_instance_step( - instance_id: str, - step_id: str, - payload: WorkflowStepActionRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceResponse: - _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) - try: - existing = get_instance( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - ) - _require_instance_view(existing, principal) - instance = resolve_step( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - step_id=step_id, - actor_id=_actor_id(principal), - principal=principal, - registry=get_registry(), - payload=payload, - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit_instance( - session, - principal, - action=f"workflow.instance.{payload.action}", - instance_id=instance.id, - details={ - "step_id": step_id, - "status": instance.status, - }, - ) - response = instance_response(session, instance) - session.commit() - return response - - -@router.post( - "/instances/{instance_id}/cancel", - response_model=WorkflowInstanceResponse, -) -def api_cancel_instance( - instance_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowInstanceResponse: - _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) - try: - instance = get_instance( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - ) - _require_instance_view(instance, principal) - instance = cancel_instance( - session, - tenant_id=principal.tenant_id, - instance_id=instance_id, - actor_id=_actor_id(principal), - principal=principal, - registry=get_registry(), - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit_instance( - session, - principal, - action="workflow.instance.cancelled", - instance_id=instance.id, - details={"status": instance.status}, - ) - response = instance_response(session, instance) - session.commit() - return response - - -@router.post( - "/definitions", - response_model=WorkflowDefinitionResponse, - status_code=status.HTTP_201_CREATED, -) -def api_create_definition( - payload: WorkflowDefinitionCreateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - tenant_id, scope_type, scope_id, _scope_key = ( - normalize_definition_scope( - principal, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - administrative=has_scope(principal, ADMIN_SCOPE), - ) - ) - payload = payload.model_copy( - update={"scope_type": scope_type, "scope_id": scope_id} - ) - canonical_scope_id = validate_access_scope_reference( - get_registry(), - tenant_id=tenant_id or principal.tenant_id, - scope_type=scope_type, - scope_id=scope_id, - ) - payload = payload.model_copy(update={"scope_id": canonical_scope_id}) - definition = create_definition( - session, - tenant_id=tenant_id or principal.tenant_id, - actor_id=_actor_id(principal), - payload=payload, - ) - except (PermissionError, ValueError) as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.created", - definition_id=definition.id, - details={ - "key": definition.definition_key, - "revision": definition.current_revision, - "scope_type": definition.scope_type, - "scope_id": definition.scope_id, - "definition_kind": definition.definition_kind, - }, - ) - response = _definition_response(session, definition, principal) - session.commit() - return response - - -@router.get( - "/definitions/{definition_id}", - response_model=WorkflowDefinitionResponse, -) -def api_get_definition( - definition_id: str, - revision: int | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) - try: - definition = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - definition, - principal=principal, - registry=get_registry(), - action="view", - ) - return _definition_response( - session, - definition, - principal, - revision=revision, - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - - -@router.put( - "/definitions/{definition_id}", - response_model=WorkflowDefinitionResponse, -) -def api_update_definition( - definition_id: str, - payload: WorkflowDefinitionUpdateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - existing = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - existing, - principal=principal, - registry=get_registry(), - action="edit", - ) - tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope( - principal, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - administrative=has_scope(principal, ADMIN_SCOPE), - ) - scope_id = validate_access_scope_reference( - get_registry(), - tenant_id=tenant_id or principal.tenant_id, - scope_type=scope_type, - scope_id=scope_id, - preserve_existing=( - existing.scope_id - if existing.scope_type == scope_type - else None - ), - ) - payload = payload.model_copy( - update={"scope_type": scope_type, "scope_id": scope_id} - ) - definition = update_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - actor_id=_actor_id(principal), - payload=payload, - ) - except (PermissionError, ValueError) as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.updated", - definition_id=definition.id, - details={ - "revision": definition.current_revision, - "status": definition.status, - }, - ) - response = _definition_response(session, definition, principal) - session.commit() - return response - - -@router.post( - "/definitions/{definition_id}/derive", - response_model=WorkflowDefinitionResponse, - status_code=status.HTTP_201_CREATED, -) -def api_derive_definition( - definition_id: str, - payload: WorkflowDefinitionDeriveRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - tenant_id, scope_type, scope_id, _scope_key = ( - normalize_definition_scope( - principal, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - administrative=has_scope(principal, ADMIN_SCOPE), - ) - ) - payload = payload.model_copy( - update={"scope_type": scope_type, "scope_id": scope_id} - ) - canonical_scope_id = validate_access_scope_reference( - get_registry(), - tenant_id=tenant_id or principal.tenant_id, - scope_type=scope_type, - scope_id=scope_id, - ) - payload = payload.model_copy(update={"scope_id": canonical_scope_id}) - definition = derive_definition( - session, - tenant_id=tenant_id or principal.tenant_id, - actor_id=_actor_id(principal), - principal=principal, - registry=get_registry(), - source_definition_id=definition_id, - payload=payload, - ) - except (PermissionError, ValueError) as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.derived", - definition_id=definition.id, - details={ - "source_definition_id": ( - definition.derived_from_definition_id - ), - "source_revision": definition.derived_from_revision, - "source_hash": definition.derived_from_hash, - "scope_type": definition.scope_type, - "scope_id": definition.scope_id, - }, - ) - response = _definition_response(session, definition, principal) - session.commit() - return response - - -@router.get( - "/definitions/{definition_id}/revisions", - response_model=WorkflowDefinitionRevisionListResponse, -) -def api_list_definition_revisions( - definition_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionRevisionListResponse: - _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) - try: - definition = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - definition, - principal=principal, - registry=get_registry(), - action="view", - ) - revisions = list_definition_revisions(session, definition=definition) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - return WorkflowDefinitionRevisionListResponse( - revisions=[revision_response(item) for item in revisions] - ) - - -@router.get( - "/definitions/{definition_id}/revisions/{revision}", - response_model=WorkflowDefinitionRevisionResponse, -) -def api_get_definition_revision( - definition_id: str, - revision: int, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionRevisionResponse: - _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) - try: - definition = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - definition, - principal=principal, - registry=get_registry(), - action="view", - ) - item = get_definition_revision( - session, - definition=definition, - revision=revision, - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - return revision_response(item) - - -@router.get( - "/definitions/{definition_id}/revisions/{revision}/bpmn", - response_model=BpmnRevisionDocumentResponse, -) -def api_get_definition_revision_bpmn( - definition_id: str, - revision: int, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnRevisionDocumentResponse: - _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) - try: - definition = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - definition, - principal=principal, - registry=get_registry(), - action="view", - ) - item = get_definition_revision( - session, - definition=definition, - revision=revision, - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - summary = revision_bpmn_summary(item) - if summary is None or not item.bpmn_xml: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="This Workflow revision has no BPMN document.", - ) - try: - inspection = _bpmn_inspection_response( - item.bpmn_xml, - adapter_id=summary.adapter_id, - adapter_version=summary.adapter_version, - activation=True, - ) - except BpmnInspectionError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc - return BpmnRevisionDocumentResponse( - **summary.model_dump(), - definition_id=definition.id, - revision=item.revision, - xml=item.bpmn_xml, - inspection=inspection, - ) - - -@router.post( - "/definitions/{definition_id}/activate", - response_model=WorkflowDefinitionResponse, -) -def api_activate_definition( - definition_id: str, - payload: WorkflowDefinitionActivateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - existing = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - existing, - principal=principal, - registry=get_registry(), - action="edit", - ) - definition = activate_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - actor_id=_actor_id(principal), - revision=payload.revision, - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.activated", - definition_id=definition.id, - details={"active_revision": definition.active_revision}, - ) - response = _definition_response(session, definition, principal) - session.commit() - return response - - -@router.post( - "/definitions/{definition_id}/archive", - response_model=WorkflowDefinitionResponse, -) -def api_archive_definition( - definition_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - existing = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - existing, - principal=principal, - registry=get_registry(), - action="edit", - ) - definition = archive_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - actor_id=_actor_id(principal), - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.archived", - definition_id=definition.id, - details={"active_revision": definition.active_revision}, - ) - response = _definition_response(session, definition, principal) - session.commit() - return response - - -@router.delete( - "/definitions/{definition_id}", - response_model=WorkflowDefinitionDeleteResponse, -) -def api_delete_definition( - definition_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -) -> WorkflowDefinitionDeleteResponse: - _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) - try: - existing = get_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - ) - require_definition_action( - existing, - principal=principal, - registry=get_registry(), - action="edit", - ) - definition = delete_definition( - session, - tenant_id=principal.tenant_id, - definition_id=definition_id, - actor_id=_actor_id(principal), - ) - except PermissionError as exc: - raise _governance_http_error(exc) from exc - except WorkflowError as exc: - raise _http_error(exc) from exc - _audit( - session, - principal, - action="workflow.definition.deleted", - definition_id=definition.id, - details={"revision": definition.current_revision}, - ) - session.commit() - return WorkflowDefinitionDeleteResponse( - deleted=True, - definition_id=definition.id, - ) - - -__all__ = ["router"] +from govoplan_workflow_engine.backend.router import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/runtime.py b/src/govoplan_workflow/backend/runtime.py index e947e73..7add09c 100644 --- a/src/govoplan_workflow/backend/runtime.py +++ b/src/govoplan_workflow/backend/runtime.py @@ -1,11 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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 +from govoplan_workflow_engine.backend.runtime import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py index ac4c492..4f06903 100644 --- a/src/govoplan_workflow/backend/schemas.py +++ b/src/govoplan_workflow/backend/schemas.py @@ -1,552 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -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 - created_by: str | None - created_at: datetime - - -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 - - -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] +from govoplan_workflow_engine.backend.schemas import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/service.py b/src/govoplan_workflow/backend/service.py index 75d1216..010fd93 100644 --- a/src/govoplan_workflow/backend/service.py +++ b/src/govoplan_workflow/backend/service.py @@ -1,970 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -from collections.abc import Mapping -from dataclasses import dataclass -import hashlib -import json -import re -import unicodedata - -from sqlalchemy import or_, select -from sqlalchemy.orm import Session - -from govoplan_core.auth import ApiPrincipal -from govoplan_core.db.base import utcnow -from govoplan_workflow.backend.db.models import ( - WorkflowDefinition, - WorkflowDefinitionRevision, -) -from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY -from govoplan_workflow.backend.bpmn import ( - BpmnDiagnostic, - BpmnInspectionError, -) -from govoplan_workflow.backend.bpmn_adapters import ( - RuntimeKind, - bpmn_adapter_registry, -) -from govoplan_workflow.backend.bpmn_graph import ( - BpmnGraphError, - NATIVE_BPMN_ADAPTER_ID, - NATIVE_BPMN_ADAPTER_VERSION, - canonical_bpmn_graph, - export_bpmn_graph, - import_bpmn_graph, - materialize_runtime_graph, - runtime_diagnostics, -) -from govoplan_workflow.backend.governance import ( - definition_governance_payload, - require_definition_action, -) -from govoplan_workflow.backend.schemas import ( - BpmnRevisionInput, - BpmnRevisionSummaryResponse, - WorkflowDefinitionCreateRequest, - WorkflowDefinitionDeriveRequest, - WorkflowDefinitionResponse, - WorkflowDefinitionRevisionResponse, - WorkflowDefinitionUpdateRequest, - WorkflowGraph, -) -from govoplan_workflow.backend.validation import validate_workflow_graph - - -class WorkflowError(RuntimeError): - pass - - -class WorkflowNotFoundError(WorkflowError): - pass - - -class WorkflowConflictError(WorkflowError): - pass - - -class WorkflowValidationError(WorkflowError): - def __init__(self, diagnostics: tuple[object, ...]) -> None: - first = diagnostics[0] if diagnostics else None - super().__init__( - str(getattr(first, "message", "Workflow definition validation failed.")) - ) - self.diagnostics = diagnostics - - -class WorkflowBpmnValidationError(WorkflowError): - def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None: - first = next( - (item for item in diagnostics if item.severity == "error"), - diagnostics[0] if diagnostics else None, - ) - super().__init__( - first.message if first is not None else "BPMN validation failed." - ) - self.diagnostics = diagnostics - - -@dataclass(frozen=True, slots=True) -class _BpmnArtifact: - xml: str - content_hash: str - adapter_id: str - adapter_version: str - runtime_kind: RuntimeKind - executable: bool - - -def list_definitions( - session: Session, - *, - tenant_id: str, -) -> list[WorkflowDefinition]: - return list( - session.scalars( - select(WorkflowDefinition) - .where( - or_( - WorkflowDefinition.tenant_id == tenant_id, - WorkflowDefinition.tenant_id.is_(None), - ), - WorkflowDefinition.deleted_at.is_(None), - ) - .order_by( - WorkflowDefinition.updated_at.desc(), - WorkflowDefinition.name.asc(), - ) - ) - ) - - -def get_definition( - session: Session, - *, - tenant_id: str, - definition_id: str, -) -> WorkflowDefinition: - definition = session.scalar( - select(WorkflowDefinition).where( - WorkflowDefinition.id == definition_id, - or_( - WorkflowDefinition.tenant_id == tenant_id, - WorkflowDefinition.tenant_id.is_(None), - ), - WorkflowDefinition.deleted_at.is_(None), - ) - ) - if definition is None: - raise WorkflowNotFoundError("Workflow definition not found.") - return definition - - -def get_definition_revision( - session: Session, - *, - definition: WorkflowDefinition, - revision: int | None = None, -) -> WorkflowDefinitionRevision: - revision_number = revision or definition.current_revision - item = session.scalar( - select(WorkflowDefinitionRevision).where( - WorkflowDefinitionRevision.definition_id == definition.id, - WorkflowDefinitionRevision.tenant_id == definition.tenant_id, - WorkflowDefinitionRevision.revision == revision_number, - ) - ) - if item is None: - raise WorkflowNotFoundError("Workflow definition revision not found.") - return item - - -def list_definition_revisions( - session: Session, - *, - definition: WorkflowDefinition, -) -> list[WorkflowDefinitionRevision]: - return list( - session.scalars( - select(WorkflowDefinitionRevision) - .where( - WorkflowDefinitionRevision.definition_id == definition.id, - WorkflowDefinitionRevision.tenant_id == definition.tenant_id, - ) - .order_by(WorkflowDefinitionRevision.revision.desc()) - ) - ) - - -def create_definition( - session: Session, - *, - tenant_id: str, - actor_id: str | None, - payload: WorkflowDefinitionCreateRequest, -) -> WorkflowDefinition: - graph, bpmn = _prepare_revision_content( - graph=payload.graph, - bpmn=payload.bpmn, - ) - stored_tenant_id = ( - None if payload.scope_type == "system" else tenant_id - ) - scope_id = ( - None - if payload.scope_type == "system" - else tenant_id - if payload.scope_type == "tenant" - else payload.scope_id - ) - scope_key = ( - "system" - if payload.scope_type == "system" - else f"{payload.scope_type}:{scope_id}" - ) - definition = WorkflowDefinition( - tenant_id=stored_tenant_id, - scope_type=payload.scope_type, - scope_id=scope_id, - scope_key=scope_key, - definition_kind=payload.definition_kind, - inherit_to_lower_scopes=payload.inherit_to_lower_scopes, - allow_start=payload.allow_start, - allow_reuse=payload.allow_reuse, - allow_automation=payload.allow_automation, - definition_key=_available_key( - session, - scope_key=scope_key, - requested=payload.key, - name=payload.name, - ), - name=payload.name.strip(), - description=_clean_optional(payload.description), - status="draft", - current_revision=1, - active_revision=None, - metadata_=dict(payload.metadata), - created_by=actor_id, - updated_by=actor_id, - ) - definition.revisions.append( - _new_revision( - tenant_id=stored_tenant_id, - revision=1, - graph=graph, - bpmn=bpmn, - execution_mode=payload.execution_mode, - view_id=payload.view_id, - view_revision_id=payload.view_revision_id, - actor_id=actor_id, - ) - ) - session.add(definition) - session.flush() - return definition - - -def update_definition( - session: Session, - *, - tenant_id: str, - definition_id: str, - actor_id: str | None, - payload: WorkflowDefinitionUpdateRequest, -) -> WorkflowDefinition: - definition = get_definition( - session, - tenant_id=tenant_id, - definition_id=definition_id, - ) - if payload.expected_revision != definition.current_revision: - raise WorkflowConflictError( - "Workflow definition changed on the server; " - f"expected revision {payload.expected_revision}, " - f"current revision is {definition.current_revision}." - ) - if ( - payload.scope_type != definition.scope_type - or payload.scope_id != definition.scope_id - and not ( - definition.scope_type == "tenant" - and payload.scope_id in {None, definition.scope_id} - ) - ): - raise WorkflowConflictError( - "Definition scope is immutable; derive a scoped copy instead." - ) - if payload.definition_kind != definition.definition_kind: - raise WorkflowConflictError( - "Definition kind is immutable; derive a flow or template instead." - ) - current = get_definition_revision(session, definition=definition) - graph, bpmn = _prepare_revision_content( - graph=payload.graph, - bpmn=payload.bpmn, - ) - definition.name = payload.name.strip() - definition.description = _clean_optional(payload.description) - definition.metadata_ = dict(payload.metadata) - ancestor_limits = _ancestor_governance_limits( - definition.derivation_provenance - ) - definition.inherit_to_lower_scopes = ( - payload.inherit_to_lower_scopes - and ancestor_limits["inherit_to_lower_scopes"] - ) - definition.allow_start = ( - payload.allow_start and ancestor_limits["allow_start"] - ) - definition.allow_reuse = ( - payload.allow_reuse and ancestor_limits["allow_reuse"] - ) - definition.allow_automation = ( - payload.allow_automation and ancestor_limits["allow_automation"] - ) - definition.updated_by = actor_id - if not _revision_matches( - current, - graph=graph, - bpmn=bpmn, - execution_mode=payload.execution_mode, - view_id=payload.view_id, - view_revision_id=payload.view_revision_id, - ): - definition.current_revision += 1 - definition.revisions.append( - _new_revision( - tenant_id=definition.tenant_id, - revision=definition.current_revision, - graph=graph, - bpmn=bpmn, - execution_mode=payload.execution_mode, - view_id=payload.view_id, - view_revision_id=payload.view_revision_id, - actor_id=actor_id, - ) - ) - if definition.status == "active": - definition.status = "draft" - session.flush() - return definition - - -def derive_definition( - session: Session, - *, - tenant_id: str, - actor_id: str | None, - principal: ApiPrincipal, - registry: object | None, - source_definition_id: str, - payload: WorkflowDefinitionDeriveRequest, -) -> WorkflowDefinition: - source = get_definition( - session, - tenant_id=tenant_id, - definition_id=source_definition_id, - ) - decision = require_definition_action( - source, - principal=principal, - registry=registry, - action="derive", - ) - source_revision = get_definition_revision( - session, - definition=source, - revision=payload.source_revision, - ) - stored_tenant_id = ( - None if payload.scope_type == "system" else tenant_id - ) - scope_id = ( - None - if payload.scope_type == "system" - else tenant_id - if payload.scope_type == "tenant" - else payload.scope_id - ) - scope_key = ( - "system" - if payload.scope_type == "system" - else f"{payload.scope_type}:{scope_id}" - ) - source_limits = _effective_governance_limits( - source, - decision_details=decision.details, - ) - limits = { - "inherit_to_lower_scopes": ( - source_limits["inherit_to_lower_scopes"] - and payload.inherit_to_lower_scopes - ), - "allow_start": ( - source_limits["allow_start"] and payload.allow_start - ), - "allow_reuse": ( - source_limits["allow_reuse"] and payload.allow_reuse - ), - "allow_automation": ( - source_limits["allow_automation"] - and payload.allow_automation - ), - } - provenance = { - "source_ref": f"workflow-definition:{source.id}", - "source_scope": { - "scope_type": source.scope_type, - "scope_id": source.scope_id, - }, - "source_definition_kind": source.definition_kind, - "source_revision": source_revision.revision, - "source_hash": source_revision.content_hash, - "source_effective_limits": limits, - "policy_decision": decision.to_dict(), - "derived_by": actor_id, - "derived_at": utcnow().isoformat(), - } - execution_mode = ( - payload.execution_mode - if "execution_mode" in payload.model_fields_set - else source_revision.execution_mode - ) - view_id = ( - payload.view_id - if "view_id" in payload.model_fields_set - else source_revision.view_id - ) - view_revision_id = ( - payload.view_revision_id - if ( - "view_revision_id" in payload.model_fields_set - or "view_id" in payload.model_fields_set - ) - else source_revision.view_revision_id - ) - if view_id is None: - view_revision_id = None - source_graph, source_bpmn = _prepare_revision_content( - graph=WorkflowGraph.model_validate(source_revision.graph), - bpmn=None, - ) - definition = WorkflowDefinition( - tenant_id=stored_tenant_id, - scope_type=payload.scope_type, - scope_id=scope_id, - scope_key=scope_key, - definition_kind=payload.definition_kind, - inherit_to_lower_scopes=limits["inherit_to_lower_scopes"], - allow_start=limits["allow_start"], - allow_reuse=limits["allow_reuse"], - allow_automation=limits["allow_automation"], - derived_from_definition_id=source.id, - derived_from_revision=source_revision.revision, - derived_from_hash=source_revision.content_hash, - derivation_provenance=provenance, - definition_key=_available_key( - session, - scope_key=scope_key, - requested=payload.key, - name=payload.name, - ), - name=payload.name.strip(), - description=_clean_optional(payload.description), - status="draft", - current_revision=1, - active_revision=None, - metadata_=dict(payload.metadata), - created_by=actor_id, - updated_by=actor_id, - ) - definition.revisions.append( - WorkflowDefinitionRevision( - tenant_id=stored_tenant_id, - revision=1, - schema_version=source_revision.schema_version, - graph=_canonical_graph(source_graph), - content_hash=_content_hash( - source_graph, - bpmn=source_bpmn, - execution_mode=execution_mode, - view_id=view_id, - view_revision_id=view_revision_id, - ), - library_id=source_revision.library_id, - library_version=source_revision.library_version, - execution_mode=execution_mode, - view_id=view_id, - view_revision_id=view_revision_id, - bpmn_xml=source_bpmn.xml, - bpmn_hash=source_bpmn.content_hash, - bpmn_adapter_id=source_bpmn.adapter_id, - bpmn_adapter_version=source_bpmn.adapter_version, - bpmn_runtime_kind=source_bpmn.runtime_kind, - bpmn_executable=source_bpmn.executable, - created_by=actor_id, - ) - ) - session.add(definition) - session.flush() - return definition - - -def activate_definition( - session: Session, - *, - tenant_id: str, - definition_id: str, - actor_id: str | None, - revision: int | None = None, -) -> WorkflowDefinition: - definition = get_definition( - session, - tenant_id=tenant_id, - definition_id=definition_id, - ) - selected = get_definition_revision( - session, - definition=definition, - revision=revision, - ) - if definition.definition_kind == "template": - raise WorkflowConflictError( - "Workflow templates cannot be activated or started." - ) - _validated_graph(WorkflowGraph.model_validate(selected.graph)) - _validate_bpmn_activation(selected) - _validate_execution_mode_activation(selected) - definition.active_revision = selected.revision - definition.status = "active" - definition.updated_by = actor_id - session.flush() - return definition - - -def archive_definition( - session: Session, - *, - tenant_id: str, - definition_id: str, - actor_id: str | None, -) -> WorkflowDefinition: - definition = get_definition( - session, - tenant_id=tenant_id, - definition_id=definition_id, - ) - definition.status = "archived" - definition.updated_by = actor_id - session.flush() - return definition - - -def delete_definition( - session: Session, - *, - tenant_id: str, - definition_id: str, - actor_id: str | None, -) -> WorkflowDefinition: - definition = get_definition( - session, - tenant_id=tenant_id, - definition_id=definition_id, - ) - definition.deleted_at = utcnow() - definition.updated_by = actor_id - session.flush() - return definition - - -def definition_response( - session: Session, - definition: WorkflowDefinition, - *, - principal: ApiPrincipal, - registry: object | None, - revision: int | None = None, -) -> WorkflowDefinitionResponse: - selected = get_definition_revision( - session, - definition=definition, - revision=revision, - ) - return WorkflowDefinitionResponse( - id=definition.id, - tenant_id=definition.tenant_id, - key=definition.definition_key, - name=definition.name, - description=definition.description, - status=definition.status, - current_revision=definition.current_revision, - active_revision=definition.active_revision, - metadata=dict(definition.metadata_), - created_by=definition.created_by, - updated_by=definition.updated_by, - created_at=definition.created_at, - updated_at=definition.updated_at, - revision=revision_response(selected), - governance=definition_governance_payload( - definition, - principal=principal, - registry=registry, - ), - ) - - -def revision_response( - revision: WorkflowDefinitionRevision, -) -> WorkflowDefinitionRevisionResponse: - return WorkflowDefinitionRevisionResponse( - id=revision.id, - revision=revision.revision, - schema_version=revision.schema_version, - graph=canonical_bpmn_graph( - WorkflowGraph.model_validate(revision.graph) - ), - content_hash=revision.content_hash, - library_id=revision.library_id, - library_version=revision.library_version, - execution_mode=revision.execution_mode, - view_id=revision.view_id, - view_revision_id=revision.view_revision_id, - bpmn=revision_bpmn_summary(revision), - created_by=revision.created_by, - created_at=revision.created_at, - ) - - -def _new_revision( - *, - tenant_id: str | None, - revision: int, - graph: WorkflowGraph, - bpmn: _BpmnArtifact | None, - execution_mode: str, - view_id: str | None, - view_revision_id: str | None, - actor_id: str | None, -) -> WorkflowDefinitionRevision: - return WorkflowDefinitionRevision( - tenant_id=tenant_id, - revision=revision, - schema_version=graph.schema_version, - graph=_canonical_graph(graph), - content_hash=_content_hash( - graph, - bpmn=bpmn, - execution_mode=execution_mode, - view_id=view_id, - view_revision_id=view_revision_id, - ), - library_id=WORKFLOW_GRAPH_LIBRARY.id, - library_version=WORKFLOW_GRAPH_LIBRARY.version, - execution_mode=execution_mode, - view_id=view_id, - view_revision_id=view_revision_id, - bpmn_xml=bpmn.xml if bpmn else None, - bpmn_hash=bpmn.content_hash if bpmn else None, - bpmn_adapter_id=bpmn.adapter_id if bpmn else None, - bpmn_adapter_version=bpmn.adapter_version if bpmn else None, - bpmn_runtime_kind=bpmn.runtime_kind if bpmn else None, - bpmn_executable=bpmn.executable if bpmn else None, - created_by=actor_id, - ) - - -def revision_bpmn_summary( - revision: WorkflowDefinitionRevision, -) -> BpmnRevisionSummaryResponse | None: - if not revision.bpmn_xml: - return None - adapter_id = revision.bpmn_adapter_id or "bpmn.interchange" - adapter_version = revision.bpmn_adapter_version or "1.0.0" - adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) - runtime_kind: RuntimeKind = "model_only" - if revision.bpmn_runtime_kind in {"model_only", "native_graph", "external"}: - runtime_kind = revision.bpmn_runtime_kind - return BpmnRevisionSummaryResponse( - content_hash=revision.bpmn_hash - or hashlib.sha256(revision.bpmn_xml.encode("utf-8")).hexdigest(), - adapter_id=adapter_id, - adapter_version=adapter_version, - runtime_kind=runtime_kind, - executable=bool(revision.bpmn_executable), - adapter_available=adapter is not None, - ) - - -def _prepare_revision_content( - *, - graph: WorkflowGraph, - bpmn: BpmnRevisionInput | None, -) -> tuple[WorkflowGraph, _BpmnArtifact | None]: - try: - effective_graph = ( - import_bpmn_graph(bpmn.xml) - if bpmn is not None - else canonical_bpmn_graph(graph) - ) - except WorkflowBpmnValidationError: - raise - except (BpmnGraphError, BpmnInspectionError) as exc: - diagnostics = getattr(exc, "diagnostics", None) - raise WorkflowBpmnValidationError( - tuple(diagnostics) - if diagnostics - else ( - BpmnDiagnostic( - severity="error", - code="bpmn.invalid", - message=str(exc), - ), - ) - ) from exc - effective_graph = _validated_graph(effective_graph) - xml = export_bpmn_graph(effective_graph) - executable = not any( - item.severity == "error" - for item in runtime_diagnostics(effective_graph) - ) - if executable: - try: - runtime_graph = materialize_runtime_graph(effective_graph) - except BpmnGraphError: - executable = False - else: - executable = not any( - item.severity == "error" - for item in validate_workflow_graph(runtime_graph) - ) - return effective_graph, _BpmnArtifact( - xml=xml, - content_hash=hashlib.sha256(xml.encode("utf-8")).hexdigest(), - adapter_id=NATIVE_BPMN_ADAPTER_ID, - adapter_version=NATIVE_BPMN_ADAPTER_VERSION, - runtime_kind="native_graph", - executable=executable, - ) - - -def _validate_bpmn_activation( - revision: WorkflowDefinitionRevision, -) -> None: - try: - runtime_graph = materialize_runtime_graph( - WorkflowGraph.model_validate(revision.graph) - ) - except BpmnGraphError as exc: - diagnostics = getattr(exc, "diagnostics", None) - raise WorkflowBpmnValidationError( - tuple(diagnostics) - if diagnostics - else ( - BpmnDiagnostic( - severity="error", - code="bpmn.activation_invalid", - message=str(exc), - ), - ) - ) from exc - runtime_validation = validate_workflow_graph(runtime_graph) - if any(item.severity == "error" for item in runtime_validation): - raise WorkflowValidationError(runtime_validation) - - -def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph: - try: - graph = canonical_bpmn_graph(graph) - except BpmnGraphError as exc: - raise WorkflowBpmnValidationError(exc.diagnostics) from exc - diagnostics = validate_workflow_graph(graph) - if any(item.severity == "error" for item in diagnostics): - raise WorkflowValidationError(diagnostics) - return graph - - -def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]: - return graph.model_dump(mode="json") - - -def _content_hash( - graph: WorkflowGraph, - *, - bpmn: _BpmnArtifact | None = None, - execution_mode: str = "hybrid", - view_id: str | None = None, - view_revision_id: str | None = None, -) -> str: - content: dict[str, object] = { - "graph": _canonical_graph(graph), - "execution": { - "mode": execution_mode, - "view_id": view_id, - "view_revision_id": view_revision_id, - }, - } - if bpmn is not None: - content["bpmn"] = { - "xml": bpmn.xml, - "adapter_id": bpmn.adapter_id, - "adapter_version": bpmn.adapter_version, - } - encoded = json.dumps(content, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest() - - -def _revision_matches( - revision: WorkflowDefinitionRevision, - *, - graph: WorkflowGraph, - bpmn: _BpmnArtifact | None, - execution_mode: str, - view_id: str | None, - view_revision_id: str | None, -) -> bool: - return ( - revision.graph == _canonical_graph(graph) - and revision.execution_mode == execution_mode - and revision.view_id == view_id - and revision.view_revision_id == view_revision_id - and revision.bpmn_xml == (bpmn.xml if bpmn else None) - and revision.bpmn_adapter_id - == (bpmn.adapter_id if bpmn else None) - and revision.bpmn_adapter_version - == (bpmn.adapter_version if bpmn else None) - ) - - -def _validate_execution_mode_activation( - revision: WorkflowDefinitionRevision, -) -> None: - if revision.execution_mode != "automated": - return - try: - graph = materialize_runtime_graph( - WorkflowGraph.model_validate(revision.graph) - ) - except BpmnGraphError as exc: - raise WorkflowBpmnValidationError(exc.diagnostics) from exc - human_nodes = [ - node.id - for node in graph.nodes - if node.type in {"workflow.activity", "workflow.review"} - or ( - node.type == "workflow.wait" - and str(node.config.get("mode") or "manual") == "manual" - ) - or ( - node.type == "workflow.capability" - and str(node.config.get("failure_policy") or "manual") == "manual" - ) - or ( - node.type == "workflow.dataflow" - and ( - str(node.config.get("warning_policy") or "review") == "review" - or str(node.config.get("failure_policy") or "manual") - == "manual" - ) - ) - ] - if human_nodes: - raise WorkflowConflictError( - "Automated workflows cannot activate with human handoff paths: " - + ", ".join(sorted(human_nodes)) - ) - - -def _available_key( - session: Session, - *, - scope_key: str, - requested: str | None, - name: str, -) -> str: - base = _slug(requested or name) - candidate = base - suffix = 2 - while session.scalar( - select(WorkflowDefinition.id).where( - WorkflowDefinition.scope_key == scope_key, - WorkflowDefinition.definition_key == candidate, - ) - ): - candidate = f"{base[: max(1, 120 - len(str(suffix)) - 1)]}-{suffix}" - suffix += 1 - return candidate - - -def _slug(value: str) -> str: - normalized = unicodedata.normalize("NFKD", value) - ascii_value = normalized.encode("ascii", "ignore").decode("ascii").lower() - cleaned = re.sub(r"[^a-z0-9]+", "-", ascii_value).strip("-") - return (cleaned or "workflow")[:120] - - -def _clean_optional(value: str | None) -> str | None: - if value is None: - return None - cleaned = value.strip() - return cleaned or None - - -def _ancestor_governance_limits( - provenance: Mapping[str, object], -) -> dict[str, bool]: - raw = provenance.get("source_effective_limits") - limits = raw if isinstance(raw, Mapping) else {} - return { - key: value if isinstance((value := limits.get(key)), bool) else True - for key in ( - "inherit_to_lower_scopes", - "allow_start", - "allow_reuse", - "allow_automation", - ) - } - - -def _effective_governance_limits( - definition: WorkflowDefinition, - *, - decision_details: Mapping[str, object] | None = None, -) -> dict[str, bool]: - ancestor = _ancestor_governance_limits( - definition.derivation_provenance - ) - effective = { - "inherit_to_lower_scopes": ( - definition.inherit_to_lower_scopes - and ancestor["inherit_to_lower_scopes"] - ), - "allow_start": ( - definition.allow_start and ancestor["allow_start"] - ), - "allow_reuse": ( - definition.allow_reuse and ancestor["allow_reuse"] - ), - "allow_automation": ( - definition.allow_automation - and ancestor["allow_automation"] - ), - } - policy_limits = ( - decision_details.get("effective_limits") - if decision_details is not None - else None - ) - if isinstance(policy_limits, Mapping): - policy_key_by_local_key = { - "inherit_to_lower_scopes": "inherit_to_lower_scopes", - "allow_start": "allow_run", - "allow_reuse": "allow_reuse", - "allow_automation": "allow_automation", - } - for local_key, policy_key in policy_key_by_local_key.items(): - value = policy_limits.get(policy_key) - if isinstance(value, bool): - effective[local_key] = effective[local_key] and value - return effective - - -__all__ = [ - "WorkflowBpmnValidationError", - "WorkflowConflictError", - "WorkflowError", - "WorkflowNotFoundError", - "WorkflowValidationError", - "activate_definition", - "archive_definition", - "create_definition", - "derive_definition", - "definition_response", - "delete_definition", - "get_definition", - "get_definition_revision", - "list_definition_revisions", - "list_definitions", - "revision_response", - "revision_bpmn_summary", - "update_definition", -] +from govoplan_workflow_engine.backend.service import * # noqa: F401,F403 diff --git a/src/govoplan_workflow/backend/validation.py b/src/govoplan_workflow/backend/validation.py index d5a75d7..cc1bfd6 100644 --- a/src/govoplan_workflow/backend/validation.py +++ b/src/govoplan_workflow/backend/validation.py @@ -1,262 +1,3 @@ -from __future__ import annotations +"""Compatibility facade for the extracted Workflow Engine backend.""" -from govoplan_core.core.definition_graphs import ( - DefinitionDiagnostic, - DefinitionEdge, - DefinitionNode, - validate_definition_graph, -) -from govoplan_workflow.backend.node_library import ( - WORKFLOW_GRAPH_LIBRARY, - WORKFLOW_NODE_TYPES_BY_ID, -) -from govoplan_workflow.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"] +from govoplan_workflow_engine.backend.validation import * # noqa: F401,F403 diff --git a/tests/test_manifest.py b/tests/test_manifest.py index c78d941..dca44bf 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,57 +2,31 @@ from __future__ import annotations import unittest -from govoplan_workflow.backend.manifest import ( - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - INSTANCE_START_SCOPE, - get_manifest, -) -from govoplan_core.core.workflows import ( - CAPABILITY_WORKFLOW_RUNTIME_WORKER, -) +from govoplan_workflow.backend.manifest import get_manifest class WorkflowManifestTests(unittest.TestCase): - def test_manifest_exposes_definition_contracts(self) -> None: + def test_manifest_is_an_editor_only_engine_client(self) -> None: manifest = get_manifest() - self.assertEqual(manifest.id, "workflow") + self.assertEqual("workflow", manifest.id) + self.assertEqual(("workflow_engine",), manifest.dependencies) + self.assertEqual( + {"workflow.definition_graph", "workflow.definition_catalogue", "workflow.bpmn_interchange"}, + {item.name for item in manifest.requires_interfaces}, + ) self.assertIn( - "workflow.definition_graph", + "workflow.editor", {item.name for item in manifest.provides_interfaces}, ) - self.assertIn( - DEFINITION_READ_SCOPE, - {item.scope for item in manifest.permissions}, - ) - self.assertIn( - DEFINITION_WRITE_SCOPE, - {item.scope for item in manifest.permissions}, - ) - self.assertIn( - INSTANCE_START_SCOPE, - {item.scope for item in manifest.permissions}, - ) - self.assertIn( - "workflow.runtime_worker", - {item.name for item in manifest.provides_interfaces}, - ) - self.assertIn( - CAPABILITY_WORKFLOW_RUNTIME_WORKER, - manifest.capability_factories, - ) + self.assertEqual((), manifest.permissions) + self.assertIsNone(manifest.route_factory) + self.assertIsNone(manifest.migration_spec) + self.assertEqual({}, manifest.capability_factories) self.assertEqual( "@govoplan/workflow-webui", manifest.frontend.package_name if manifest.frontend else None, ) - self.assertIsNotNone(manifest.migration_spec) - requirement = next( - item - for item in manifest.requires_interfaces - if item.name == "dataflow.run_lifecycle" - ) - self.assertTrue(requirement.optional) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py index fd3ff20..19ddf96 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1,76 +1,13 @@ from __future__ import annotations -import tempfile import unittest -from pathlib import Path -from alembic.runtime.migration import MigrationContext -from sqlalchemy import create_engine, inspect - -from govoplan_core.db.migrations import migrate_database from govoplan_workflow.backend.manifest import get_manifest -class WorkflowMigrationTests(unittest.TestCase): - def test_migration_creates_definition_tables_and_head(self) -> None: - with tempfile.TemporaryDirectory( - prefix="govoplan-workflow-migration-" - ) as directory: - url = f"sqlite:///{Path(directory) / 'workflow.db'}" - migrate_database( - database_url=url, - enabled_modules=("workflow",), - manifest_factories=(get_manifest,), - ) - engine = create_engine(url) - try: - with engine.connect() as connection: - self.assertIn( - "f1b7d3e5a9c2", - set(MigrationContext.configure(connection).get_current_heads()), - ) - self.assertEqual( - { - "workflow_definition_revisions", - "workflow_definitions", - "workflow_instance_events", - "workflow_instance_steps", - "workflow_instances", - }, - { - name - for name in inspect(connection).get_table_names() - if name.startswith("workflow_") - }, - ) - revision_columns = { - item["name"] - for item in inspect(connection).get_columns( - "workflow_definition_revisions" - ) - } - self.assertTrue( - { - "bpmn_xml", - "bpmn_hash", - "bpmn_adapter_id", - "bpmn_adapter_version", - "bpmn_runtime_kind", - "bpmn_executable", - "execution_mode", - "view_id", - "view_revision_id", - }.issubset(revision_columns) - ) - instance_columns = { - item["name"] - for item in inspect(connection).get_columns( - "workflow_instances" - ) - } - self.assertIn("start_origin", instance_columns) - finally: - engine.dispose() +class WorkflowEditorMigrationTests(unittest.TestCase): + def test_editor_does_not_own_database_migrations(self) -> None: + self.assertIsNone(get_manifest().migration_spec) if __name__ == "__main__": diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index 0488a81..d1bbc6e 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -136,10 +136,30 @@ export type WorkflowRevision = { view_id?: string | null; view_revision_id?: string | null; bpmn?: BpmnRevisionSummary | null; + contribution_origin_module_version?: string | null; + contribution_schema_version?: string | null; + contribution_hash?: string | null; + contribution_metadata: Record; created_by?: string | null; created_at: string; }; +export type WorkflowStandardProvenance = { + kind: "baseline" | "override"; + origin_module_id: string; + origin_module_version?: string | null; + definition_key: string; + contribution_schema_version?: string | null; + contribution_hash?: string | null; + baseline_definition_id: string; + latest_baseline_revision: number; + active_baseline_revision?: number | null; + pinned_baseline_revision?: number | null; + pinned_baseline_hash?: string | null; + update_available: boolean; + reset_available: boolean; +}; + export type WorkflowDefinition = { id: string; tenant_id: string | null; @@ -156,6 +176,7 @@ export type WorkflowDefinition = { updated_at: string; revision: WorkflowRevision; governance: WorkflowGovernance; + standard?: WorkflowStandardProvenance | null; }; export type WorkflowActionDecision = { @@ -409,6 +430,33 @@ export function deriveWorkflowDefinition( ); } +export function reconcileWorkflowStandards( + settings: ApiSettings +): Promise<{ + discovered: number; + created: number; + updated: number; + unchanged: number; + blocked: number; + pending_tenant_scope: number; + items: Array>; +}> { + return apiFetch(settings, "/api/v1/workflow/standards/reconcile", { + method: "POST" + }); +} + +export function resetWorkflowDefinitionToStandard( + settings: ApiSettings, + definitionId: string +): Promise { + return apiFetch( + settings, + `/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/reset-standard`, + { method: "POST" } + ); +} + export async function listWorkflowRevisions( settings: ApiSettings, definitionId: string @@ -420,6 +468,17 @@ export async function listWorkflowRevisions( return response.revisions; } +export function getWorkflowRevision( + settings: ApiSettings, + definitionId: string, + revision: number +): Promise { + return apiFetch( + settings, + `/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}` + ); +} + export function getWorkflowRevisionBpmn( settings: ApiSettings, definitionId: string, diff --git a/webui/src/features/workflow/WorkflowPage.tsx b/webui/src/features/workflow/WorkflowPage.tsx index 781666b..e53e4bb 100644 --- a/webui/src/features/workflow/WorkflowPage.tsx +++ b/webui/src/features/workflow/WorkflowPage.tsx @@ -49,10 +49,13 @@ import { createWorkflowDefinition, deleteWorkflowDefinition, deriveWorkflowDefinition, + getWorkflowRevision, listWorkflowDefinitions, listWorkflowNodeTypes, listWorkflowRevisions, + reconcileWorkflowStandards, renderWorkflowBpmn, + resetWorkflowDefinitionToStandard, updateWorkflowDefinition, validateWorkflowDefinition, workflowScopeReferenceProvider, @@ -118,6 +121,8 @@ export default function WorkflowPage({ const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false); const [deriveOpen, setDeriveOpen] = useState(false); const [runsOpen, setRunsOpen] = useState(false); + const [resetOpen, setResetOpen] = useState(false); + const [compareOpen, setCompareOpen] = useState(false); const bpmnFileInputRef = useRef(null); const canWrite = hasScope(auth, "workflow:definition:write") @@ -125,6 +130,10 @@ export default function WorkflowPage({ const canEdit = canWrite && ( !draft?.id || draft.governance?.actions.edit?.allowed !== false ); + const canActivate = Boolean( + canWrite + && (draft?.standard?.kind === "baseline" || canEdit) + ); const canReuse = Boolean( draft?.id && canWrite @@ -142,6 +151,10 @@ export default function WorkflowPage({ () => definitions.find((item) => item.id === draft?.id) ?? null, [definitions, draft?.id] ); + const baselineDefinition = useMemo(() => { + const baselineId = selectedDefinition?.standard?.baseline_definition_id; + return definitions.find((item) => item.id === baselineId) ?? null; + }, [definitions, selectedDefinition]); const dirty = Boolean(draft) && workflowFingerprint(draft) !== workflowFingerprint(savedDraft); const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null; @@ -480,6 +493,42 @@ export default function WorkflowPage({ } }; + const reconcileStandards = async () => { + setWorking(true); + setError(""); + setSuccess(""); + try { + const result = await reconcileWorkflowStandards(settings); + await reload(draft?.id); + setSuccess( + `Module standards: ${result.created} installed, ${result.updated} updated, ${result.blocked} blocked.` + ); + } catch (reconcileError) { + setError(apiErrorMessage(reconcileError)); + } finally { + setWorking(false); + } + }; + + const resetToStandard = async () => { + if (!draft?.id) return; + setWorking(true); + setError(""); + try { + const baseline = await resetWorkflowDefinitionToStandard( + settings, + draft.id + ); + setResetOpen(false); + await reload(baseline.id); + setSuccess("The local override was archived and the module standard restored."); + } catch (resetError) { + setError(apiErrorMessage(resetError)); + } finally { + setWorking(false); + } + }; + const updateGraph = (graph: WorkflowDraft["graph"]) => { if (graphReadOnly) return; setDraft((current) => current ? { ...current, graph } : current); @@ -532,6 +581,15 @@ export default function WorkflowPage({ onClick={() => void reload(draft?.id)} disabled={loading || working} /> + {hasScope(auth, "workflow:instance:admin") ? ( + } + variant="ghost" + onClick={() => void reconcileStandards()} + disabled={loading || working} + /> + ) : null} } @@ -567,6 +625,14 @@ export default function WorkflowPage({ {definition.governance.scope_type} · {definition.governance.definition_kind} + {definition.standard ? ( + + {definition.standard.kind === "baseline" + ? `Standard · ${definition.standard.origin_module_id}` + : `Override · ${definition.standard.origin_module_id}`} + {definition.standard.update_available ? " · update available" : ""} + + ) : null} ) : null} + {draft.standard?.kind === "override" + && draft.standard.update_available ? ( + + ) : null} + {draft.standard?.kind === "override" + && draft.standard.reset_available ? ( + + ) : null} {historicalRevision ? ( ) : null} - {draft.id && draft.status !== "active" ? ( + {draft.id && ( + draft.status !== "active" + || draft.activeRevision !== draft.currentRevision + ) ? ( - {draft.id ? ( + {draft.id && draft.standard?.kind !== "baseline" ? ( } @@ -885,6 +974,22 @@ export default function WorkflowPage({ onCancel={() => setDeleteOpen(false)} onConfirm={() => void removeDefinition()} /> + setResetOpen(false)} + onConfirm={() => void resetToStandard()} + /> + setCompareOpen(false)} + /> void; +}) { + const [pinned, setPinned] = useState(null); + const [latest, setLatest] = useState(null); + const [error, setError] = useState(""); + const standard = override?.standard; + + useEffect(() => { + if (!open || !baseline || !standard) return; + let cancelled = false; + setError(""); + const pinnedRevision = standard.pinned_baseline_revision + ?? standard.active_baseline_revision + ?? 1; + void Promise.all([ + getWorkflowRevision(settings, baseline.id, pinnedRevision), + getWorkflowRevision( + settings, + baseline.id, + standard.latest_baseline_revision + ) + ]).then(([pinnedItem, latestItem]) => { + if (cancelled) return; + setPinned(pinnedItem); + setLatest(latestItem); + }).catch((loadError) => { + if (!cancelled) setError(apiErrorMessage(loadError)); + }); + return () => { + cancelled = true; + }; + }, [baseline, open, settings, standard]); + + return ( + Close} + > + {error ? ( + + {error} + + ) : null} +
+ + + +
+
+ ); +} + +function StandardRevisionColumn({ + label, + revision +}: { + label: string; + revision: WorkflowRevision | null; +}) { + return ( +
+
+ {label} + + {revision + ? `Revision ${revision.revision} · ${revision.content_hash.slice(0, 12)}` + : "Loading..."} + +
+
{revision ? JSON.stringify(revision.graph, null, 2) : ""}
+
+ ); +} + function WorkflowDefinitionSettingsDialog({ open, settings, diff --git a/webui/src/features/workflow/model.ts b/webui/src/features/workflow/model.ts index 2035030..69de2b4 100644 --- a/webui/src/features/workflow/model.ts +++ b/webui/src/features/workflow/model.ts @@ -9,6 +9,7 @@ import type { WorkflowGraphNode, WorkflowExecutionMode, WorkflowNodeType, + WorkflowStandardProvenance, WorkflowStatus } from "../../api/workflow"; @@ -32,6 +33,7 @@ export type WorkflowDraft = { viewId: string; viewRevisionId: string; governance: WorkflowGovernance | null; + standard: WorkflowStandardProvenance | null; }; const inputPort = [{ @@ -133,6 +135,7 @@ export function sampleWorkflowDraft(): WorkflowDraft { viewId: "", viewRevisionId: "", governance: null, + standard: null, graph: { schema_version: 1, nodes: [ @@ -247,7 +250,8 @@ export function draftFromDefinition( executionMode: definition.revision.execution_mode, viewId: definition.revision.view_id ?? "", viewRevisionId: definition.revision.view_revision_id ?? "", - governance: definition.governance + governance: definition.governance, + standard: definition.standard ?? null }; } diff --git a/webui/src/styles/workflow.css b/webui/src/styles/workflow.css index 491d6d3..dacb806 100644 --- a/webui/src/styles/workflow.css +++ b/webui/src/styles/workflow.css @@ -13,6 +13,64 @@ width: min(620px, calc(100vw - 32px)); } +.workflow-standard-comparison-dialog { + width: min(1180px, calc(100vw - 32px)); + height: min(760px, calc(100vh - 32px)); +} + +.workflow-standard-comparison { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + height: 100%; + min-height: 0; + gap: 10px; + overflow: hidden; +} + +.workflow-standard-revision { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + border: var(--border-line); + border-radius: var(--radius-sm); + background: var(--panel-soft); + overflow: hidden; +} + +.workflow-standard-revision header { + display: grid; + gap: 3px; + padding: 10px; + border-bottom: var(--border-line); +} + +.workflow-standard-revision small { + color: var(--muted); + font-size: 11px; +} + +.workflow-standard-revision pre { + min-height: 0; + flex: 1 1 auto; + margin: 0; + padding: 10px; + overflow: auto; + font-size: 11px; + white-space: pre; +} + +@media (max-width: 900px) { + .workflow-standard-comparison { + grid-template-columns: 1fr; + overflow: auto; + } + + .workflow-standard-revision { + min-height: 260px; + } +} + .workflow-definition-fields { display: grid; gap: 12px;