feat: add workflow engine contribution contracts

This commit is contained in:
2026-07-31 16:58:57 +02:00
parent 5211e07d0b
commit 1884274f8d
11 changed files with 301 additions and 13 deletions
+2 -2
View File
@@ -373,7 +373,7 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
@@ -446,7 +446,7 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
+6
View File
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
from govoplan_core.core.workflows import WorkflowDefinitionContribution
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -440,3 +441,8 @@ class ModuleManifest:
...,
] = ()
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
# A renamed or extracted module may continue to own an established
# permission namespace. This keeps persisted grants stable while the
# runtime module ID changes.
permission_namespace: str | None = None
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
+66 -2
View File
@@ -43,6 +43,9 @@ from govoplan_core.core.views import (
route_view_surface_id,
validate_view_surface_id,
)
from govoplan_core.core.workflows import (
workflow_definition_contribution_hash,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
@@ -540,8 +543,12 @@ def _validate_manifest_permission(
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
permission_namespace = manifest.permission_namespace or manifest.id
if permission.module_id != permission_namespace:
raise RegistryError(
f"Permission {permission.scope!r} has mismatched module id "
f"{permission.module_id!r}; expected {permission_namespace!r}"
)
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
@@ -611,6 +618,55 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_workflow_definition_contributions(manifest)
def _validate_workflow_definition_contributions(
manifest: ModuleManifest,
) -> None:
seen_keys: set[str] = set()
for contribution in manifest.workflow_definitions:
if contribution.origin_module_id != manifest.id:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin module "
f"{contribution.origin_module_id!r}"
)
if contribution.origin_module_version != manifest.version:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin version "
f"{contribution.origin_module_version!r}, expected "
f"{manifest.version!r}"
)
if not re.match(
r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$",
contribution.definition_key,
):
raise RegistryError(
f"Workflow contribution key is invalid: "
f"{contribution.definition_key!r}"
)
if contribution.definition_key in seen_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate Workflow "
f"contribution {contribution.definition_key!r}"
)
seen_keys.add(contribution.definition_key)
if contribution.scope_type not in {"system", "tenant"}:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
f"unsupported scope {contribution.scope_type!r}"
)
expected_hash = workflow_definition_contribution_hash(contribution)
if (
contribution.content_hash is not None
and contribution.content_hash != expected_hash
):
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
"a content hash that does not match its canonical content"
)
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
@@ -678,6 +734,14 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
if not manifest.version.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
if (
manifest.permission_namespace is not None
and not _MODULE_ID_RE.match(manifest.permission_namespace)
):
raise RegistryError(
f"Module {manifest.id!r} has invalid permission namespace "
f"{manifest.permission_namespace!r}"
)
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported manifest contract version "
+113 -1
View File
@@ -1,11 +1,86 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
import json
from typing import Protocol, runtime_checkable
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = (
"workflow.definitionContributions"
)
@dataclass(frozen=True, slots=True)
class WorkflowDefinitionContribution:
"""Versioned, module-owned Workflow Engine baseline definition."""
origin_module_id: str
origin_module_version: str
definition_key: str
name: str
graph: Mapping[str, object]
contribution_schema_version: str = "1"
description: str | None = None
definition_kind: str = "flow"
scope_type: str = "system"
inherit_to_lower_scopes: bool = True
allow_start: bool = True
allow_reuse: bool = True
allow_automation: bool = False
execution_mode: str = "hybrid"
view_id: str | None = None
view_revision_id: str | None = None
bpmn_xml: str | None = None
bpmn_adapter_id: str = "govoplan.native.bpmn"
bpmn_adapter_version: str | None = None
activate_on_install: bool = True
required_capabilities: tuple[str, ...] = ()
required_interfaces: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
policy_metadata: Mapping[str, object] = field(default_factory=dict)
content_hash: str | None = None
def workflow_definition_contribution_hash(
contribution: WorkflowDefinitionContribution,
) -> str:
"""Return the stable content hash, excluding package release provenance."""
payload = {
"schema_version": contribution.contribution_schema_version,
"definition_key": contribution.definition_key,
"name": contribution.name,
"description": contribution.description,
"graph": contribution.graph,
"definition_kind": contribution.definition_kind,
"scope_type": contribution.scope_type,
"inherit_to_lower_scopes": contribution.inherit_to_lower_scopes,
"allow_start": contribution.allow_start,
"allow_reuse": contribution.allow_reuse,
"allow_automation": contribution.allow_automation,
"execution_mode": contribution.execution_mode,
"view_id": contribution.view_id,
"view_revision_id": contribution.view_revision_id,
"bpmn_xml": contribution.bpmn_xml,
"bpmn_adapter_id": contribution.bpmn_adapter_id,
"bpmn_adapter_version": contribution.bpmn_adapter_version,
"activate_on_install": contribution.activate_on_install,
"required_capabilities": contribution.required_capabilities,
"required_interfaces": contribution.required_interfaces,
"metadata": contribution.metadata,
"policy_metadata": contribution.policy_metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
@runtime_checkable
@@ -20,6 +95,17 @@ class WorkflowRuntimeWorker(Protocol):
...
@runtime_checkable
class WorkflowDefinitionContributionProvider(Protocol):
def reconcile(
self,
session: object,
*,
tenant_ids: Sequence[str] = (),
) -> Mapping[str, object]:
...
def workflow_runtime_worker(
registry: object | None,
) -> WorkflowRuntimeWorker | None:
@@ -37,8 +123,34 @@ def workflow_runtime_worker(
)
def workflow_definition_contribution_provider(
registry: object | None,
) -> WorkflowDefinitionContributionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
):
return None
capability = registry.capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
return (
capability
if isinstance(capability, WorkflowDefinitionContributionProvider)
else None
)
__all__ = [
"CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS",
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
"WorkflowDefinitionContribution",
"WorkflowDefinitionContributionProvider",
"WorkflowRuntimeWorker",
"workflow_definition_contribution_hash",
"workflow_definition_contribution_provider",
"workflow_runtime_worker",
]
+2 -2
View File
@@ -49,7 +49,7 @@ class Settings(BaseSettings):
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,workflow,views,search,risk_compliance,"
"datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance,"
"postbox,notifications,docs,ops"
),
alias="ENABLED_MODULES",
@@ -156,7 +156,7 @@ class Settings(BaseSettings):
celery_queues: str = Field(
default=(
"send_email,append_sent,notifications,calendar,"
"dataflow,events,default"
"dataflow,workflow,events,default"
),
alias="CELERY_QUEUES",
)