refactor: retain workflow as optional editor
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user