feat: add governed service workflow launcher

This commit is contained in:
2026-08-01 17:48:41 +02:00
parent 43c1f3fb72
commit 55f98d1b65
7 changed files with 272 additions and 1 deletions
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Workflow Engine Codex Guide
## Scope
This repository owns headless workflow definitions, immutable versions, validation, execution adapters, resumable state, human handoffs, and module-provided workflow baselines.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Workflow Engine internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- `govoplan-workflow` owns the optional editor; modules may define and run workflows through engine contracts without it.
- Preserve versioning, idempotency, authorization, recovery, and audit evidence at every transition.
+7
View File
@@ -21,6 +21,13 @@ See [the module concept](docs/CONCEPT.md) and
[BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for runtime [BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for runtime
semantics and adapter boundaries. semantics and adapter boundaries.
The optional `workflow_engine.service_launcher` capability lets Portal start an
authorized active workflow from an exact published Service revision. It resolves
tenant overrides before system baselines, pins the selected workflow revision,
records the Service and binding in trusted instance context, and safely replays
the same launch after an ambiguous response. Portal never accesses Workflow
Engine tables.
## Checks ## Checks
```bash ```bash
+2
View File
@@ -114,6 +114,8 @@ The first executable slice now provides:
- manual activity, review, and wait handoffs with comments and evidence - manual activity, review, and wait handoffs with comments and evidence
- a worker capability with current-authorization rechecks - a worker capability with current-authorization rechecks
- an operator dialog for starting, inspecting, and advancing instances - an operator dialog for starting, inspecting, and advancing instances
- an owner-side Service launcher that starts an authorized active definition,
retains the exact Service/binding provenance, and safely replays Portal calls
The next execution slices should provide: The next execution slices should provide:
@@ -14,6 +14,7 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic, DocumentationTopic,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
@@ -23,6 +24,7 @@ from govoplan_core.core.modules import (
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.policy import ( from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE, CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
) )
@@ -41,6 +43,10 @@ from govoplan_workflow_engine.backend.db import models as workflow_models
from govoplan_workflow_engine.backend.configuration_provider import ( from govoplan_workflow_engine.backend.configuration_provider import (
WORKFLOW_CONFIGURATION_CAPABILITY, WORKFLOW_CONFIGURATION_CAPABILITY,
) )
from govoplan_workflow_engine.backend.service_launcher import (
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER,
WorkflowServiceLauncher,
)
MODULE_ID = "workflow_engine" MODULE_ID = "workflow_engine"
@@ -164,6 +170,10 @@ def _orchestration_provider(context: ModuleContext):
return SqlWorkflowOrchestrationProvider(registry=context.registry) return SqlWorkflowOrchestrationProvider(registry=context.registry)
def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher:
return WorkflowServiceLauncher(registry=context.registry)
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -209,6 +219,10 @@ manifest = ModuleManifest(
), ),
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION), ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"), ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"),
ModuleInterfaceProvider(
name=CAPABILITY_WORKFLOW_SERVICE_LAUNCHER,
version="0.1.0",
),
ModuleInterfaceProvider( ModuleInterfaceProvider(
name="workflow.bpmn_execution_adapters", name="workflow.bpmn_execution_adapters",
version="1.0.0", version="1.0.0",
@@ -262,6 +276,14 @@ manifest = ModuleManifest(
CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker, CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker,
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider, CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider, WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
},
capability_documentation={
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: CapabilityDocumentation(
label="Workflow service launcher",
summary="Starts an authorized active Workflow from an exact available Service revision.",
contract_version="0.1.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -302,7 +324,9 @@ manifest = ModuleManifest(
"Unlike Dataflow, Workflow permits cycles for correction and retry paths. " "Unlike Dataflow, Workflow permits cycles for correction and retry paths. "
"Module actions are addressed through versioned capabilities rather than " "Module actions are addressed through versioned capabilities rather than "
"implementation imports. Definitions are persisted as immutable graph " "implementation imports. Definitions are persisted as immutable graph "
"revisions; activation pins the exact revision used by future instances." "revisions; activation pins the exact revision used by future instances. "
"The optional service-launch capability starts an authorized active "
"revision from an exact Portal Service binding and records that provenance."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -338,6 +362,19 @@ manifest = ModuleManifest(
order=77, order=77,
), ),
), ),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="runtime",
maturity="vertical_slice",
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
test_ref="tests/test_instance_service.py",
known_limits=("Execution adapters support declared conformance profiles but do not cover every editable BPMN semantic.",),
owned_concepts=("workflow definition", "workflow revision", "workflow instance", "work transition", "execution adapter binding"),
non_owned_concepts=("visual editor", "domain action", "notification", "dataflow run"),
recovery_docs=("docs/CONCEPT.md",),
security_docs=("docs/CONCEPT.md",),
operations_docs=("README.md",),
),
) )
@@ -0,0 +1,142 @@
from __future__ import annotations
import hashlib
from urllib.parse import quote
from sqlalchemy import case, or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.institutional import (
InstitutionalContextError,
InstitutionalReference,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
)
from govoplan_workflow_engine.backend.db.models import WorkflowDefinition
from govoplan_workflow_engine.backend.instance_service import start_instance
from govoplan_workflow_engine.backend.schemas import WorkflowInstanceStartRequest
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER = "workflow_engine.service_launcher"
class WorkflowServiceLauncher:
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def launch_service(
self,
session: object,
principal: object,
*,
definition: ServiceDefinition,
request: ServiceLaunchRequest,
) -> ServiceLaunchResult:
if not isinstance(session, Session) or not isinstance(principal, ApiPrincipal):
raise InstitutionalContextError(
"Workflow service launch requires a database session and API principal."
)
if request.service_ref != definition.reference:
raise InstitutionalContextError(
"Workflow service launch must use the requested exact Service revision."
)
if (
request.binding.kind != "workflow"
or request.binding not in definition.bindings
):
raise InstitutionalContextError(
"Workflow service launch requires a workflow binding from the Service definition."
)
workflow = _workflow_definition(
session,
tenant_id=definition.reference.tenant_id,
reference=request.binding.reference,
)
correlation_hash = hashlib.sha256(
(
f"{definition.reference.tenant_id}:"
f"{definition.reference.object_id}:"
f"{definition.reference.version}:"
f"{request.idempotency_key}"
).encode("utf-8")
).hexdigest()[:32]
instance, replayed = start_instance(
session,
tenant_id=definition.reference.tenant_id,
definition_id=workflow.id,
actor_id=principal.account_id,
principal=principal,
registry=self._registry,
payload=WorkflowInstanceStartRequest(
idempotency_key=request.idempotency_key,
input={
**dict(request.parameters),
"_service": definition.reference.to_dict(),
},
correlation_id=f"service:{correlation_hash}",
),
start_origin="user",
)
target_ref = InstitutionalReference(
kind="workflow",
owner_module="workflow_engine",
object_id=instance.id,
tenant_id=definition.reference.tenant_id,
version=str(workflow.active_revision or workflow.current_revision),
valid_at=request.requested_at,
)
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=target_ref,
href=f"/workflow?instance={quote(instance.id, safe='')}",
replayed=replayed,
metadata={
"workflow_definition_id": workflow.id,
"workflow_instance_id": instance.id,
"workflow_status": instance.status,
},
)
def _workflow_definition(
session: Session,
*,
tenant_id: str,
reference: str,
) -> WorkflowDefinition:
value = reference.removeprefix("workflow:").strip()
if not value:
raise InstitutionalContextError("Workflow Service binding is invalid.")
item = session.scalar(
select(WorkflowDefinition)
.where(
or_(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
WorkflowDefinition.deleted_at.is_(None),
or_(
WorkflowDefinition.id == value,
WorkflowDefinition.definition_key == value,
),
)
.order_by(
case((WorkflowDefinition.tenant_id == tenant_id, 0), else_=1),
WorkflowDefinition.updated_at.desc(),
)
)
if item is None:
raise InstitutionalContextError(
"Workflow Service binding does not resolve to an available definition."
)
return item
__all__ = [
"CAPABILITY_WORKFLOW_SERVICE_LAUNCHER",
"WorkflowServiceLauncher",
]
+60
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from datetime import UTC, datetime, timedelta
import unittest import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -24,6 +25,13 @@ from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE, CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
DataflowRunDescriptor, DataflowRunDescriptor,
) )
from govoplan_core.core.institutional import (
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceLaunchRequest,
TemporalRevision,
)
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_workflow_engine.backend.db.models import ( from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition, WorkflowDefinition,
@@ -55,6 +63,7 @@ from govoplan_workflow_engine.backend.service import (
activate_definition, activate_definition,
create_definition, create_definition,
) )
from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher
try: try:
from test_bpmn import NATIVE_BPMN from test_bpmn import NATIVE_BPMN
except ModuleNotFoundError as exc: except ModuleNotFoundError as exc:
@@ -496,6 +505,57 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
), ),
) )
def test_service_launcher_starts_and_replays_exact_active_workflow(self) -> None:
now = datetime.now(tz=UTC)
service_ref = InstitutionalReference(
kind="service",
owner_module="services",
object_id="monthly-service",
tenant_id="tenant-1",
version="3",
valid_at=now,
)
binding = ServiceBinding("workflow", self.definition.id)
definition = ServiceDefinition(
reference=service_ref,
key="monthly.processing",
temporal=TemporalRevision(
revision="3",
valid_from=now - timedelta(days=1),
recorded_at=now - timedelta(days=2),
change_reason="Published workflow service.",
),
title="Monthly processing",
audience=("authenticated",),
bindings=(binding,),
publication_state="published",
)
request = ServiceLaunchRequest(
service_ref=service_ref,
binding=binding,
idempotency_key="service-workflow-1",
requested_at=now,
parameters={"case_id": "case-1"},
)
launcher = WorkflowServiceLauncher(self.registry)
first = launcher.launch_service(
self.session,
principal(),
definition=definition,
request=request,
)
second = launcher.launch_service(
self.session,
principal(),
definition=definition,
request=request,
)
self.assertFalse(first.replayed)
self.assertTrue(second.replayed)
self.assertEqual(first.target_ref, second.target_ref)
def test_guided_workflow_rejects_automated_start_origin(self) -> None: def test_guided_workflow_rejects_automated_start_origin(self) -> None:
definition = create_definition( definition = create_definition(
self.session, self.session,
+7
View File
@@ -8,6 +8,9 @@ from govoplan_workflow_engine.backend.manifest import (
INSTANCE_START_SCOPE, INSTANCE_START_SCOPE,
get_manifest, get_manifest,
) )
from govoplan_workflow_engine.backend.service_launcher import (
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER,
)
from govoplan_core.core.workflows import ( from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_RUNTIME_WORKER, CAPABILITY_WORKFLOW_RUNTIME_WORKER,
) )
@@ -43,6 +46,10 @@ class WorkflowManifestTests(unittest.TestCase):
CAPABILITY_WORKFLOW_RUNTIME_WORKER, CAPABILITY_WORKFLOW_RUNTIME_WORKER,
manifest.capability_factories, manifest.capability_factories,
) )
self.assertIn(
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER,
manifest.capability_factories,
)
self.assertIsNone(manifest.frontend) self.assertIsNone(manifest.frontend)
self.assertEqual((), manifest.nav_items) self.assertEqual((), manifest.nav_items)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)