feat: add governed service workflow launcher
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user