diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0499e87 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 985da1f..5e61a60 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ See [the module concept](docs/CONCEPT.md) and [BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for runtime 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 ```bash diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index d0c5d7b..798f768 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -114,6 +114,8 @@ The first executable slice now provides: - manual activity, review, and wait handoffs with comments and evidence - a worker capability with current-authorization rechecks - 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: diff --git a/src/govoplan_workflow_engine/backend/manifest.py b/src/govoplan_workflow_engine/backend/manifest.py index 1ebcaa2..49acf8b 100644 --- a/src/govoplan_workflow_engine/backend/manifest.py +++ b/src/govoplan_workflow_engine/backend/manifest.py @@ -14,6 +14,7 @@ from govoplan_core.core.module_guards import ( persistent_table_uninstall_guard, ) from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationTopic, MigrationSpec, ModuleContext, @@ -23,6 +24,7 @@ from govoplan_core.core.modules import ( PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.policy import ( 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 ( WORKFLOW_CONFIGURATION_CAPABILITY, ) +from govoplan_workflow_engine.backend.service_launcher import ( + CAPABILITY_WORKFLOW_SERVICE_LAUNCHER, + WorkflowServiceLauncher, +) MODULE_ID = "workflow_engine" @@ -164,6 +170,10 @@ def _orchestration_provider(context: ModuleContext): return SqlWorkflowOrchestrationProvider(registry=context.registry) +def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher: + return WorkflowServiceLauncher(registry=context.registry) + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -209,6 +219,10 @@ manifest = ModuleManifest( ), ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION), ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"), + ModuleInterfaceProvider( + name=CAPABILITY_WORKFLOW_SERVICE_LAUNCHER, + version="0.1.0", + ), ModuleInterfaceProvider( name="workflow.bpmn_execution_adapters", version="1.0.0", @@ -262,6 +276,14 @@ manifest = ModuleManifest( CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker, CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_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( module_id=MODULE_ID, @@ -302,7 +324,9 @@ manifest = ModuleManifest( "Unlike Dataflow, Workflow permits cycles for correction and retry paths. " "Module actions are addressed through versioned capabilities rather than " "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", documentation_types=("admin", "user"), @@ -338,6 +362,19 @@ manifest = ModuleManifest( 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",), + ), ) diff --git a/src/govoplan_workflow_engine/backend/service_launcher.py b/src/govoplan_workflow_engine/backend/service_launcher.py new file mode 100644 index 0000000..4bbbb5d --- /dev/null +++ b/src/govoplan_workflow_engine/backend/service_launcher.py @@ -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", +] diff --git a/tests/test_instance_service.py b/tests/test_instance_service.py index bc4f906..e4119c2 100644 --- a/tests/test_instance_service.py +++ b/tests/test_instance_service.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import replace +from datetime import UTC, datetime, timedelta import unittest from sqlalchemy import create_engine @@ -24,6 +25,13 @@ from govoplan_core.core.dataflows import ( CAPABILITY_DATAFLOW_RUN_LIFECYCLE, DataflowRunDescriptor, ) +from govoplan_core.core.institutional import ( + InstitutionalReference, + ServiceBinding, + ServiceDefinition, + ServiceLaunchRequest, + TemporalRevision, +) from govoplan_core.db.base import Base from govoplan_workflow_engine.backend.db.models import ( WorkflowDefinition, @@ -55,6 +63,7 @@ from govoplan_workflow_engine.backend.service import ( activate_definition, create_definition, ) +from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher try: from test_bpmn import NATIVE_BPMN 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: definition = create_definition( self.session, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d5bf076..3ad547e 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -8,6 +8,9 @@ from govoplan_workflow_engine.backend.manifest import ( INSTANCE_START_SCOPE, get_manifest, ) +from govoplan_workflow_engine.backend.service_launcher import ( + CAPABILITY_WORKFLOW_SERVICE_LAUNCHER, +) from govoplan_core.core.workflows import ( CAPABILITY_WORKFLOW_RUNTIME_WORKER, ) @@ -43,6 +46,10 @@ class WorkflowManifestTests(unittest.TestCase): CAPABILITY_WORKFLOW_RUNTIME_WORKER, manifest.capability_factories, ) + self.assertIn( + CAPABILITY_WORKFLOW_SERVICE_LAUNCHER, + manifest.capability_factories, + ) self.assertIsNone(manifest.frontend) self.assertEqual((), manifest.nav_items) self.assertIsNotNone(manifest.migration_spec)