Install and reconcile module workflow standards

This commit is contained in:
2026-07-31 19:40:04 +02:00
parent 9bccbd68da
commit 43c1f3fb72
8 changed files with 1289 additions and 5 deletions
@@ -0,0 +1,487 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult,
ConfigurationDiagnostic,
ConfigurationExportResult,
ConfigurationExportSelection,
ConfigurationPackageFragment,
ConfigurationPlanItem,
ConfigurationPreflightContext,
ConfigurationPreflightResult,
ConfigurationProvider,
ConfigurationProviderDescription,
)
from govoplan_core.core.workflows import (
WorkflowDefinitionContribution,
workflow_definition_contribution_hash,
)
from govoplan_core.db.session import get_database
from govoplan_workflow_engine.backend.contributions import (
reconcile_workflow_contributions,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
)
from govoplan_workflow_engine.backend.schemas import WorkflowGraph
from govoplan_workflow_engine.backend.validation import validate_workflow_graph
WORKFLOW_CONFIGURATION_CAPABILITY = "workflow.configuration"
WORKFLOW_DEFINITIONS_FRAGMENT = "workflow_definitions"
class SqlWorkflowConfigurationProvider(ConfigurationProvider):
module_id = "workflow_engine"
def __init__(self, *, registry: object | None = None) -> None:
self._registry = registry
def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription(
module_id=self.module_id,
fragment_types=(WORKFLOW_DEFINITIONS_FRAGMENT,),
schema_refs={
WORKFLOW_DEFINITIONS_FRAGMENT: (
"govoplan/workflow-engine/configuration/definitions.v1"
),
},
exported_scopes=("system", "tenant"),
)
def preflight(
self,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> ConfigurationPreflightResult:
with get_database().session() as session:
return preflight_workflow_definitions(session, fragment, context)
def apply(
self,
fragment: ConfigurationPackageFragment,
supplied_data: Mapping[str, Any],
context: ConfigurationPreflightContext,
) -> ConfigurationApplyResult:
del supplied_data
with get_database().session() as session:
result = apply_workflow_definitions(
session,
fragment,
context,
registry=self._registry,
)
if not any(item.severity == "blocker" for item in result.diagnostics):
session.commit()
return result
def export(
self,
selection: ConfigurationExportSelection,
context: ConfigurationPreflightContext,
) -> ConfigurationExportResult:
del context
with get_database().session() as session:
return export_workflow_definitions(session, selection)
def health(
self,
import_result: ConfigurationApplyResult,
context: ConfigurationPreflightContext,
) -> tuple[ConfigurationDiagnostic, ...]:
del context
return tuple(
item for item in import_result.diagnostics if item.severity == "blocker"
)
def preflight_workflow_definitions(
session: Session,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> ConfigurationPreflightResult:
if fragment.fragment_type != WORKFLOW_DEFINITIONS_FRAGMENT:
return ConfigurationPreflightResult(
diagnostics=(_unsupported_fragment(fragment),),
)
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
try:
contributions = _contributions(fragment, context)
except (TypeError, ValueError) as exc:
return ConfigurationPreflightResult(
diagnostics=(
ConfigurationDiagnostic(
severity="blocker",
code="workflow_fragment_invalid",
message=str(exc),
module_id="workflow_engine",
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution=(
"Correct the workflow definition fragment before applying it."
),
),
),
)
for contribution in contributions:
graph = WorkflowGraph.model_validate(contribution.graph)
graph_diagnostics = validate_workflow_graph(graph)
errors = [item for item in graph_diagnostics if item.severity == "error"]
diagnostics.extend(
ConfigurationDiagnostic(
severity="blocker" if item.severity == "error" else "warning",
code=f"workflow_{item.code}",
message=item.message,
module_id="workflow_engine",
object_ref=(
f"{contribution.definition_key}:{item.node_id or item.field or 'graph'}"
),
resolution="Correct the workflow graph before applying it.",
)
for item in graph_diagnostics
)
tenant_id = context.tenant_id if contribution.scope_type == "tenant" else None
if contribution.scope_type == "tenant" and not tenant_id:
diagnostics.append(
ConfigurationDiagnostic(
severity="blocker",
code="workflow_tenant_required",
message=(
f"Workflow definition {contribution.definition_key!r} "
"requires a tenant target."
),
module_id="workflow_engine",
object_ref=contribution.definition_key,
resolution="Choose a tenant before applying this package.",
)
)
existing = _standard_definition(
session,
contribution=contribution,
tenant_id=tenant_id,
)
contribution_hash = workflow_definition_contribution_hash(contribution)
action = (
"create"
if existing is None
else (
"noop"
if existing.standard_contribution_hash == contribution_hash
else "update"
)
)
if errors or (contribution.scope_type == "tenant" and not tenant_id):
action = "blocked"
plan.append(
ConfigurationPlanItem(
action=action,
module_id="workflow_engine",
fragment_type=WORKFLOW_DEFINITIONS_FRAGMENT,
fragment_id=contribution.definition_key,
summary=(
f"{action.capitalize()} module workflow baseline "
f"{contribution.definition_key}."
),
)
)
return ConfigurationPreflightResult(
diagnostics=tuple(diagnostics),
plan=tuple(plan),
)
def apply_workflow_definitions(
session: Session,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
*,
registry: object | None,
) -> ConfigurationApplyResult:
preflight = preflight_workflow_definitions(session, fragment, context)
blockers = tuple(
item for item in preflight.diagnostics if item.severity == "blocker"
)
if blockers:
return ConfigurationApplyResult(diagnostics=blockers)
contributions = _contributions(fragment, context)
result = reconcile_workflow_contributions(
session,
contributions=contributions,
registry=registry,
tenant_ids=((context.tenant_id,) if context.tenant_id else ()),
)
diagnostics = [
ConfigurationDiagnostic(
severity="blocker",
code="workflow_contribution_blocked",
message=str(item.get("message") or "Workflow baseline was blocked."),
module_id="workflow_engine",
object_ref=str(item.get("definition_key") or "workflow_definition"),
resolution=(
"Install the required capabilities and correct the definition graph."
),
)
for item in result["items"]
if isinstance(item, Mapping) and item.get("state") == "blocked"
]
created: dict[str, str] = {}
updated: dict[str, str] = {}
for item in result["items"]:
if not isinstance(item, Mapping):
continue
definition_id = item.get("definition_id")
definition_key = item.get("definition_key")
if not definition_id or not definition_key:
continue
ref = f"workflow_definition:{definition_id}"
if item.get("state") == "created":
created[str(definition_key)] = ref
elif item.get("state") == "updated":
updated[str(definition_key)] = ref
return ConfigurationApplyResult(
diagnostics=tuple(diagnostics),
created_refs=created,
updated_refs=updated,
)
def export_workflow_definitions(
session: Session,
selection: ConfigurationExportSelection,
) -> ConfigurationExportResult:
query = select(WorkflowDefinition).where(
WorkflowDefinition.deleted_at.is_(None),
WorkflowDefinition.standard_origin_module_id.is_not(None),
)
if selection.tenant_id:
query = query.where(
WorkflowDefinition.tenant_id == selection.tenant_id,
)
elif "system" in selection.scopes:
query = query.where(WorkflowDefinition.tenant_id.is_(None))
else:
return ConfigurationExportResult(
diagnostics=(
ConfigurationDiagnostic(
severity="blocker",
code="workflow_export_scope_required",
message=("Workflow export needs a tenant or the system scope."),
module_id="workflow_engine",
resolution="Choose a tenant or system scope before export.",
),
),
)
definitions = session.scalars(
query.order_by(WorkflowDefinition.definition_key.asc())
).all()
items = [
_export_item(definition)
for definition in definitions
if not selection.object_refs
or definition.id in selection.object_refs
or f"workflow_definition:{definition.id}" in selection.object_refs
]
return ConfigurationExportResult(
fragments=(
ConfigurationPackageFragment(
module_id="workflow_engine",
fragment_type=WORKFLOW_DEFINITIONS_FRAGMENT,
fragment_id="workflow-standards",
payload={"schema_version": 1, "items": items},
),
),
)
def _contributions(
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> tuple[WorkflowDefinitionContribution, ...]:
raw_items = fragment.payload.get("items")
if not isinstance(raw_items, list):
raise ValueError("Workflow definitions fragment requires an items list.")
contributions: list[WorkflowDefinitionContribution] = []
for index, raw in enumerate(raw_items):
if not isinstance(raw, Mapping):
raise ValueError(f"Workflow definition item {index} must be an object.")
definition_key = _required_text(raw, "definition_key")
scope_type = str(raw.get("scope_type") or "tenant").strip().casefold()
if scope_type not in {"system", "tenant"}:
raise ValueError(
f"Workflow definition {definition_key!r} has unsupported scope "
f"{scope_type!r}."
)
graph = raw.get("graph")
if not isinstance(graph, Mapping):
raise ValueError(
f"Workflow definition {definition_key!r} requires a graph object."
)
origin_module_id = str(
raw.get("origin_module_id")
or fragment.payload.get("origin_module_id")
or "configuration_package"
).strip()
origin_module_version = str(
raw.get("origin_module_version")
or fragment.payload.get("origin_module_version")
or "1"
).strip()
contributions.append(
WorkflowDefinitionContribution(
origin_module_id=origin_module_id,
origin_module_version=origin_module_version,
definition_key=definition_key,
name=_required_text(raw, "name"),
description=_optional_text(raw.get("description")),
graph=dict(graph),
contribution_schema_version=str(
raw.get("contribution_schema_version") or "1"
),
definition_kind=str(raw.get("definition_kind") or "flow"),
scope_type=scope_type,
inherit_to_lower_scopes=_bool(
raw.get("inherit_to_lower_scopes"),
default=scope_type == "system",
),
allow_start=_bool(raw.get("allow_start"), default=True),
allow_reuse=_bool(raw.get("allow_reuse"), default=True),
allow_automation=_bool(
raw.get("allow_automation"),
default=False,
),
execution_mode=str(raw.get("execution_mode") or "hybrid"),
view_id=_optional_text(raw.get("view_id")),
view_revision_id=_optional_text(raw.get("view_revision_id")),
bpmn_xml=_optional_text(raw.get("bpmn_xml")),
bpmn_adapter_id=str(
raw.get("bpmn_adapter_id") or "govoplan.native.bpmn"
),
bpmn_adapter_version=_optional_text(raw.get("bpmn_adapter_version")),
activate_on_install=_bool(
raw.get("activate_on_install"),
default=True,
),
required_capabilities=_strings(raw.get("required_capabilities")),
required_interfaces=_strings(raw.get("required_interfaces")),
metadata={
**_mapping(raw.get("metadata")),
"configuration_fragment_id": fragment.fragment_id,
"configuration_tenant_id": context.tenant_id,
},
policy_metadata=_mapping(raw.get("policy_metadata")),
)
)
return tuple(contributions)
def _standard_definition(
session: Session,
*,
contribution: WorkflowDefinitionContribution,
tenant_id: str | None,
) -> WorkflowDefinition | None:
scope_key = "system" if tenant_id is None else f"tenant:{tenant_id}"
return session.scalar(
select(WorkflowDefinition).where(
WorkflowDefinition.scope_key == scope_key,
WorkflowDefinition.standard_origin_module_id
== contribution.origin_module_id,
WorkflowDefinition.standard_definition_key == contribution.definition_key,
)
)
def _export_item(definition: WorkflowDefinition) -> dict[str, object]:
revision = next(
item
for item in definition.revisions
if item.revision == definition.current_revision
)
return {
"origin_module_id": definition.standard_origin_module_id,
"origin_module_version": definition.standard_origin_module_version,
"contribution_schema_version": (
definition.standard_contribution_schema_version or "1"
),
"definition_key": (
definition.standard_definition_key or definition.definition_key
),
"name": definition.name,
"description": definition.description,
"graph": dict(revision.graph),
"definition_kind": definition.definition_kind,
"scope_type": definition.scope_type,
"inherit_to_lower_scopes": definition.inherit_to_lower_scopes,
"allow_start": definition.allow_start,
"allow_reuse": definition.allow_reuse,
"allow_automation": definition.allow_automation,
"execution_mode": revision.execution_mode,
"view_id": revision.view_id,
"view_revision_id": revision.view_revision_id,
"bpmn_xml": revision.bpmn_xml,
"bpmn_adapter_id": revision.bpmn_adapter_id,
"bpmn_adapter_version": revision.bpmn_adapter_version,
"activate_on_install": definition.active_revision is not None,
"metadata": dict(definition.metadata_),
}
def _unsupported_fragment(
fragment: ConfigurationPackageFragment,
) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="workflow_fragment_unsupported",
message=(
f"Workflow Engine does not support fragment type "
f"{fragment.fragment_type!r}."
),
module_id="workflow_engine",
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Use a workflow_definitions fragment.",
)
def _required_text(value: Mapping[str, Any], key: str) -> str:
raw = value.get(key)
text = str(raw).strip() if raw is not None else ""
if not text:
raise ValueError(f"Workflow definition requires {key!r}.")
return text
def _optional_text(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _strings(value: object) -> tuple[str, ...]:
if value is None:
return ()
if not isinstance(value, (list, tuple)):
raise ValueError("Workflow requirement lists must be arrays.")
return tuple(str(item).strip() for item in value if str(item).strip())
def _bool(value: object, *, default: bool) -> bool:
return value if isinstance(value, bool) else default
__all__ = [
"SqlWorkflowConfigurationProvider",
"WORKFLOW_CONFIGURATION_CAPABILITY",
"WORKFLOW_DEFINITIONS_FRAGMENT",
"apply_workflow_definitions",
"export_workflow_definitions",
"preflight_workflow_definitions",
]
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any, cast
from sqlalchemy import select
from sqlalchemy import inspect, select
from sqlalchemy.orm import Session
from govoplan_core.core.workflows import (
@@ -18,7 +18,10 @@ from govoplan_workflow_engine.backend.db.models import (
from govoplan_workflow_engine.backend.schemas import (
BpmnRevisionInput,
WorkflowGraph,
WorkflowStandardDiffItemResponse,
WorkflowStandardDiffResponse,
)
from govoplan_workflow_engine.backend.bpmn_graph import canonical_bpmn_graph
from govoplan_workflow_engine.backend.service import (
WorkflowConflictError,
WorkflowNotFoundError,
@@ -29,6 +32,7 @@ from govoplan_workflow_engine.backend.service import (
_validate_bpmn_activation,
_validate_execution_mode_activation,
get_definition,
get_definition_revision,
)
@@ -38,7 +42,21 @@ def reconcile_workflow_definition_contributions(
registry: object | None,
tenant_ids: Sequence[str] = (),
) -> dict[str, object]:
contributions = _registered_contributions(registry)
return reconcile_workflow_contributions(
session,
contributions=_registered_contributions(registry),
registry=registry,
tenant_ids=tenant_ids,
)
def reconcile_workflow_contributions(
session: Session,
*,
contributions: Sequence[WorkflowDefinitionContribution],
registry: object | None,
tenant_ids: Sequence[str] = (),
) -> dict[str, object]:
normalized_tenant_ids = tuple(
dict.fromkeys(str(item).strip() for item in tenant_ids if str(item).strip())
)
@@ -163,6 +181,199 @@ def reset_workflow_override_to_standard(
return baseline
def compare_workflow_override_to_standard(
session: Session,
*,
tenant_id: str,
definition_id: str,
include_unchanged: bool = False,
) -> WorkflowStandardDiffResponse:
override = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
if not override.derived_from_definition_id:
raise WorkflowConflictError(
"Only a definition derived from a module standard can be compared."
)
baseline = session.get(
WorkflowDefinition,
override.derived_from_definition_id,
)
if (
baseline is None
or baseline.deleted_at is not None
or not baseline.standard_origin_module_id
):
raise WorkflowNotFoundError(
"The module standard behind this override is unavailable."
)
pinned_revision_number = override.derived_from_revision
if pinned_revision_number is None:
raise WorkflowConflictError(
"The override does not pin a module standard revision."
)
pinned = get_definition_revision(
session,
definition=baseline,
revision=pinned_revision_number,
)
local = get_definition_revision(
session,
definition=override,
revision=override.current_revision,
)
latest = get_definition_revision(
session,
definition=baseline,
revision=baseline.current_revision,
)
items = _semantic_graph_diff(
_canonical_graph_payload(pinned.graph),
_canonical_graph_payload(local.graph),
_canonical_graph_payload(latest.graph),
)
counts = {
state: sum(item.state == state for item in items)
for state in (
"unchanged",
"local_only",
"upstream_only",
"same_change",
"conflict",
)
}
visible_items = (
items
if include_unchanged
else [item for item in items if item.state != "unchanged"]
)
return WorkflowStandardDiffResponse(
override_definition_id=override.id,
baseline_definition_id=baseline.id,
pinned_baseline_revision=pinned.revision,
local_revision=local.revision,
latest_baseline_revision=latest.revision,
counts=counts,
conflict_count=counts["conflict"],
auto_mergeable=counts["conflict"] == 0,
items=visible_items,
)
def _canonical_graph_payload(raw: Mapping[str, object]) -> dict[str, object]:
return canonical_bpmn_graph(WorkflowGraph.model_validate(raw)).model_dump(
mode="json"
)
def _semantic_graph_diff(
baseline: Mapping[str, object],
local: Mapping[str, object],
latest: Mapping[str, object],
) -> list[WorkflowStandardDiffItemResponse]:
items = [
_diff_item(
resource_type="graph",
resource_id="graph",
baseline={
"schema_version": baseline.get("schema_version"),
"metadata": baseline.get("metadata", {}),
},
local={
"schema_version": local.get("schema_version"),
"metadata": local.get("metadata", {}),
},
latest={
"schema_version": latest.get("schema_version"),
"metadata": latest.get("metadata", {}),
},
)
]
for resource_type, collection_name in (
("node", "nodes"),
("edge", "edges"),
):
baseline_items = _items_by_id(baseline.get(collection_name))
local_items = _items_by_id(local.get(collection_name))
latest_items = _items_by_id(latest.get(collection_name))
for resource_id in sorted(
set(baseline_items) | set(local_items) | set(latest_items)
):
items.append(
_diff_item(
resource_type=resource_type,
resource_id=resource_id,
baseline=baseline_items.get(resource_id),
local=local_items.get(resource_id),
latest=latest_items.get(resource_id),
)
)
return items
def _items_by_id(value: object) -> dict[str, dict[str, object]]:
if not isinstance(value, list):
return {}
return {
str(item["id"]): dict(item)
for item in value
if isinstance(item, Mapping) and item.get("id") is not None
}
def _diff_item(
*,
resource_type: str,
resource_id: str,
baseline: dict[str, object] | None,
local: dict[str, object] | None,
latest: dict[str, object] | None,
) -> WorkflowStandardDiffItemResponse:
local_changed = local != baseline
upstream_changed = latest != baseline
if not local_changed and not upstream_changed:
state = "unchanged"
action = "none"
elif local_changed and not upstream_changed:
state = "local_only"
action = "keep_local"
elif upstream_changed and not local_changed:
state = "upstream_only"
action = "adopt_upstream"
elif local == latest:
state = "same_change"
action = "either"
else:
state = "conflict"
action = "manual_resolution"
return WorkflowStandardDiffItemResponse(
resource_type=resource_type,
resource_id=resource_id,
state=state,
recommended_action=action,
changed_fields=_changed_fields(baseline, local, latest),
baseline=baseline,
local=local,
latest=latest,
)
def _changed_fields(
baseline: Mapping[str, object] | None,
local: Mapping[str, object] | None,
latest: Mapping[str, object] | None,
) -> list[str]:
if baseline is None or local is None or latest is None:
return ["existence"]
return sorted(
key
for key in set(baseline) | set(local) | set(latest)
if not (baseline.get(key) == local.get(key) == latest.get(key))
)
class SqlWorkflowDefinitionContributionProvider:
def __init__(self, *, registry: object | None = None) -> None:
self._registry = registry
@@ -175,6 +386,20 @@ class SqlWorkflowDefinitionContributionProvider:
) -> Mapping[str, object]:
if not isinstance(session, Session):
raise TypeError("Workflow contribution reconciliation requires a Session.")
inspector = inspect(session.get_bind())
required_tables = {
WorkflowDefinition.__tablename__,
WorkflowDefinitionRevision.__tablename__,
}
missing_tables = sorted(
table for table in required_tables if not inspector.has_table(table)
)
if missing_tables:
return {
"skipped": True,
"reason": "schema_unavailable",
"missing_tables": missing_tables,
}
return reconcile_workflow_definition_contributions(
session,
registry=self._registry,
@@ -464,6 +689,8 @@ def _item(
__all__ = [
"SqlWorkflowDefinitionContributionProvider",
"compare_workflow_override_to_standard",
"reconcile_workflow_contributions",
"reconcile_workflow_definition_contributions",
"reset_workflow_override_to_standard",
]
@@ -33,10 +33,14 @@ from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
CAPABILITY_WORKFLOW_ORCHESTRATION,
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
)
from govoplan_core.db.base import Base
from govoplan_workflow_engine.backend.db import models as workflow_models
from govoplan_workflow_engine.backend.configuration_provider import (
WORKFLOW_CONFIGURATION_CAPABILITY,
)
MODULE_ID = "workflow_engine"
@@ -144,6 +148,22 @@ def _definition_contribution_provider(context: ModuleContext):
return SqlWorkflowDefinitionContributionProvider(registry=context.registry)
def _configuration_provider(context: ModuleContext):
from govoplan_workflow_engine.backend.configuration_provider import (
SqlWorkflowConfigurationProvider,
)
return SqlWorkflowConfigurationProvider(registry=context.registry)
def _orchestration_provider(context: ModuleContext):
from govoplan_workflow_engine.backend.orchestration import (
SqlWorkflowOrchestrationProvider,
)
return SqlWorkflowOrchestrationProvider(registry=context.registry)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -179,6 +199,14 @@ manifest = ModuleManifest(
name="workflow.definition_contributions",
version="0.1.0",
),
ModuleInterfaceProvider(
name=WORKFLOW_CONFIGURATION_CAPABILITY,
version="1.0.0",
),
ModuleInterfaceProvider(
name=CAPABILITY_WORKFLOW_ORCHESTRATION,
version="1.0.0",
),
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"),
ModuleInterfaceProvider(
@@ -232,6 +260,8 @@ manifest = ModuleManifest(
_definition_contribution_provider
),
CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker,
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -0,0 +1,200 @@
from __future__ import annotations
from sqlalchemy import case, or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.workflows import (
WorkflowCurrentStepResolution,
WorkflowInstanceRef,
WorkflowStandardStartRequest,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowInstance,
WorkflowInstanceStep,
)
from govoplan_workflow_engine.backend.instance_service import (
get_instance as get_workflow_instance,
resolve_step,
start_instance,
)
from govoplan_workflow_engine.backend.schemas import (
WorkflowInstanceStartRequest,
WorkflowStepActionRequest,
)
class SqlWorkflowOrchestrationProvider:
def __init__(self, *, registry: object | None = None) -> None:
self._registry = registry
def start_standard(
self,
session: object,
principal: object,
*,
request: WorkflowStandardStartRequest,
) -> WorkflowInstanceRef:
sql_session = _session(session)
api_principal = _principal(principal)
definition = _active_standard(
sql_session,
tenant_id=request.tenant_id,
origin_module_id=request.origin_module_id,
definition_key=request.definition_key,
)
instance, replayed = start_instance(
sql_session,
tenant_id=request.tenant_id,
definition_id=definition.id,
actor_id=request.actor_id,
principal=api_principal,
registry=self._registry,
payload=WorkflowInstanceStartRequest(
idempotency_key=request.idempotency_key,
input=dict(request.input),
correlation_id=request.correlation_id,
),
start_origin=request.start_origin,
)
return _instance_ref(sql_session, instance, replayed=replayed)
def resolve_current_step(
self,
session: object,
principal: object,
*,
tenant_id: str,
instance_id: str,
resolution: WorkflowCurrentStepResolution,
) -> WorkflowInstanceRef:
sql_session = _session(session)
api_principal = _principal(principal)
instance = get_workflow_instance(
sql_session,
tenant_id=tenant_id,
instance_id=instance_id,
)
step_id = instance.current_step_id
if step_id is None:
raise ValueError("Workflow instance has no current waiting step.")
if (
resolution.expected_step_id is not None
and resolution.expected_step_id != step_id
):
raise ValueError("Workflow current step changed; reload and retry.")
resolved = resolve_step(
sql_session,
tenant_id=tenant_id,
instance_id=instance_id,
step_id=step_id,
actor_id=resolution.actor_id,
principal=api_principal,
registry=self._registry,
payload=WorkflowStepActionRequest(
action=resolution.action, # type: ignore[arg-type]
output=dict(resolution.output),
evidence=list(resolution.evidence),
comment=resolution.comment,
),
)
return _instance_ref(sql_session, resolved)
def get_instance(
self,
session: object,
*,
tenant_id: str,
instance_id: str,
) -> WorkflowInstanceRef:
sql_session = _session(session)
instance = get_workflow_instance(
sql_session,
tenant_id=tenant_id,
instance_id=instance_id,
)
return _instance_ref(sql_session, instance)
def _active_standard(
session: Session,
*,
tenant_id: str,
origin_module_id: str,
definition_key: str,
) -> WorkflowDefinition:
definition = session.scalar(
select(WorkflowDefinition)
.where(
or_(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
WorkflowDefinition.standard_origin_module_id == origin_module_id,
WorkflowDefinition.standard_definition_key == definition_key,
WorkflowDefinition.status == "active",
WorkflowDefinition.active_revision.is_not(None),
WorkflowDefinition.deleted_at.is_(None),
)
.order_by(
case(
(WorkflowDefinition.tenant_id == tenant_id, 0),
else_=1,
),
WorkflowDefinition.updated_at.desc(),
)
)
if definition is None:
raise ValueError(
f"Active Workflow standard is unavailable: "
f"{origin_module_id}/{definition_key}."
)
return definition
def _instance_ref(
session: Session,
instance: WorkflowInstance,
*,
replayed: bool = False,
) -> WorkflowInstanceRef:
revision = session.get(
WorkflowDefinitionRevision,
instance.definition_revision_id,
)
if revision is None:
raise ValueError("Workflow instance revision is unavailable.")
step = (
session.get(WorkflowInstanceStep, instance.current_step_id)
if instance.current_step_id
else None
)
return WorkflowInstanceRef(
id=instance.id,
tenant_id=instance.tenant_id,
definition_id=instance.definition_id,
definition_revision_id=instance.definition_revision_id,
definition_revision=revision.revision,
definition_hash=revision.content_hash,
status=instance.status,
current_step_id=instance.current_step_id,
current_node_id=step.node_id if step is not None else None,
replayed=replayed,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Workflow orchestration requires a SQLAlchemy Session.")
return value
def _principal(value: object) -> ApiPrincipal:
if not isinstance(value, ApiPrincipal):
raise TypeError("Workflow orchestration requires an API principal.")
return value
__all__ = ["SqlWorkflowOrchestrationProvider"]
@@ -21,6 +21,7 @@ from govoplan_workflow_engine.backend.governance import (
require_definition_action,
)
from govoplan_workflow_engine.backend.contributions import (
compare_workflow_override_to_standard,
reconcile_workflow_definition_contributions,
reset_workflow_override_to_standard,
)
@@ -87,6 +88,7 @@ from govoplan_workflow_engine.backend.schemas import (
WorkflowNodeLibraryResponse,
WorkflowNodeTypeResponse,
WorkflowPortResponse,
WorkflowStandardDiffResponse,
WorkflowStepActionRequest,
)
from govoplan_workflow_engine.backend.instance_service import (
@@ -819,6 +821,41 @@ def api_reset_definition_to_standard(
return response
@router.get(
"/definitions/{definition_id}/standard-diff",
response_model=WorkflowStandardDiffResponse,
)
def api_compare_definition_to_standard(
definition_id: str,
include_unchanged: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowStandardDiffResponse:
_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 compare_workflow_override_to_standard(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
include_unchanged=include_unchanged,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.get(
"/instances/{instance_id}",
response_model=WorkflowInstanceResponse,
@@ -309,6 +309,41 @@ class WorkflowStandardProvenanceResponse(BaseModel):
reset_available: bool = False
class WorkflowStandardDiffItemResponse(BaseModel):
resource_type: Literal["graph", "node", "edge"]
resource_id: str
state: Literal[
"unchanged",
"local_only",
"upstream_only",
"same_change",
"conflict",
]
recommended_action: Literal[
"none",
"keep_local",
"adopt_upstream",
"either",
"manual_resolution",
]
changed_fields: list[str] = Field(default_factory=list)
baseline: dict[str, Any] | None = None
local: dict[str, Any] | None = None
latest: dict[str, Any] | None = None
class WorkflowStandardDiffResponse(BaseModel):
override_definition_id: str
baseline_definition_id: str
pinned_baseline_revision: int
local_revision: int
latest_baseline_revision: int
counts: dict[str, int]
conflict_count: int
auto_mergeable: bool
items: list[WorkflowStandardDiffItemResponse]
class WorkflowActionDecisionResponse(BaseModel):
allowed: bool
reason: str | None = None