commit 9bccbd68daf7c93f41c39d8e6c09c0606183dab2 Author: Albrecht Degering Date: Fri Jul 31 16:58:51 2026 +0200 feat: extract headless workflow engine diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ab78a97 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/workflow-engine +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..41ab33d --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/workflow-engine + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..044afd0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/workflow-engine +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..070d762 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/workflow-engine +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..47deb49 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/workflow-engine +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6750d45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,348 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Local WebUI test/build scratch directories +.component-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +*.db + +# GovOPlaN local runtime state +runtime/ + +# GovOPlaN WebUI test output +webui/.module-test-build/ +webui/.component-test-build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..985da1f --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# GovOPlaN Workflow Engine + + +**Repository type:** module (platform). + + +Headless workflow definition and execution services for GovOPlaN modules. + +Workflow Engine owns the canonical BPMN/native graph model, immutable +definition revisions, runtime instances and evidence, migrations, validation, +execution adapters, dispatch, retries, and recovery. It intentionally provides +no primary navigation or visual editor. Modules can depend on runtime module ID +`workflow_engine` and the versioned `workflow.*` interfaces without installing +the optional `govoplan-workflow` editor. + +The extraction preserves the existing `workflow_*` tables, Alembic revision +IDs, `/api/v1/workflow` routes, `workflow:*` permissions, instance IDs, and +audit references. See [the engine/editor split](docs/ENGINE_EDITOR_SPLIT.md) +for the ownership and compatibility policy. +See [the module concept](docs/CONCEPT.md) and +[BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for runtime +semantics and adapter boundaries. + +## Checks + +```bash +/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +``` diff --git a/docs/BPMN_INTEROPERABILITY.md b/docs/BPMN_INTEROPERABILITY.md new file mode 100644 index 0000000..cc59aef --- /dev/null +++ b/docs/BPMN_INTEROPERABILITY.md @@ -0,0 +1,84 @@ +# Native BPMN Graph + +GovOPlaN uses BPMN 2.0 as Workflow's canonical graph language while keeping +notation support distinct from executable runtime support. + +## Current Contract + +- The native Workflow graph stores BPMN element and flow types, process + membership, containment, geometry, properties, and preserved extension + content. There is one editor and one graph representation. +- BPMN XML import maps standard elements and BPMN DI into the native graph. + Export deterministically renders XML and DI from the current graph. The + normalized XML artifact, its hash, and the native profile version are pinned + with every immutable revision. +- No browser-side BPMN modeler is required. The WebUI uses the same graph + surface and shared controls as the rest of GovOPlaN. +- `GET /api/v1/workflow/bpmn/profile` publishes all installed, versioned + conformance profiles. +- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML, + inventories every BPMN model element, detects duplicate IDs and selected + dangling references, and classifies elements as interchange-only, natively + mappable, or natively executable. +- `POST /api/v1/workflow/bpmn/compile` imports a bounded BPMN document into the + canonical native graph. +- `POST /api/v1/workflow/bpmn/render` exports a native graph as normalized BPMN + XML with BPMN DI geometry. +- `GET /api/v1/workflow/definitions/{id}/revisions/{revision}/bpmn` returns + the exact pinned document and its current availability/conformance + assessment. +- XML entities, DTD-based expansion, oversized documents, and malformed roots + are rejected. + +Inspection is not XML Schema validation and does not claim that every editable +BPMN construct can be executed. Notation and interchange remain available when +the native runtime cannot activate the document. + +## Built-In Profiles + +- `govoplan.native.bpmn@1.0.0` is the canonical graph and interchange profile. + It maps the supported BPMN vocabulary into native nodes and edges. Activation + separately validates whether every execution semantic is implemented. +- `govoplan.native.linear@1.0.0` and `bpmn.interchange@1.0.0` remain registered + for historical revision compatibility; new editor revisions use the native + BPMN profile. + +Gateways, subprocesses, event definitions, transactions, compensation, +collaboration, and choreography remain editable and exportable even when their +token or lifecycle semantics are not yet implemented. + +## Execution Boundary + +Adding a BPMN shape is not equivalent to implementing its token semantics, +event subscriptions, compensation, transactions, choreography, or conformance +behavior. Each executable mapping therefore needs: + +1. an explicit native semantic mapping; +2. validation rules and lifecycle behavior; +3. resumability and idempotency tests; +4. migration and round-trip fixtures; +5. a declared fallback when the installed runtime cannot execute it. + +Unsupported execution constructs remain visible in the native graph, but +activation remains blocked until an execution adapter declares support. + +## Adapter Boundary + +Adapter packages register through the +`govoplan.workflow.bpmn_adapters` Python entry-point group. Workflow discovers +them without importing a concrete module. An adapter publishes a stable ID, +version, runtime kind, conformance statement, supported elements and event +definitions, operational requirements, validation, and canonical graph +materialization. + +Revisions pin the exact adapter version. If that version is unavailable after +an installation change, the document remains readable and exportable but +cannot activate. External-engine adapters must still materialize lifecycle, +handoff, retry, cancellation, and audit evidence through the canonical +Workflow instance contract; a remote engine's private state is not the +platform record. + +The conformance fixtures under `tests/fixtures/bpmn` cover processes, +collaboration, choreography, events, transactions, compensation, and data +elements. Every fixture must import and export through the native graph without +losing modeled nodes or flows; activation has its own narrower test matrix. diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md new file mode 100644 index 0000000..d0c5d7b --- /dev/null +++ b/docs/CONCEPT.md @@ -0,0 +1,201 @@ +# govoplan-workflow Concept + +## Purpose + +`govoplan-workflow-engine` is the headless process orchestration module. +`govoplan-workflow` is its optional authoring and inspection surface. See +`ENGINE_EDITOR_SPLIT.md`. + +Workflow does not own business records. A case, task, file, appointment, +template, payment, or postbox message remains owned by its domain module. +Workflow coordinates those modules through stable contracts. + +Workflow is also the first home for GovOPlaN's action/effect automation layer. +That layer must keep automated actions governed, previewable, idempotent, +auditable, and recoverable. If action catalogues, schedules, rule execution, or +cross-module automation grow beyond workflow ownership, the runner can later be +split into a dedicated `govoplan-automation` module without changing the +action/effect contracts. + +## Ownership + +Workflow Engine owns: + +- workflow definitions and versions +- workflow instances and current state +- transitions, guards, and transition history +- timers, deadlines, and wait states that belong to process execution +- command plans and command execution records +- retry/manual-intervention state for failed command handoffs +- action/effect execution records for workflow-triggered automation +- workflow audit/event emission +- workflow diagram metadata + +Workflow owns visual authoring, catalogue, comparison, activation, inspection, +override, and reset surfaces. It owns no process tables or transition runtime. + +The module does not own: + +- case records and case evidence +- task queues and task completion semantics +- form schemas or submissions +- file storage, documents, mail, notifications, appointments, payments, ledgers, + or records +- external protocol adapters + +## Workflow Model + +A workflow definition should contain: + +- definition id, version, tenant scope, status +- states with labels, categories, and terminal markers +- transitions with from/to states, required scopes, guards, and commands +- input/output data schema references +- timers and escalation rules +- extension metadata for diagrams and operator UI + +Instances should contain: + +- instance id, tenant id, definition id/version +- subject references such as `case_id` or `submission_id` +- current state and previous state +- process variables with strict redaction rules +- transition history +- pending commands and manual actions + +## Core Contracts + +The module should integrate through: + +- module manifest metadata, route factories, permissions, and migrations +- events such as `workflow.instance_started`, `workflow.transitioned`, + `workflow.command_requested`, `workflow.command_failed`, and + `workflow.instance_completed` +- commands such as `workflow.start`, `workflow.transition`, + `workflow.retry_command`, and `workflow.cancel` +- capability lookups for domain commands, for example cases, tasks, templates, + appointments, and payments +- configuration-package fragments that install workflow definitions + +Command handoff must be explicit. A transition should record which module +capability was requested, with input payload, result summary, and failure reason. + +The shared action/effect doctrine lives in +`govoplan-core/docs/ACTION_EFFECT_AUTOMATION_LAYER.md`. + +## Reference Journey + +Permit-to-payment MVP: + +1. A form submission starts a workflow instance. +2. Workflow commands cases to create a case. +3. Workflow commands tasks to create an intake task. +4. Completion of the task transitions the instance to appointment proposal. +5. Appointment acceptance transitions to review/decision. +6. Workflow commands templates to generate a permit or decision. +7. Workflow commands payments/ledger handoff. +8. Workflow closes the case and emits evidence events. + +## MVP Slice + +The first executable slice now provides: + +- a versioned, workflow-specific graphical node library +- shared Core graph DTO and validation primitives also consumed by Dataflow +- workflow constraints that permit loops while requiring one trigger and one or + more outcomes +- trigger, activity, review, decision, wait, module-action, Dataflow, and outcome + nodes +- API discovery and validation endpoints +- revision-pinned, idempotent Workflow instances +- persisted steps and append-only transition evidence +- durable Dataflow handoff, progress reconciliation, output references, + retries, cancellation, and warning/review paths +- 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 + +The next execution slices should provide: + +- static workflow definition registration from configuration packages +- event, API, schedule, and parent-workflow start dispatchers +- guard hooks implemented through capability calls +- registry-driven generic module-action execution records +- action/effect previews for transitions that call other modules +- explicit blocked, retryable, quarantined, manual-required, and + compensation-required states +- dashboard summary provider +- event emission and audit integration + +## Permissions + +Candidate scopes: + +- `workflow:definition:read` +- `workflow:definition:write` +- `workflow:instance:read` +- `workflow:instance:start` +- `workflow:instance:transition` +- `workflow:instance:admin` + +State transitions may require both workflow scopes and domain-module permission +checks for the command being executed. + +Workflow guard evaluation should consume access semantics through kernel +capabilities instead of importing access internals: + +- `access.semanticDirectory` resolves identity, account, organization unit, + function assignment, delegation, and role facts. +- `access.explanation` records why a transition was allowed or denied in terms + of identity/account/function/role/right provenance. + +Transitions that allow a person to act in place of another function holder must +require an explicit acting context and must record both the real actor account +and the represented account/function assignment in transition history and audit +details. + +## Data Model Sketch + +Current tables: + +- `workflow_definitions` +- `workflow_definition_revisions` +- `workflow_instances` +- `workflow_instance_steps` +- `workflow_instance_events` + +Future generic action execution and timers may add: + +- `workflow_command_records` +- `workflow_timers` + +Definitions should be immutable by version after activation. Instances should +reference the exact version used at start. + +## WebUI + +Current route contribution: + +- `/workflow` + +The route combines the definition editor and a fixed run dialog showing current +state, available transitions, failed handoffs, comments/evidence, immutable +event history, and linked Dataflow results. It does not import Dataflow or other +domain UI components. + +## Tests + +Minimum tests: + +- core starts with workflow installed but cases/tasks/templates absent +- workflow definition versioning is immutable after activation +- transition guard denial is recorded and visible +- command failure is retryable and does not partially advance state +- events are emitted for start/transition/completion +- configuration package can install a simple workflow definition + +## Open Decisions + +- Whether long-running timers use Celery beat, a module scheduler, or an ops + scheduler abstraction. +- How workflow variables are redacted and retained. diff --git a/docs/ENGINE_EDITOR_SPLIT.md b/docs/ENGINE_EDITOR_SPLIT.md new file mode 100644 index 0000000..6e5c1ff --- /dev/null +++ b/docs/ENGINE_EDITOR_SPLIT.md @@ -0,0 +1,137 @@ +# Workflow Engine And Workflow Editor Split + +## Decision + +Split the current module into two installable modules: + +- `govoplan-workflow-engine` (runtime module ID `workflow_engine`) is the + headless definition and execution platform available to all modules. +- `govoplan-workflow` remains the optional authoring, inspection, catalogue, + diff, override, and reset WebUI. + +Other modules depend on Workflow Engine capabilities, never on the Workflow +editor package. Workflow depends on Workflow Engine. + +This is a packaging and ownership split, not a second workflow model. BPMN 2.0 +and the existing native graph remain the canonical language and use the same +versioned contracts. + +## Existing Versioning + +Workflow definitions are already versioned: + +- `workflow_definitions.current_revision` identifies the latest graph revision. +- `workflow_definitions.active_revision` selects the revision used for new + instances. +- `workflow_definition_revisions` stores immutable graph content, hashes, node + library versions, execution mode, pinned View revision, BPMN XML/hash, and + execution-adapter profile/version. +- Graph changes create a new revision and return an active definition to draft; + metadata-only changes do not create graph revisions. +- Updates use `expected_revision` optimistic concurrency. +- Instances pin `definition_revision_id`; later edits or module upgrades cannot + mutate running or historical instances. +- Derived definitions record source definition, source revision/hash, actor, + Policy decision, scope, and effective ancestor limits. + +The missing model is a durable module-owned baseline and local override/reset +relationship. + +## Workflow Engine Ownership + +Workflow Engine owns: + +- definition, immutable revision, instance, step, event, command, timer, and + execution-record persistence and migrations +- definition CRUD/activation/derivation APIs and headless read APIs +- Workflow graph/BPMN schemas, validation, import/export, and conformance +- node-library and execution-adapter registries +- instance start/transition/cancel/retry/reconciliation services +- runtime worker, event/API/schedule/parent dispatch, idempotency, and recovery +- definition governance capability integration and audit/event emission +- configuration-package workflow fragments +- module-contributed standard definition discovery and reconciliation + +It contributes no primary navigation or full editor. A module can install and +run its workflows when the editor is absent. + +## Workflow Editor Ownership + +Workflow owns: + +- the Workflow workspace and visual BPMN/graph editor +- definition/revision catalogue, preview, diff, validation, and activation UI +- instance inspection and operator controls built on Engine APIs +- derivation and governed override UX +- module-standard update comparison and reset-to-standard UX +- reusable embedded editor/inspector components for other module surfaces + +The editor never owns workflow tables or executes transitions directly. + +## Module-Contributed Definitions + +Modules announce standard workflows through a versioned Engine contribution +contract or a module-owned configuration-package fragment. A contribution has: + +- origin module ID/version, stable definition key, contribution schema version, + and content hash +- graph plus BPMN representation, node-library/profile requirements, and + execution mode +- default scope, start/reuse/automation ceilings, required capabilities, and + Policy metadata +- upgrade compatibility and optional migration diagnostics + +Engine reconciles contributions idempotently after module discovery. The +module-provided baseline is immutable. A module update may add a new baseline +revision, but it never rewrites a running instance or silently replaces a local +override. + +## Override And Reset + +Editing a system/module standard creates a local override derived from a pinned +baseline revision. The UI may present this as editing the effective definition, +but the canonical baseline remains available. + +- View is always possible when the caller can read the definition. +- Edit/derive is controlled by Policy and scope ceilings. +- An upstream baseline update is shown as an available update with a three-way + diff; it is not merged silently. +- Reset archives the local override and selects the latest permitted baseline. +- Historical overrides, baselines, and instances remain addressable for audit. +- A tenant/group/user override cannot loosen inherited restrictions. + +## Compatibility And Extraction Order + +1. Define Engine-owned capability and contribution DTOs in Core-neutral + contracts while preserving existing `workflow.*` interface names. +2. Create `govoplan-workflow-engine` and move backend code, tests, migrations, + and runtime workers without changing table names or API paths. +3. Transfer migration ownership without replaying the existing chain. Test both + upgrades and fresh installs with Engine alone. +4. Keep a compatibility facade in `govoplan-workflow` for one release line; + make it depend on `workflow_engine` and retain only WebUI/editor code. +5. Update Core workers and consuming modules to resolve Engine capabilities. +6. Add module contributions, immutable baselines, override/update/reset, and + configuration-package support. +7. Remove compatibility imports only under the platform compatibility policy. + +The extraction must preserve existing definition IDs, revision IDs, active +revision selection, instance foreign keys, idempotency keys, API routes, and +audit references. + +## Implemented Boundary + +The split is implemented in the `govoplan-workflow-engine` repository. Engine +owns the unchanged migration chain and `/api/v1/workflow` API, retains the +existing `workflow:*` permission namespace through an explicit manifest +compatibility field, and exposes headless runtime and contribution +capabilities. `govoplan-workflow` now has a hard dependency on runtime module +ID `workflow_engine`, contributes only its WebUI/navigation/editor contract, +and keeps one release line of `govoplan_workflow.backend` import facades. + +Module manifests can announce versioned workflow baselines. Reconciliation is +idempotent, records module/schema/hash provenance, keeps a newly supplied +baseline revision inactive when an older revision is active, and fails closed +when required capabilities or interfaces are absent. Baselines are immutable; +editing derives a pinned local override, and reset archives that override +without removing revision or instance history. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4fdf661 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-workflow-engine" +version = "0.1.14" +description = "Headless, versioned workflow definition and execution engine for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +license = "AGPL-3.0-or-later" +authors = [{ name = "GovOPlaN" }] +dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_workflow_engine = ["py.typed"] + +[project.entry-points."govoplan.modules"] +workflow_engine = "govoplan_workflow_engine.backend.manifest:get_manifest" diff --git a/src/govoplan_workflow_engine/__init__.py b/src/govoplan_workflow_engine/__init__.py new file mode 100644 index 0000000..32ae7b3 --- /dev/null +++ b/src/govoplan_workflow_engine/__init__.py @@ -0,0 +1,3 @@ +"""Headless GovOPlaN Workflow Engine module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_workflow_engine/backend/__init__.py b/src/govoplan_workflow_engine/backend/__init__.py new file mode 100644 index 0000000..da4885c --- /dev/null +++ b/src/govoplan_workflow_engine/backend/__init__.py @@ -0,0 +1 @@ +"""Workflow backend.""" diff --git a/src/govoplan_workflow_engine/backend/bpmn.py b/src/govoplan_workflow_engine/backend/bpmn.py new file mode 100644 index 0000000..162343c --- /dev/null +++ b/src/govoplan_workflow_engine/backend/bpmn.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from typing import Literal +from xml.etree.ElementTree import Element, ParseError + +from defusedxml.ElementTree import fromstring +from defusedxml.common import DefusedXmlException + + +BPMN_MODEL_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/MODEL" +BPMN_DI_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/DI" +OMG_DI_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DI" +OMG_DC_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DC" +MAX_BPMN_XML_BYTES = 1_048_576 +MAX_BPMN_ELEMENTS = 20_000 + +SupportLevel = Literal[ + "interchange_only", + "native_mapping", + "native_execution", +] + + +NATIVE_EXECUTION_ELEMENTS = frozenset( + { + "startEvent", + "endEvent", + "task", + "manualTask", + "userTask", + "serviceTask", + "sendTask", + "receiveTask", + "intermediateCatchEvent", + "sequenceFlow", + } +) + +NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset( + { + "definitions", + "process", + "collaboration", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "conditionExpression", + "scriptTask", + "serviceTask", + "businessRuleTask", + "receiveTask", + "sendTask", + "callActivity", + "subProcess", + "transaction", + "adHocSubProcess", + "exclusiveGateway", + "parallelGateway", + "inclusiveGateway", + "eventBasedGateway", + "complexGateway", + "boundaryEvent", + "eventSubProcess", + "intermediateCatchEvent", + "intermediateThrowEvent", + "dataObject", + "dataObjectReference", + "dataStoreReference", + "messageFlow", + "participant", + "lane", + "laneSet", + "textAnnotation", + "association", + "group", + "dataInputAssociation", + "dataOutputAssociation", + "choreography", + "choreographyTask", + "callChoreography", + "subChoreography", + "conversation", + "callConversation", + "subConversation", + "conversationLink", + } +) + + +@dataclass(frozen=True, slots=True) +class BpmnDiagnostic: + severity: Literal["error", "warning", "info"] + code: str + message: str + element_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class BpmnElementInventoryItem: + element_type: str + element_id: str | None + name: str | None + parent_type: str | None + parent_id: str | None + support_level: SupportLevel + + +@dataclass(frozen=True, slots=True) +class BpmnInspection: + definitions_id: str | None + target_namespace: str | None + process_count: int + executable_process_count: int + collaboration_count: int + choreography_count: int + element_counts: dict[str, int] + support_counts: dict[str, int] + elements: tuple[BpmnElementInventoryItem, ...] + diagnostics: tuple[BpmnDiagnostic, ...] + + @property + def valid_xml(self) -> bool: + return not any(item.severity == "error" for item in self.diagnostics) + + +class BpmnInspectionError(ValueError): + pass + + +def bpmn_support_level(element_type: str) -> SupportLevel: + if element_type in NATIVE_EXECUTION_ELEMENTS: + return "native_execution" + if element_type in NATIVE_MAPPING_ELEMENTS: + return "native_mapping" + return "interchange_only" + + +def parse_bpmn_xml(xml: str) -> Element: + encoded = xml.encode("utf-8") + if not encoded: + raise BpmnInspectionError("BPMN XML is empty") + if len(encoded) > MAX_BPMN_XML_BYTES: + raise BpmnInspectionError( + f"BPMN XML exceeds the {MAX_BPMN_XML_BYTES}-byte inspection limit" + ) + try: + root = fromstring(encoded) + except (DefusedXmlException, ParseError, ValueError) as exc: + raise BpmnInspectionError(f"BPMN XML is not safe and well formed: {exc}") from exc + + namespace, local_name = _qualified_name(root.tag) + if namespace != BPMN_MODEL_NAMESPACE or local_name != "definitions": + raise BpmnInspectionError( + "BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace" + ) + if sum(1 for _item in root.iter()) > MAX_BPMN_ELEMENTS: + raise BpmnInspectionError( + f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit" + ) + return root + + +def inspect_bpmn_xml(xml: str) -> BpmnInspection: + root = parse_bpmn_xml(xml) + + diagnostics: list[BpmnDiagnostic] = [] + elements: list[BpmnElementInventoryItem] = [] + ids: dict[str, str] = {} + references: list[tuple[str, str | None, str]] = [] + process_count = 0 + executable_process_count = 0 + collaboration_count = 0 + choreography_count = 0 + + stack = [(root, None, None)] + visited = 0 + while stack: + element, parent_type, parent_id = stack.pop() + visited += 1 + element_namespace, element_type = _qualified_name(element.tag) + element_id = _bounded_attribute(element.attrib.get("id"), 255) + if element_namespace == BPMN_MODEL_NAMESPACE: + name = _bounded_attribute(element.attrib.get("name"), 300) + support = bpmn_support_level(element_type) + elements.append( + BpmnElementInventoryItem( + element_type=element_type, + element_id=element_id, + name=name, + parent_type=parent_type, + parent_id=parent_id, + support_level=support, + ) + ) + if element_id: + previous = ids.get(element_id) + if previous is not None: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="duplicate_bpmn_id", + message=( + f"BPMN id {element_id!r} is used by both " + f"{previous} and {element_type}" + ), + element_id=element_id, + ) + ) + else: + ids[element_id] = element_type + if element_type == "process": + process_count += 1 + if element.attrib.get("isExecutable", "").strip().lower() == "true": + executable_process_count += 1 + elif element_type == "collaboration": + collaboration_count += 1 + elif element_type in {"choreography", "globalChoreographyTask"}: + choreography_count += 1 + _collect_references(element_type, element_id, element.attrib, references) + next_parent_type = element_type + next_parent_id = element_id + else: + next_parent_type = parent_type + next_parent_id = parent_id + for child in reversed(list(element)): + stack.append((child, next_parent_type, next_parent_id)) + + if process_count == 0 and collaboration_count == 0 and choreography_count == 0: + diagnostics.append( + BpmnDiagnostic( + severity="warning", + code="no_bpmn_process_or_collaboration", + message="BPMN definitions contain no process, collaboration, or choreography", + ) + ) + for reference, element_id, field in references: + if reference not in ids: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="dangling_bpmn_reference", + message=( + f"{field} references unknown BPMN id {reference!r}" + ), + element_id=element_id, + ) + ) + unsupported = [ + item + for item in elements + if item.support_level == "interchange_only" + and item.element_type not in {"definitions", "process"} + ] + if unsupported: + diagnostics.append( + BpmnDiagnostic( + severity="info", + code="interchange_only_elements", + message=( + f"{len(unsupported)} BPMN element(s) can be inventoried and " + "round-tripped by a BPMN modeler but are not executable by " + "the native GovOPlaN workflow runtime" + ), + ) + ) + element_counts = dict( + sorted(Counter(item.element_type for item in elements).items()) + ) + support_counts = dict( + sorted(Counter(item.support_level for item in elements).items()) + ) + return BpmnInspection( + definitions_id=_bounded_attribute(root.attrib.get("id"), 255), + target_namespace=_bounded_attribute( + root.attrib.get("targetNamespace"), + 1000, + ), + process_count=process_count, + executable_process_count=executable_process_count, + collaboration_count=collaboration_count, + choreography_count=choreography_count, + element_counts=element_counts, + support_counts=support_counts, + elements=tuple(elements), + diagnostics=tuple(diagnostics), + ) + + +def _collect_references( + element_type: str, + element_id: str | None, + attributes: dict[str, str], + references: list[tuple[str, str | None, str]], +) -> None: + fields_by_element = { + "sequenceFlow": ("sourceRef", "targetRef"), + "messageFlow": ("sourceRef", "targetRef", "messageRef"), + "association": ("sourceRef", "targetRef"), + "participant": ("processRef",), + "lane": ("partitionElementRef",), + "boundaryEvent": ("attachedToRef",), + "dataInputAssociation": ("sourceRef", "targetRef"), + "dataOutputAssociation": ("sourceRef", "targetRef"), + "dataObjectReference": ("dataObjectRef",), + "dataStoreReference": ("dataStoreRef",), + "messageEventDefinition": ("messageRef", "operationRef"), + "signalEventDefinition": ("signalRef",), + "errorEventDefinition": ("errorRef",), + "escalationEventDefinition": ("escalationRef",), + "compensateEventDefinition": ("activityRef",), + } + fields = fields_by_element.get(element_type, ()) + for field in fields: + value = attributes.get(field) + if value: + references.append((value, element_id, field)) + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _bounded_attribute(value: str | None, limit: int) -> str | None: + if value is None: + return None + text = value.strip() + return text[:limit] if text else None + + +__all__ = [ + "BPMN_MODEL_NAMESPACE", + "BpmnInspectionError", + "NATIVE_EXECUTION_ELEMENTS", + "NATIVE_MAPPING_ELEMENTS", + "bpmn_support_level", + "inspect_bpmn_xml", + "parse_bpmn_xml", +] diff --git a/src/govoplan_workflow_engine/backend/bpmn_adapters.py b/src/govoplan_workflow_engine/backend/bpmn_adapters.py new file mode 100644 index 0000000..834f78a --- /dev/null +++ b/src/govoplan_workflow_engine/backend/bpmn_adapters.py @@ -0,0 +1,721 @@ +from __future__ import annotations + +import hashlib +import logging +from collections import Counter +from dataclasses import dataclass +from importlib.metadata import entry_points +from threading import Lock +from typing import Literal, Protocol, runtime_checkable +from xml.etree.ElementTree import Element + +from govoplan_workflow_engine.backend.bpmn import ( + BPMN_DI_NAMESPACE, + BPMN_MODEL_NAMESPACE, + OMG_DC_NAMESPACE, + BpmnDiagnostic, + BpmnInspection, + inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow_engine.backend.bpmn_graph import ( + BPMN_EDGE_LOCAL_NAMES, + BPMN_NODE_LOCAL_NAMES, + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + import_bpmn_graph, + runtime_diagnostics, +) +from govoplan_workflow_engine.backend.schemas import ( + WorkflowEdge, + WorkflowGraph, + WorkflowNode, + WorkflowPosition, +) + + +BPMN_ADAPTER_ENTRY_POINT_GROUP = "govoplan.workflow.bpmn_adapters" +INTERCHANGE_ADAPTER_ID = "bpmn.interchange" +NATIVE_LINEAR_ADAPTER_ID = "govoplan.native.linear" + +RuntimeKind = Literal["model_only", "native_graph", "external"] +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class BpmnExecutionProfile: + id: str + version: str + label: str + description: str + conformance: str + runtime_kind: RuntimeKind + executable: bool + supported_elements: tuple[str, ...] + supported_event_definitions: tuple[str, ...] = () + requirements: tuple[str, ...] = () + + +@runtime_checkable +class BpmnExecutionAdapter(Protocol): + profile: BpmnExecutionProfile + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + ... + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + ... + + +class BpmnAdapterError(ValueError): + def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None: + self.diagnostics = diagnostics + first = next( + (item for item in diagnostics if item.severity == "error"), + diagnostics[0] if diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN adapter validation failed." + ) + + +class BpmnExecutionAdapterRegistry: + def __init__(self) -> None: + self._adapters: dict[tuple[str, str], BpmnExecutionAdapter] = {} + + def register(self, adapter: BpmnExecutionAdapter) -> None: + if not isinstance(adapter, BpmnExecutionAdapter): + raise TypeError("BPMN adapter does not implement BpmnExecutionAdapter.") + profile = adapter.profile + if not profile.id.strip() or not profile.version.strip(): + raise ValueError("BPMN adapters require a stable id and version.") + key = (profile.id, profile.version) + if key in self._adapters: + raise ValueError( + f"Duplicate BPMN execution adapter {profile.id}@{profile.version}." + ) + self._adapters[key] = adapter + + def profiles(self) -> tuple[BpmnExecutionProfile, ...]: + return tuple( + adapter.profile + for _key, adapter in sorted( + self._adapters.items(), + key=lambda item: ( + not item[1].profile.executable, + item[1].profile.label.casefold(), + item[1].profile.id, + item[1].profile.version, + ), + ) + ) + + def resolve( + self, + adapter_id: str, + version: str | None = None, + ) -> BpmnExecutionAdapter | None: + if version is not None: + return self._adapters.get((adapter_id, version)) + matches = [ + adapter + for (candidate_id, _candidate_version), adapter in self._adapters.items() + if candidate_id == adapter_id + ] + return max(matches, key=lambda item: item.profile.version) if matches else None + + def require( + self, + adapter_id: str, + version: str | None = None, + ) -> BpmnExecutionAdapter: + adapter = self.resolve(adapter_id, version) + if adapter is None: + suffix = f"@{version}" if version else "" + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.unavailable", + message=( + f"BPMN execution adapter {adapter_id}{suffix} is not " + "installed." + ), + ), + ) + ) + return adapter + + +class InterchangeOnlyAdapter: + profile = BpmnExecutionProfile( + id=INTERCHANGE_ADAPTER_ID, + version="1.0.0", + label="Historical model-only revision", + description=( + "Read historical BPMN 2.0 revisions that were stored without a " + "native graph or executable semantics." + ), + conformance="BPMN 2.0 interchange", + runtime_kind="model_only", + executable=False, + supported_elements=("*",), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + del xml, inspection + if not activation: + return () + return ( + BpmnDiagnostic( + severity="error", + code="adapter.model_only", + message=( + "This BPMN revision is model-only. Select an executable " + "conformance profile and save a new revision before activation." + ), + ), + ) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + del xml, inspection + return None + + +class NativeBpmnGraphAdapter: + profile = BpmnExecutionProfile( + id=NATIVE_BPMN_ADAPTER_ID, + version=NATIVE_BPMN_ADAPTER_VERSION, + label="GovOPlaN native BPMN", + description=( + "Use BPMN 2.0 as the canonical GovOPlaN graph language. The " + "native graph preserves standard notation while activation " + "fails closed for runtime semantics that are not implemented." + ), + conformance="BPMN 2.0 native graph profile 1", + runtime_kind="native_graph", + executable=True, + supported_elements=tuple( + sorted( + { + "definitions", + "process", + "collaboration", + "choreography", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "conditionExpression", + "laneSet", + "flowNodeRef", + *BPMN_NODE_LOCAL_NAMES, + *BPMN_EDGE_LOCAL_NAMES, + } + ) + ), + supported_event_definitions=( + "message", + "timer", + "conditional", + "signal", + "error", + "escalation", + "compensation", + "link", + "cancel", + "terminate", + ), + requirements=( + "BPMN is stored as the canonical native graph", + "Imported XML is parsed with bounded, entity-safe inspection", + "Unsupported execution semantics remain editable but block activation", + ), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + diagnostics = [ + item for item in inspection.diagnostics if item.severity == "error" + ] + try: + graph = import_bpmn_graph(xml) + except BpmnGraphError as exc: + diagnostics.extend(exc.diagnostics) + return _deduplicate_diagnostics(diagnostics) + if activation: + diagnostics.extend(runtime_diagnostics(graph)) + return _deduplicate_diagnostics(diagnostics) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + del inspection + return import_bpmn_graph(xml) + + +class NativeLinearAdapter: + _supported_elements = frozenset( + { + "definitions", + "process", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "startEvent", + "endEvent", + "task", + "manualTask", + "userTask", + "sequenceFlow", + } + ) + _node_types = frozenset( + {"startEvent", "endEvent", "task", "manualTask", "userTask"} + ) + + profile = BpmnExecutionProfile( + id=NATIVE_LINEAR_ADAPTER_ID, + version="1.0.0", + label="GovOPlaN native linear", + description=( + "Execute a single linear process containing a plain start event, " + "human tasks, and one or more plain end events." + ), + conformance="GovOPlaN native linear BPMN profile 1", + runtime_kind="native_graph", + executable=True, + supported_elements=tuple(sorted(_supported_elements)), + requirements=( + "Exactly one executable process", + "Exactly one plain start event", + "No gateways, subprocesses, boundary events, or event definitions", + "Every activity has one incoming and one outgoing sequence flow", + ), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + del activation + diagnostics = list( + item + for item in inspection.diagnostics + if item.severity == "error" + ) + unsupported = [ + item + for item in inspection.elements + if item.element_type not in self._supported_elements + ] + for item in unsupported: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.unsupported_element", + message=( + f"{item.element_type} is not supported by the " + f"{self.profile.label} profile." + ), + element_id=item.element_id, + ) + ) + if inspection.process_count != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.process_count", + message="The native linear profile requires exactly one process.", + ) + ) + if inspection.executable_process_count != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.executable_process", + message=( + "The native linear profile requires one process with " + "isExecutable=\"true\"." + ), + ) + ) + if diagnostics: + return _deduplicate_diagnostics(diagnostics) + + root = parse_bpmn_xml(xml) + process = next( + ( + item + for item in root + if _qualified_name(item.tag) + == (BPMN_MODEL_NAMESPACE, "process") + ), + None, + ) + if process is None: + return ( + BpmnDiagnostic( + severity="error", + code="adapter.process_missing", + message="The executable BPMN process is missing.", + ), + ) + node_ids = { + item.attrib.get("id", "") + for item in process + if _qualified_name(item.tag)[1] in self._node_types + and item.attrib.get("id") + } + starts = [ + item + for item in process + if _qualified_name(item.tag)[1] == "startEvent" + ] + if len(starts) != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.start_count", + message="The native linear profile requires exactly one start event.", + ) + ) + incoming: Counter[str] = Counter() + outgoing: Counter[str] = Counter() + for item in process: + if _qualified_name(item.tag)[1] != "sequenceFlow": + continue + source = item.attrib.get("sourceRef", "") + target = item.attrib.get("targetRef", "") + if source in node_ids: + outgoing[source] += 1 + if target in node_ids: + incoming[target] += 1 + for item in process: + _namespace, element_type = _qualified_name(item.tag) + if element_type not in self._node_types: + continue + element_id = item.attrib.get("id") + if not element_id: + continue + if element_type != "startEvent" and incoming[element_id] != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.incoming_count", + message=( + f"{element_type} requires exactly one incoming " + "sequence flow in the native linear profile." + ), + element_id=element_id, + ) + ) + if element_type != "endEvent" and outgoing[element_id] != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.outgoing_count", + message=( + f"{element_type} requires exactly one outgoing " + "sequence flow in the native linear profile." + ), + element_id=element_id, + ) + ) + return _deduplicate_diagnostics(diagnostics) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + diagnostics = self.diagnostics(xml, inspection, activation=True) + if any(item.severity == "error" for item in diagnostics): + raise BpmnAdapterError(diagnostics) + root = parse_bpmn_xml(xml) + process = next( + item + for item in root + if _qualified_name(item.tag) == (BPMN_MODEL_NAMESPACE, "process") + ) + positions = _diagram_positions(root) + raw_nodes = [ + item + for item in process + if _qualified_name(item.tag)[1] in self._node_types + ] + id_map = { + item.attrib["id"]: _graph_id(item.attrib["id"]) + for item in raw_nodes + } + fallback_positions = { + item.attrib["id"]: WorkflowPosition(x=80 + index * 240, y=140) + for index, item in enumerate(_linear_node_order(process, raw_nodes)) + } + nodes: list[WorkflowNode] = [] + for item in raw_nodes: + element_type = _qualified_name(item.tag)[1] + bpmn_id = item.attrib["id"] + label = item.attrib.get("name", "").strip() + if element_type == "startEvent": + node_type = "workflow.start.manual" + config = {"input_schema_ref": ""} + label = label or "Start" + elif element_type == "endEvent": + node_type = "workflow.end.completed" + config = {"output_mapping": {}} + label = label or "Completed" + else: + node_type = "workflow.activity" + label = label or "Activity" + config = { + "title": label, + "instructions": "", + "assignee": "", + "due_after": "", + } + nodes.append( + WorkflowNode( + id=id_map[bpmn_id], + type=node_type, + label=label, + position=positions.get(bpmn_id, fallback_positions[bpmn_id]), + config=config, + ) + ) + edges = [ + WorkflowEdge( + id=_graph_id(item.attrib["id"]), + source=id_map[item.attrib["sourceRef"]], + target=id_map[item.attrib["targetRef"]], + ) + for item in process + if _qualified_name(item.tag)[1] == "sequenceFlow" + ] + return WorkflowGraph(nodes=nodes, edges=edges) + + +def assess_bpmn_adapter( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool = False, + registry: BpmnExecutionAdapterRegistry | None = None, +) -> tuple[ + BpmnExecutionAdapter, + BpmnInspection, + tuple[BpmnDiagnostic, ...], +]: + active_registry = registry or bpmn_adapter_registry() + adapter = active_registry.require(adapter_id, adapter_version) + inspection = inspect_bpmn_xml(xml) + diagnostics = adapter.diagnostics( + xml, + inspection, + activation=activation, + ) + return adapter, inspection, diagnostics + + +def compile_bpmn_to_graph( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool = False, + registry: BpmnExecutionAdapterRegistry | None = None, +) -> tuple[BpmnExecutionAdapter, BpmnInspection, WorkflowGraph | None]: + adapter, inspection, diagnostics = assess_bpmn_adapter( + xml, + adapter_id=adapter_id, + adapter_version=adapter_version, + activation=activation, + registry=registry, + ) + if any(item.severity == "error" for item in diagnostics): + raise BpmnAdapterError(diagnostics) + graph = adapter.compile(xml, inspection) + if adapter.profile.executable and graph is None: + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.no_runtime_materialization", + message=( + f"{adapter.profile.label} did not produce executable " + "Workflow runtime materialization." + ), + ), + ) + ) + return adapter, inspection, graph + + +_registry: BpmnExecutionAdapterRegistry | None = None +_registry_lock = Lock() + + +def bpmn_adapter_registry() -> BpmnExecutionAdapterRegistry: + global _registry + if _registry is not None: + return _registry + with _registry_lock: + if _registry is not None: + return _registry + registry = BpmnExecutionAdapterRegistry() + registry.register(NativeBpmnGraphAdapter()) + registry.register(NativeLinearAdapter()) + registry.register(InterchangeOnlyAdapter()) + for entry_point in entry_points(group=BPMN_ADAPTER_ENTRY_POINT_GROUP): + try: + loaded = entry_point.load() + candidate = ( + loaded() + if callable(loaded) and not hasattr(loaded, "profile") + else loaded + ) + registry.register(candidate) + except Exception: + logger.exception( + "Could not register BPMN execution adapter %s", + entry_point.name, + ) + _registry = registry + return registry + + +def _diagram_positions(root: Element) -> dict[str, WorkflowPosition]: + positions: dict[str, WorkflowPosition] = {} + for item in root.iter(): + if _qualified_name(item.tag) != (BPMN_DI_NAMESPACE, "BPMNShape"): + continue + element_id = item.attrib.get("bpmnElement") + if not element_id: + continue + bounds = next( + ( + child + for child in item + if _qualified_name(child.tag) == (OMG_DC_NAMESPACE, "Bounds") + ), + None, + ) + if bounds is None: + continue + try: + positions[element_id] = WorkflowPosition( + x=float(bounds.attrib.get("x", "0")), + y=float(bounds.attrib.get("y", "0")), + ) + except ValueError: + continue + return positions + + +def _linear_node_order( + process: Element, + nodes: list[Element], +) -> list[Element]: + by_id = {item.attrib["id"]: item for item in nodes} + target_by_source = { + item.attrib.get("sourceRef"): item.attrib.get("targetRef") + for item in process + if _qualified_name(item.tag)[1] == "sequenceFlow" + } + start = next( + ( + item + for item in nodes + if _qualified_name(item.tag)[1] == "startEvent" + ), + None, + ) + ordered: list[Element] = [] + seen: set[str] = set() + current = start + while current is not None: + current_id = current.attrib["id"] + if current_id in seen: + break + seen.add(current_id) + ordered.append(current) + current = by_id.get(target_by_source.get(current_id, "")) + ordered.extend(item for item in nodes if item.attrib["id"] not in seen) + return ordered + + +def _graph_id(value: str) -> str: + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + return f"{value[:103]}-{digest}" + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _deduplicate_diagnostics( + diagnostics: list[BpmnDiagnostic], +) -> tuple[BpmnDiagnostic, ...]: + seen: set[tuple[str, str | None, str]] = set() + result: list[BpmnDiagnostic] = [] + for item in diagnostics: + key = (item.code, item.element_id, item.message) + if key in seen: + continue + seen.add(key) + result.append(item) + return tuple(result) + + +__all__ = [ + "BPMN_ADAPTER_ENTRY_POINT_GROUP", + "INTERCHANGE_ADAPTER_ID", + "NATIVE_BPMN_ADAPTER_ID", + "NATIVE_LINEAR_ADAPTER_ID", + "BpmnAdapterError", + "BpmnExecutionAdapter", + "BpmnExecutionAdapterRegistry", + "BpmnExecutionProfile", + "RuntimeKind", + "assess_bpmn_adapter", + "bpmn_adapter_registry", + "compile_bpmn_to_graph", +] diff --git a/src/govoplan_workflow_engine/backend/bpmn_graph.py b/src/govoplan_workflow_engine/backend/bpmn_graph.py new file mode 100644 index 0000000..0fa19d8 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/bpmn_graph.py @@ -0,0 +1,1741 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections import defaultdict +from collections.abc import Iterable +from copy import deepcopy +from dataclasses import dataclass +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from govoplan_workflow_engine.backend.bpmn import ( + BPMN_DI_NAMESPACE, + BPMN_MODEL_NAMESPACE, + OMG_DC_NAMESPACE, + OMG_DI_NAMESPACE, + BpmnDiagnostic, + inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow_engine.backend.schemas import ( + WorkflowEdge, + WorkflowGraph, + WorkflowNode, + WorkflowPosition, + WorkflowSize, + WorkflowWaypoint, +) + + +NATIVE_BPMN_ADAPTER_ID = "govoplan.native.bpmn" +NATIVE_BPMN_ADAPTER_VERSION = "1.0.0" +GOVOPLAN_EXTENSION_NAMESPACE = "https://govoplan.add-ideas.de/ns/workflow" +XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" + +BPMN_NODE_LOCAL_NAMES = frozenset( + { + "startEvent", + "intermediateCatchEvent", + "intermediateThrowEvent", + "boundaryEvent", + "endEvent", + "task", + "userTask", + "manualTask", + "serviceTask", + "scriptTask", + "businessRuleTask", + "sendTask", + "receiveTask", + "callActivity", + "subProcess", + "transaction", + "adHocSubProcess", + "exclusiveGateway", + "parallelGateway", + "inclusiveGateway", + "eventBasedGateway", + "complexGateway", + "dataObjectReference", + "dataStoreReference", + "participant", + "lane", + "textAnnotation", + "group", + "choreographyTask", + "callChoreography", + "subChoreography", + "conversation", + "callConversation", + "subConversation", + } +) +BPMN_EDGE_LOCAL_NAMES = frozenset( + { + "sequenceFlow", + "messageFlow", + "association", + "dataInputAssociation", + "dataOutputAssociation", + "conversationLink", + } +) +BPMN_EVENT_DEFINITION_LOCAL_NAMES = frozenset( + { + "messageEventDefinition", + "timerEventDefinition", + "conditionalEventDefinition", + "signalEventDefinition", + "errorEventDefinition", + "escalationEventDefinition", + "compensateEventDefinition", + "linkEventDefinition", + "cancelEventDefinition", + "terminateEventDefinition", + } +) +_EVENT_DEFINITION_NAMES = { + "messageEventDefinition": "message", + "timerEventDefinition": "timer", + "conditionalEventDefinition": "conditional", + "signalEventDefinition": "signal", + "errorEventDefinition": "error", + "escalationEventDefinition": "escalation", + "compensateEventDefinition": "compensation", + "linkEventDefinition": "link", + "cancelEventDefinition": "cancel", + "terminateEventDefinition": "terminate", +} +_EVENT_DEFINITION_ELEMENTS = { + value: key for key, value in _EVENT_DEFINITION_NAMES.items() +} +_DEFAULT_NODE_SIZE = { + "startEvent": (36.0, 36.0), + "intermediateCatchEvent": (36.0, 36.0), + "intermediateThrowEvent": (36.0, 36.0), + "boundaryEvent": (36.0, 36.0), + "endEvent": (36.0, 36.0), + "exclusiveGateway": (50.0, 50.0), + "parallelGateway": (50.0, 50.0), + "inclusiveGateway": (50.0, 50.0), + "eventBasedGateway": (50.0, 50.0), + "complexGateway": (50.0, 50.0), + "participant": (600.0, 180.0), + "lane": (560.0, 140.0), + "textAnnotation": (120.0, 60.0), + "group": (300.0, 180.0), + "dataObjectReference": (36.0, 50.0), + "dataStoreReference": (50.0, 50.0), +} +_MODEL_ATTRIBUTES_EXCLUDED = { + "id", + "name", + "sourceRef", + "targetRef", + "default", + "attachedToRef", + "processRef", +} +_FLOW_NODE_NAMES = BPMN_NODE_LOCAL_NAMES - { + "participant", + "lane", + "textAnnotation", + "group", + "conversation", + "callConversation", + "subConversation", +} +_RUNTIME_NODE_TYPES = frozenset( + { + "bpmn.startEvent", + "bpmn.endEvent", + "bpmn.task", + "bpmn.userTask", + "bpmn.manualTask", + "bpmn.serviceTask", + "bpmn.sendTask", + "bpmn.receiveTask", + "bpmn.intermediateCatchEvent", + "bpmn.exclusiveGateway", + } +) + + +class BpmnGraphError(ValueError): + def __init__(self, diagnostics: Iterable[BpmnDiagnostic]) -> None: + self.diagnostics = tuple(diagnostics) + first = next( + (item for item in self.diagnostics if item.severity == "error"), + self.diagnostics[0] if self.diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN graph conversion failed." + ) + + +@dataclass(frozen=True, slots=True) +class _DiagramShape: + position: WorkflowPosition + size: WorkflowSize + + +def is_native_bpmn_graph(graph: WorkflowGraph) -> bool: + return bool(graph.nodes) and all( + node.type.startswith("bpmn.") for node in graph.nodes + ) + + +def canonical_bpmn_graph(graph: WorkflowGraph) -> WorkflowGraph: + if not graph.nodes or is_native_bpmn_graph(graph): + return graph.model_copy(deep=True) + if any(node.type.startswith("bpmn.") for node in graph.nodes): + raise BpmnGraphError( + ( + BpmnDiagnostic( + severity="error", + code="graph.mixed_notation", + message=( + "A Workflow revision cannot mix legacy workflow nodes " + "with canonical BPMN nodes." + ), + ), + ) + ) + return legacy_graph_to_bpmn(graph) + + +def legacy_graph_to_bpmn(graph: WorkflowGraph) -> WorkflowGraph: + nodes: list[WorkflowNode] = [] + for node in graph.nodes: + config = deepcopy(node.config) + node_type = node.type + if node_type.startswith("workflow.start."): + start_kind = node_type.removeprefix("workflow.start.") + if start_kind == "workflow": + start_kind = "parent_workflow" + config["start_kind"] = start_kind + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.startEvent" + elif node_type == "workflow.activity": + config.setdefault("task_mode", "activity") + bpmn_type = "bpmn.userTask" + elif node_type == "workflow.review": + config["task_mode"] = "review" + bpmn_type = "bpmn.userTask" + elif node_type == "workflow.wait": + config["wait_mode"] = config.pop("mode", "manual") + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.intermediateCatchEvent" + elif node_type == "workflow.capability": + config["implementation"] = "capability" + bpmn_type = "bpmn.serviceTask" + elif node_type == "workflow.dataflow": + config["implementation"] = "dataflow" + bpmn_type = "bpmn.serviceTask" + elif node_type == "workflow.decision": + bpmn_type = "bpmn.exclusiveGateway" + elif node_type == "workflow.end.completed": + config["outcome"] = "completed" + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.endEvent" + elif node_type == "workflow.end.cancelled": + config["outcome"] = "cancelled" + config.setdefault("event_definition", "cancel") + bpmn_type = "bpmn.endEvent" + else: + raise BpmnGraphError( + ( + BpmnDiagnostic( + severity="error", + code="graph.legacy_node_unsupported", + message=f"Legacy node type {node_type!r} cannot be projected to BPMN.", + element_id=node.id, + ), + ) + ) + config.setdefault("documentation", "") + config.setdefault("bpmn_id", node.id) + nodes.append( + WorkflowNode( + id=node.id, + type=bpmn_type, + label=node.label, + position=node.position, + size=node.size or _default_size(bpmn_type.removeprefix("bpmn.")), + parent_id=node.parent_id, + process_id=node.process_id or "Process_1", + config=config, + ) + ) + + edges: list[WorkflowEdge] = [] + by_id = {node.id: node for node in graph.nodes} + for edge in graph.edges: + source_type = by_id.get(edge.source).type if edge.source in by_id else "" + config = deepcopy(edge.config) + if source_type == "workflow.decision": + if edge.source_port == "true": + decision = by_id[edge.source] + config.setdefault("condition", decision.config.get("expression", "")) + elif edge.source_port == "false": + config.setdefault("default", True) + elif edge.source_port not in {"output", "outgoing"}: + config.setdefault("outcome", edge.source_port) + config.setdefault("bpmn_id", edge.id) + edges.append( + WorkflowEdge( + id=edge.id, + type="bpmn.sequenceFlow", + label=edge.label, + source=edge.source, + target=edge.target, + source_port="outgoing", + target_port="incoming", + config=config, + waypoints=edge.waypoints, + ) + ) + + metadata = deepcopy(graph.metadata) + metadata.setdefault( + "bpmn", + { + "definitions_id": "Definitions_1", + "target_namespace": "urn:govoplan:workflow", + "processes": [ + { + "id": "Process_1", + "name": "", + "is_executable": True, + "attributes": {}, + } + ], + "collaborations": [], + "choreographies": [], + "root_elements_xml": [], + }, + ) + metadata["notation"] = "bpmn-2.0" + return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) + + +def import_bpmn_graph(xml: str) -> WorkflowGraph: + inspection = inspect_bpmn_xml(xml) + errors = tuple( + item for item in inspection.diagnostics if item.severity == "error" + ) + if errors: + raise BpmnGraphError(errors) + root = parse_bpmn_xml(xml) + shape_by_id, waypoints_by_id = _diagram_geometry(root) + + original_node_ids = [ + item.attrib["id"] + for item in root.iter() + if _qualified_name(item.tag) + == (BPMN_MODEL_NAMESPACE, _qualified_name(item.tag)[1]) + and _qualified_name(item.tag)[1] in BPMN_NODE_LOCAL_NAMES + and item.attrib.get("id") + ] + id_map = _graph_id_map(original_node_ids) + nodes: list[WorkflowNode] = [] + edge_elements: list[tuple[Element, str | None, str | None]] = [] + processes: list[dict[str, object]] = [] + collaborations: list[dict[str, object]] = [] + choreographies: list[dict[str, object]] = [] + process_elements_xml: dict[str, list[str]] = defaultdict(list) + root_elements_xml: list[str] = [] + + def walk( + parent: Element, + *, + process_id: str | None = None, + parent_node_id: str | None = None, + container_kind: str | None = None, + ) -> None: + for child in parent: + namespace, local_name = _qualified_name(child.tag) + if namespace != BPMN_MODEL_NAMESPACE: + continue + if local_name == "process": + child_process_id = child.attrib.get("id") or f"Process_{len(processes) + 1}" + processes.append(_container_metadata(child, child_process_id)) + walk( + child, + process_id=child_process_id, + container_kind="process", + ) + continue + if local_name == "collaboration": + collaboration_id = child.attrib.get("id") or ( + f"Collaboration_{len(collaborations) + 1}" + ) + collaborations.append(_container_metadata(child, collaboration_id)) + walk(child, container_kind="collaboration") + continue + if local_name == "choreography": + choreography_id = child.attrib.get("id") or ( + f"Choreography_{len(choreographies) + 1}" + ) + choreographies.append(_container_metadata(child, choreography_id)) + walk( + child, + process_id=choreography_id, + container_kind="choreography", + ) + continue + if local_name in BPMN_NODE_LOCAL_NAMES and child.attrib.get("id"): + original_id = child.attrib["id"] + graph_id = id_map[original_id] + effective_process_id = ( + child.attrib.get("processRef") + if local_name == "participant" + else process_id + ) + shape = shape_by_id.get(original_id) + if shape is None: + shape = _fallback_shape(local_name, len(nodes)) + nodes.append( + WorkflowNode( + id=graph_id, + type=f"bpmn.{local_name}", + label=child.attrib.get("name", "").strip(), + position=shape.position, + size=shape.size, + parent_id=parent_node_id, + process_id=effective_process_id, + config=_node_config(child, original_id), + ) + ) + for nested in child: + nested_namespace, nested_name = _qualified_name(nested.tag) + if ( + nested_namespace == BPMN_MODEL_NAMESPACE + and nested_name + in {"dataInputAssociation", "dataOutputAssociation"} + and nested.attrib.get("id") + ): + edge_elements.append((nested, process_id, graph_id)) + if local_name in { + "subProcess", + "transaction", + "adHocSubProcess", + "subChoreography", + "subConversation", + }: + walk( + child, + process_id=process_id, + parent_node_id=graph_id, + container_kind=container_kind, + ) + continue + if local_name in BPMN_EDGE_LOCAL_NAMES and child.attrib.get("id"): + edge_elements.append((child, process_id, parent_node_id)) + continue + if local_name in {"laneSet", "childLaneSet"}: + walk( + child, + process_id=process_id, + parent_node_id=parent_node_id, + container_kind=container_kind, + ) + continue + if local_name in { + "documentation", + "extensionElements", + "incoming", + "outgoing", + }: + continue + if container_kind == "process" and process_id: + process_elements_xml[process_id].append(_serialize_element(child)) + elif parent is root: + root_elements_xml.append(_serialize_element(child)) + + walk(root) + edge_id_map = _graph_id_map( + element.attrib["id"] + for element, _process_id, _parent_node_id in edge_elements + ) + normalized_nodes: list[WorkflowNode] = [] + for node in nodes: + config = deepcopy(node.config) + if node.type == "bpmn.boundaryEvent": + attached_to_ref = str(config.get("attached_to_ref") or "") + if attached_to_ref: + config["attached_to_ref"] = id_map.get( + attached_to_ref, + _graph_id(attached_to_ref), + ) + if node.type == "bpmn.lane": + config["flow_node_refs"] = [ + id_map.get(str(item), _graph_id(str(item))) + for item in config.get("flow_node_refs") or () + ] + default_flow_ref = str(config.get("default_flow_ref") or "") + if default_flow_ref: + config["default_flow_ref"] = edge_id_map.get( + default_flow_ref, + _graph_id(default_flow_ref), + ) + normalized_nodes.append(node.model_copy(update={"config": config})) + nodes = normalized_nodes + node_ids = {node.id for node in nodes} + edges: list[WorkflowEdge] = [] + for element, process_id, parent_node_id in edge_elements: + local_name = _qualified_name(element.tag)[1] + original_id = element.attrib["id"] + source_ref, target_ref = _edge_references(element, local_name) + if local_name == "dataInputAssociation" and parent_node_id: + target_ref = parent_node_id + elif local_name == "dataOutputAssociation" and parent_node_id: + source_ref = parent_node_id + source = id_map.get(source_ref or "", _graph_id(source_ref or "")) + target = id_map.get(target_ref or "", _graph_id(target_ref or "")) + if source not in node_ids or target not in node_ids: + continue + config = _edge_config(element, original_id, process_id, parent_node_id) + edges.append( + WorkflowEdge( + id=edge_id_map[original_id], + type=f"bpmn.{local_name}", + label=element.attrib.get("name", "").strip(), + source=source, + target=target, + source_port="outgoing", + target_port="incoming", + config=config, + waypoints=waypoints_by_id.get(original_id, []), + ) + ) + default_flow_by_source = { + node.id: str(node.config.get("default_flow_ref")) + for node in nodes + if node.config.get("default_flow_ref") + } + edges = [ + edge.model_copy( + update={ + "config": { + **edge.config, + **( + {"default": True} + if default_flow_by_source.get(edge.source) == edge.id + else {} + ), + } + } + ) + for edge in edges + ] + + metadata = { + "notation": "bpmn-2.0", + "bpmn": { + "definitions_id": root.attrib.get("id") or "Definitions_1", + "target_namespace": ( + root.attrib.get("targetNamespace") or "urn:govoplan:workflow" + ), + "definitions_attributes": _extra_attributes( + root, + excluded={"id", "targetNamespace"}, + ), + "extension_elements_xml": _foreign_extension_xml(root), + "processes": processes, + "collaborations": collaborations, + "choreographies": choreographies, + "root_elements_xml": root_elements_xml, + "process_elements_xml": dict(process_elements_xml), + }, + } + return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) + + +def export_bpmn_graph(graph: WorkflowGraph, *, name: str = "") -> str: + canonical = canonical_bpmn_graph(graph) + ElementTree.register_namespace("bpmn", BPMN_MODEL_NAMESPACE) + ElementTree.register_namespace("bpmndi", BPMN_DI_NAMESPACE) + ElementTree.register_namespace("dc", OMG_DC_NAMESPACE) + ElementTree.register_namespace("di", OMG_DI_NAMESPACE) + ElementTree.register_namespace("xsi", XSI_NAMESPACE) + ElementTree.register_namespace("govoplan", GOVOPLAN_EXTENSION_NAMESPACE) + + bpmn_metadata = dict(canonical.metadata.get("bpmn") or {}) + root = Element( + _tag(BPMN_MODEL_NAMESPACE, "definitions"), + { + "id": str(bpmn_metadata.get("definitions_id") or "Definitions_1"), + "targetNamespace": str( + bpmn_metadata.get("target_namespace") + or "urn:govoplan:workflow" + ), + }, + ) + _apply_attributes( + root, + bpmn_metadata.get("definitions_attributes"), + excluded={"id", "targetNamespace"}, + ) + _append_extension_elements( + root, + bpmn_metadata.get("extension_elements_xml"), + ) + _append_preserved_elements(root, bpmn_metadata.get("root_elements_xml")) + + process_metadata = { + str(item.get("id")): dict(item) + for item in bpmn_metadata.get("processes") or () + if isinstance(item, dict) and item.get("id") + } + process_ids = { + node.process_id + for node in canonical.nodes + if node.process_id and node.type != "bpmn.participant" + } + process_ids.update(process_metadata) + choreography_ids = { + str(item.get("id")) + for item in bpmn_metadata.get("choreographies") or () + if isinstance(item, dict) and item.get("id") + } + process_ids.difference_update(choreography_ids) + if not process_ids: + process_ids.add("Process_1") + process_by_id: dict[str, Element] = {} + for process_id in sorted(process_ids): + metadata = process_metadata.get(process_id, {}) + attributes = { + "id": process_id, + "isExecutable": ( + "true" if metadata.get("is_executable", True) else "false" + ), + } + process_name = str(metadata.get("name") or "") + if process_name: + attributes["name"] = process_name + process = Element(_tag(BPMN_MODEL_NAMESPACE, "process"), attributes) + _apply_attributes( + process, + metadata.get("attributes"), + excluded={"id", "name", "isExecutable"}, + ) + _append_extension_elements( + process, + metadata.get("extension_elements_xml"), + ) + root.append(process) + process_by_id[process_id] = process + + collaboration_metadata = [ + dict(item) + for item in bpmn_metadata.get("collaborations") or () + if isinstance(item, dict) + ] + needs_collaboration = any( + node.type == "bpmn.participant" for node in canonical.nodes + ) or any(edge.type == "bpmn.messageFlow" for edge in canonical.edges) + collaboration: Element | None = None + if collaboration_metadata or needs_collaboration: + metadata = collaboration_metadata[0] if collaboration_metadata else {} + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": str(metadata.get("id") or "Collaboration_1")}, + ) + if metadata.get("name"): + collaboration.set("name", str(metadata["name"])) + _apply_attributes( + collaboration, + metadata.get("attributes"), + excluded={"id", "name"}, + ) + _append_extension_elements( + collaboration, + metadata.get("extension_elements_xml"), + ) + root.append(collaboration) + + choreography_metadata = [ + dict(item) + for item in bpmn_metadata.get("choreographies") or () + if isinstance(item, dict) + ] + choreography_by_id: dict[str, Element] = {} + for index, metadata in enumerate(choreography_metadata, start=1): + choreography_id = str(metadata.get("id") or f"Choreography_{index}") + choreography = Element( + _tag(BPMN_MODEL_NAMESPACE, "choreography"), + {"id": choreography_id}, + ) + if metadata.get("name"): + choreography.set("name", str(metadata["name"])) + root.append(choreography) + choreography_by_id[choreography_id] = choreography + + node_xml_ids = _xml_id_map(canonical.nodes, prefix="Node") + edge_xml_ids = _xml_id_map(canonical.edges, prefix="Flow") + default_edge_by_source = { + edge.source: edge.id + for edge in canonical.edges + if edge.type == "bpmn.sequenceFlow" + and edge.config.get("default") is True + } + node_element_by_id: dict[str, Element] = {} + lane_nodes: list[WorkflowNode] = [] + for node in _nodes_parent_first(canonical.nodes): + local_name = node.type.removeprefix("bpmn.") + if local_name == "lane": + lane_nodes.append(node) + continue + parent: Element + if local_name == "participant": + if collaboration is None: + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": "Collaboration_1"}, + ) + root.append(collaboration) + parent = collaboration + elif node.process_id in choreography_by_id: + parent = choreography_by_id[node.process_id] + elif local_name in { + "choreographyTask", + "callChoreography", + "subChoreography", + }: + parent = choreography_by_id.get(node.process_id or "") + if parent is None: + choreography_id = node.process_id or "Choreography_1" + parent = Element( + _tag(BPMN_MODEL_NAMESPACE, "choreography"), + {"id": choreography_id}, + ) + root.append(parent) + choreography_by_id[choreography_id] = parent + elif node.parent_id and node.parent_id in node_element_by_id: + parent = node_element_by_id[node.parent_id] + else: + parent = process_by_id.get(node.process_id or "") + if parent is None: + parent = next(iter(process_by_id.values())) + node_config = deepcopy(node.config) + node_config.pop("default_flow_ref", None) + if node.id in default_edge_by_source: + node_config["default_flow_ref"] = default_edge_by_source[node.id] + export_node = node.model_copy(update={"config": node_config}) + element = _node_element( + export_node, + node_xml_ids[node.id], + edge_xml_ids, + ) + parent.append(element) + node_element_by_id[node.id] = element + + lanes_by_process: dict[str, list[WorkflowNode]] = defaultdict(list) + for lane in lane_nodes: + lanes_by_process[lane.process_id or next(iter(process_by_id))].append(lane) + for process_id, lanes in lanes_by_process.items(): + process = process_by_id.get(process_id) + if process is None: + process = next(iter(process_by_id.values())) + lane_set = Element( + _tag(BPMN_MODEL_NAMESPACE, "laneSet"), + {"id": _xml_id("LaneSet", f"LaneSet_{process_id}")}, + ) + process.insert(0, lane_set) + for lane in lanes: + lane_element = _node_element( + lane, + node_xml_ids[lane.id], + edge_xml_ids, + ) + for flow_node_ref in lane.config.get("flow_node_refs") or (): + target_id = node_xml_ids.get(str(flow_node_ref), str(flow_node_ref)) + ref_element = Element(_tag(BPMN_MODEL_NAMESPACE, "flowNodeRef")) + ref_element.text = target_id + lane_element.append(ref_element) + lane_set.append(lane_element) + node_element_by_id[lane.id] = lane_element + + for edge in canonical.edges: + local_name = edge.type.removeprefix("bpmn.") + element = _edge_element( + edge, + edge_xml_ids[edge.id], + node_xml_ids, + ) + if local_name in {"messageFlow", "conversationLink"}: + if collaboration is None: + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": "Collaboration_1"}, + ) + root.append(collaboration) + collaboration.append(element) + elif local_name == "dataInputAssociation": + node_element_by_id.get(edge.target, next(iter(process_by_id.values()))).append( + element + ) + elif local_name == "dataOutputAssociation": + node_element_by_id.get(edge.source, next(iter(process_by_id.values()))).append( + element + ) + else: + parent_node_id = str(edge.config.get("parent_node_id") or "") + if parent_node_id in node_element_by_id: + node_element_by_id[parent_node_id].append(element) + continue + process_id = _edge_process_id(edge, canonical.nodes) + if process_id in choreography_by_id: + choreography_by_id[process_id].append(element) + else: + process = process_by_id.get(process_id) + if process is None: + process = next(iter(process_by_id.values())) + process.append(element) + + process_extras = bpmn_metadata.get("process_elements_xml") + if isinstance(process_extras, dict): + for process_id, snippets in process_extras.items(): + process = process_by_id.get(str(process_id)) + if process is not None: + _append_preserved_elements(process, snippets) + + diagram = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNDiagram"), + {"id": "BPMNDiagram_1", "name": name or "GovOPlaN Workflow"}, + ) + plane_target = ( + collaboration.attrib["id"] + if collaboration is not None + else next(iter(process_by_id)) + ) + plane = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNPlane"), + {"id": "BPMNPlane_1", "bpmnElement": plane_target}, + ) + diagram.append(plane) + root.append(diagram) + for node in canonical.nodes: + shape = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNShape"), + { + "id": _xml_id("Shape", f"Shape_{node_xml_ids[node.id]}"), + "bpmnElement": node_xml_ids[node.id], + }, + ) + if node.type == "bpmn.participant": + shape.set("isHorizontal", "true") + size = node.size or _default_size(node.type.removeprefix("bpmn.")) + shape.append( + Element( + _tag(OMG_DC_NAMESPACE, "Bounds"), + { + "x": _number(node.position.x), + "y": _number(node.position.y), + "width": _number(size.width), + "height": _number(size.height), + }, + ) + ) + plane.append(shape) + node_by_id = {node.id: node for node in canonical.nodes} + for edge in canonical.edges: + edge_element = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNEdge"), + { + "id": _xml_id("Edge", f"Edge_{edge_xml_ids[edge.id]}"), + "bpmnElement": edge_xml_ids[edge.id], + }, + ) + waypoints = edge.waypoints or _fallback_waypoints( + node_by_id.get(edge.source), + node_by_id.get(edge.target), + ) + for waypoint in waypoints: + edge_element.append( + Element( + _tag(OMG_DI_NAMESPACE, "waypoint"), + {"x": _number(waypoint.x), "y": _number(waypoint.y)}, + ) + ) + plane.append(edge_element) + + ElementTree.indent(root, space=" ") + return ( + '\n' + + ElementTree.tostring(root, encoding="unicode") + ) + + +def materialize_runtime_graph(graph: WorkflowGraph) -> WorkflowGraph: + canonical = canonical_bpmn_graph(graph) + diagnostics: list[BpmnDiagnostic] = [] + runtime_nodes: list[WorkflowNode] = [] + ignored_node_ids = { + node.id + for node in canonical.nodes + if node.type.removeprefix("bpmn.") + in { + "participant", + "lane", + "textAnnotation", + "group", + "dataObjectReference", + "dataStoreReference", + "conversation", + "callConversation", + "subConversation", + } + } + for node in canonical.nodes: + if node.id in ignored_node_ids: + continue + if node.type not in _RUNTIME_NODE_TYPES: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.unsupported_bpmn_element", + message=( + f"{node.type.removeprefix('bpmn.')} is valid BPMN " + "notation but is not executable by the native runtime." + ), + element_id=node.id, + ) + ) + continue + runtime_nodes.append(_runtime_node(node)) + + runtime_node_ids = {node.id for node in runtime_nodes} + runtime_edges: list[WorkflowEdge] = [] + for edge in canonical.edges: + if edge.type != "bpmn.sequenceFlow": + continue + if edge.source not in runtime_node_ids or edge.target not in runtime_node_ids: + continue + source_node = next(node for node in runtime_nodes if node.id == edge.source) + if source_node.type == "workflow.decision": + condition = str(edge.config.get("condition") or "") + if condition: + source_node.config["expression"] = condition + runtime_edges.append( + WorkflowEdge( + id=edge.id, + source=edge.source, + target=edge.target, + source_port=_runtime_source_port(source_node, edge), + target_port="input", + ) + ) + + starts = [node for node in runtime_nodes if node.type.startswith("workflow.start.")] + if len(starts) != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.start_event_count", + message=( + "The current native runtime requires exactly one executable " + "start event per Workflow revision." + ), + ) + ) + if not any(node.type.startswith("workflow.end.") for node in runtime_nodes): + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.end_event_missing", + message="The executable process requires at least one end event.", + ) + ) + for node in runtime_nodes: + if node.type != "workflow.decision": + continue + outgoing = [edge for edge in runtime_edges if edge.source == node.id] + if ( + len(outgoing) != 2 + or sum(edge.source_port == "true" for edge in outgoing) != 1 + or sum(edge.source_port == "false" for edge in outgoing) != 1 + ): + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.exclusive_gateway_shape", + message=( + "The current native runtime supports an exclusive " + "gateway with one conditional and one default flow." + ), + element_id=node.id, + ) + ) + if diagnostics: + raise BpmnGraphError(diagnostics) + return WorkflowGraph(nodes=runtime_nodes, edges=runtime_edges) + + +def runtime_diagnostics(graph: WorkflowGraph) -> tuple[BpmnDiagnostic, ...]: + try: + materialize_runtime_graph(graph) + except BpmnGraphError as exc: + return exc.diagnostics + return () + + +def _runtime_node(node: WorkflowNode) -> WorkflowNode: + config = deepcopy(node.config) + if node.type == "bpmn.startEvent": + start_kind = str(config.get("start_kind") or "manual") + if start_kind == "parent_workflow": + start_kind = "workflow" + node_type = f"workflow.start.{start_kind}" + elif node.type == "bpmn.endEvent": + outcome = str(config.get("outcome") or "completed") + node_type = ( + "workflow.end.cancelled" + if outcome == "cancelled" + else "workflow.end.completed" + ) + elif node.type == "bpmn.userTask" and config.get("task_mode") == "review": + node_type = "workflow.review" + elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}: + node_type = "workflow.activity" + elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}: + node_type = "workflow.wait" + config["mode"] = config.get("wait_mode") or "event" + elif node.type in {"bpmn.serviceTask", "bpmn.sendTask"}: + implementation = str(config.get("implementation") or "capability") + node_type = ( + "workflow.dataflow" + if implementation == "dataflow" + else "workflow.capability" + ) + elif node.type == "bpmn.exclusiveGateway": + node_type = "workflow.decision" + config.setdefault("expression", "") + else: + raise AssertionError(f"Unsupported runtime node {node.type}") + return WorkflowNode( + id=node.id, + type=node_type, + label=node.label, + position=node.position, + config=config, + ) + + +def _runtime_source_port(node: WorkflowNode, edge: WorkflowEdge) -> str: + configured = str(edge.config.get("outcome") or "") + if configured: + return configured + if node.type in {"workflow.capability", "workflow.dataflow"}: + return "success" + if node.type == "workflow.review": + return "approved" + if node.type == "workflow.wait": + return "resumed" + if node.type == "workflow.decision": + return "false" if edge.config.get("default") is True else "true" + return "output" + + +def _node_config(element: Element, original_id: str) -> dict[str, object]: + local_name = _qualified_name(element.tag)[1] + config: dict[str, object] = { + "bpmn_id": original_id, + "bpmn_attributes": _extra_attributes( + element, + excluded=_MODEL_ATTRIBUTES_EXCLUDED, + ), + "documentation": _documentation(element), + } + extension_xml = _foreign_extension_xml(element) + if extension_xml: + config["bpmn_extension_elements_xml"] = extension_xml + event_definition = next( + ( + child + for child in element + if _qualified_name(child.tag)[0] == BPMN_MODEL_NAMESPACE + and _qualified_name(child.tag)[1] in BPMN_EVENT_DEFINITION_LOCAL_NAMES + ), + None, + ) + if event_definition is not None: + event_name = _qualified_name(event_definition.tag)[1] + config["event_definition"] = _EVENT_DEFINITION_NAMES.get( + event_name, + event_name.removesuffix("EventDefinition"), + ) + config["event_definition_xml"] = _serialize_element(event_definition) + elif local_name.endswith("Event"): + config["event_definition"] = "none" + if local_name == "startEvent": + config["start_kind"] = _start_kind(element, config) + elif local_name == "boundaryEvent": + config["attached_to_ref"] = element.attrib.get("attachedToRef", "") + config["cancel_activity"] = element.attrib.get("cancelActivity", "true") + elif local_name == "participant": + config["process_ref"] = element.attrib.get("processRef", "") + elif local_name == "lane": + config["flow_node_refs"] = [ + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "flowNodeRef") + and (child.text or "").strip() + ] + elif local_name == "textAnnotation": + text_element = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "text") + ), + None, + ) + config["text"] = (text_element.text or "") if text_element is not None else "" + config["text_format"] = element.attrib.get("textFormat", "text/plain") + elif local_name == "dataObjectReference": + config["data_object_ref"] = element.attrib.get("dataObjectRef", "") + elif local_name == "dataStoreReference": + config["data_store_ref"] = element.attrib.get("dataStoreRef", "") + elif local_name == "callActivity": + config["called_element"] = element.attrib.get("calledElement", "") + if element.attrib.get("default"): + config["default_flow_ref"] = element.attrib["default"] + if local_name in { + "task", + "userTask", + "manualTask", + "serviceTask", + "scriptTask", + "businessRuleTask", + "sendTask", + "receiveTask", + }: + config.setdefault("title", element.attrib.get("name", "").strip()) + config.setdefault("instructions", "") + govoplan_extension = next( + ( + child + for extension in element + if _qualified_name(extension.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + for child in extension + if _qualified_name(child.tag)[0] == GOVOPLAN_EXTENSION_NAMESPACE + and _qualified_name(child.tag)[1] == "config" + ), + None, + ) + if govoplan_extension is not None: + for item in govoplan_extension: + if _qualified_name(item.tag)[0] != GOVOPLAN_EXTENSION_NAMESPACE: + continue + key = item.attrib.get("key") + if key: + raw_value = item.text or "" + if item.attrib.get("format") == "json": + try: + config[key] = json.loads(raw_value) + except (TypeError, ValueError): + config[key] = raw_value + else: + config[key] = raw_value + preserved = [ + _serialize_element(child) + for child in element + if not _known_node_child(child) + ] + if preserved: + config["bpmn_child_elements_xml"] = preserved + return config + + +def _edge_config( + element: Element, + original_id: str, + process_id: str | None, + parent_node_id: str | None, +) -> dict[str, object]: + config: dict[str, object] = { + "bpmn_id": original_id, + "process_id": process_id or "", + "parent_node_id": parent_node_id or "", + "bpmn_attributes": _extra_attributes( + element, + excluded=_MODEL_ATTRIBUTES_EXCLUDED, + ), + } + local_name = _qualified_name(element.tag)[1] + if local_name == "sequenceFlow": + condition = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "conditionExpression") + ), + None, + ) + if condition is not None: + config["condition"] = condition.text or "" + config["condition_attributes"] = dict(condition.attrib) + return config + + +def _node_element( + node: WorkflowNode, + xml_id: str, + edge_xml_ids: dict[str, str], +) -> Element: + local_name = node.type.removeprefix("bpmn.") + attributes = {"id": xml_id} + if node.label: + attributes["name"] = node.label + if local_name == "boundaryEvent": + attached = str(node.config.get("attached_to_ref") or "") + if attached: + attributes["attachedToRef"] = attached + attributes["cancelActivity"] = str( + node.config.get("cancel_activity") or "true" + ).lower() + elif local_name == "participant": + process_ref = str(node.config.get("process_ref") or node.process_id or "") + if process_ref: + attributes["processRef"] = process_ref + elif local_name == "textAnnotation": + attributes["textFormat"] = str( + node.config.get("text_format") or "text/plain" + ) + elif local_name == "dataObjectReference" and node.config.get("data_object_ref"): + attributes["dataObjectRef"] = str(node.config["data_object_ref"]) + elif local_name == "dataStoreReference" and node.config.get("data_store_ref"): + attributes["dataStoreRef"] = str(node.config["data_store_ref"]) + elif local_name == "callActivity" and node.config.get("called_element"): + attributes["calledElement"] = str(node.config["called_element"]) + if node.config.get("default_flow_ref"): + attributes["default"] = edge_xml_ids.get( + str(node.config["default_flow_ref"]), + str(node.config["default_flow_ref"]), + ) + element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) + _apply_attributes( + element, + node.config.get("bpmn_attributes"), + excluded=set(attributes), + ) + documentation = str(node.config.get("documentation") or "") + if documentation: + documentation_element = Element( + _tag(BPMN_MODEL_NAMESPACE, "documentation") + ) + documentation_element.text = documentation + element.append(documentation_element) + if local_name == "textAnnotation": + text = Element(_tag(BPMN_MODEL_NAMESPACE, "text")) + text.text = str(node.config.get("text") or "") + element.append(text) + _append_event_definition(element, node.config) + _append_govoplan_config(element, node.config) + _append_extension_elements( + element, + node.config.get("bpmn_extension_elements_xml"), + ) + _append_preserved_elements( + element, + node.config.get("bpmn_child_elements_xml"), + ) + return element + + +def _edge_element( + edge: WorkflowEdge, + xml_id: str, + node_xml_ids: dict[str, str], +) -> Element: + local_name = edge.type.removeprefix("bpmn.") + attributes = {"id": xml_id} + if edge.label: + attributes["name"] = edge.label + source_ref = node_xml_ids[edge.source] + target_ref = node_xml_ids[edge.target] + if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: + attributes["sourceRef"] = source_ref + attributes["targetRef"] = target_ref + element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) + _apply_attributes( + element, + edge.config.get("bpmn_attributes"), + excluded=set(attributes), + ) + if local_name in {"dataInputAssociation", "dataOutputAssociation"}: + source = Element(_tag(BPMN_MODEL_NAMESPACE, "sourceRef")) + source.text = source_ref + target = Element(_tag(BPMN_MODEL_NAMESPACE, "targetRef")) + target.text = target_ref + element.extend((source, target)) + condition = str(edge.config.get("condition") or "") + if local_name == "sequenceFlow" and condition: + condition_element = Element( + _tag(BPMN_MODEL_NAMESPACE, "conditionExpression"), + {_tag(XSI_NAMESPACE, "type"): "bpmn:tFormalExpression"}, + ) + _apply_attributes( + condition_element, + edge.config.get("condition_attributes"), + excluded={_tag(XSI_NAMESPACE, "type")}, + ) + condition_element.text = condition + element.append(condition_element) + return element + + +def _append_govoplan_config(element: Element, config: dict[str, object]) -> None: + excluded = { + "bpmn_id", + "bpmn_attributes", + "bpmn_child_elements_xml", + "bpmn_extension_elements_xml", + "documentation", + "event_definition", + "event_definition_xml", + "attached_to_ref", + "cancel_activity", + "process_ref", + "flow_node_refs", + "text", + "text_format", + "data_object_ref", + "data_store_ref", + "called_element", + "default_flow_ref", + } + values = { + key: value + for key, value in config.items() + if key not in excluded + and value is not None + and value != "" + } + if not values: + return + extension = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + ), + None, + ) + if extension is None: + extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) + element.append(extension) + root = Element(_tag(GOVOPLAN_EXTENSION_NAMESPACE, "config")) + for key, value in sorted(values.items()): + item = Element( + _tag(GOVOPLAN_EXTENSION_NAMESPACE, "property"), + {"key": key, "format": "json"}, + ) + item.text = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ) + root.append(item) + extension.append(root) + + +def _append_event_definition(element: Element, config: dict[str, object]) -> None: + event_definition = str(config.get("event_definition") or "none") + if event_definition == "none": + return + preserved = config.get("event_definition_xml") + if isinstance(preserved, str): + try: + preserved_element = parse_bpmn_xml( + '{preserved}' + ) + if len(preserved_element): + element.append(deepcopy(preserved_element[0])) + return + except Exception: + pass + local_name = _EVENT_DEFINITION_ELEMENTS.get(event_definition) + if local_name: + element.append(Element(_tag(BPMN_MODEL_NAMESPACE, local_name))) + + +def _append_preserved_elements(parent: Element, snippets: object) -> None: + if not isinstance(snippets, (list, tuple)): + return + for snippet in snippets: + if not isinstance(snippet, str) or not snippet.strip(): + continue + try: + wrapper = parse_bpmn_xml( + '' + f"{snippet}" + ) + except Exception: + continue + for child in wrapper: + parent.append(deepcopy(child)) + + +def _append_extension_elements(parent: Element, snippets: object) -> None: + if not isinstance(snippets, (list, tuple)) or not snippets: + return + extension = next( + ( + child + for child in parent + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + ), + None, + ) + if extension is None: + extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) + parent.insert(0, extension) + wrapper = Element(_tag(BPMN_MODEL_NAMESPACE, "definitions")) + _append_preserved_elements(wrapper, snippets) + for child in wrapper: + extension.append(deepcopy(child)) + + +def _diagram_geometry( + root: Element, +) -> tuple[dict[str, _DiagramShape], dict[str, list[WorkflowWaypoint]]]: + shapes: dict[str, _DiagramShape] = {} + waypoints: dict[str, list[WorkflowWaypoint]] = {} + for item in root.iter(): + namespace, local_name = _qualified_name(item.tag) + if namespace == BPMN_DI_NAMESPACE and local_name == "BPMNShape": + element_id = item.attrib.get("bpmnElement") + bounds = next( + ( + child + for child in item + if _qualified_name(child.tag) + == (OMG_DC_NAMESPACE, "Bounds") + ), + None, + ) + if not element_id or bounds is None: + continue + try: + shapes[element_id] = _DiagramShape( + position=WorkflowPosition( + x=float(bounds.attrib.get("x", "0")), + y=float(bounds.attrib.get("y", "0")), + ), + size=WorkflowSize( + width=float(bounds.attrib.get("width", "100")), + height=float(bounds.attrib.get("height", "80")), + ), + ) + except ValueError: + continue + elif namespace == BPMN_DI_NAMESPACE and local_name == "BPMNEdge": + element_id = item.attrib.get("bpmnElement") + if not element_id: + continue + points: list[WorkflowWaypoint] = [] + for child in item: + if _qualified_name(child.tag) != (OMG_DI_NAMESPACE, "waypoint"): + continue + try: + points.append( + WorkflowWaypoint( + x=float(child.attrib.get("x", "0")), + y=float(child.attrib.get("y", "0")), + ) + ) + except ValueError: + continue + if points: + waypoints[element_id] = points + return shapes, waypoints + + +def _container_metadata(element: Element, fallback_id: str) -> dict[str, object]: + return { + "id": element.attrib.get("id") or fallback_id, + "name": element.attrib.get("name", ""), + "is_executable": element.attrib.get("isExecutable", "false").lower() + == "true", + "attributes": _extra_attributes( + element, + excluded={"id", "name", "isExecutable"}, + ), + "documentation": _documentation(element), + "extension_elements_xml": _foreign_extension_xml(element), + } + + +def _extra_attributes( + element: Element, + *, + excluded: set[str], +) -> dict[str, str]: + return { + key: value + for key, value in element.attrib.items() + if key not in excluded + } + + +def _apply_attributes( + element: Element, + values: object, + *, + excluded: set[str], +) -> None: + if not isinstance(values, dict): + return + for key, value in values.items(): + if str(key) not in excluded and value is not None: + element.set(str(key), str(value)) + + +def _documentation(element: Element) -> str: + return "\n".join( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "documentation") + and (child.text or "").strip() + ) + + +def _known_node_child(element: Element) -> bool: + namespace, local_name = _qualified_name(element.tag) + return namespace == BPMN_MODEL_NAMESPACE and ( + local_name + in { + "documentation", + "extensionElements", + "incoming", + "outgoing", + "flowNodeRef", + "text", + *BPMN_EDGE_LOCAL_NAMES, + *BPMN_NODE_LOCAL_NAMES, + } + or local_name in BPMN_EVENT_DEFINITION_LOCAL_NAMES + ) + + +def _foreign_extension_xml(element: Element) -> list[str]: + result: list[str] = [] + for extension in element: + if _qualified_name(extension.tag) != ( + BPMN_MODEL_NAMESPACE, + "extensionElements", + ): + continue + for child in extension: + if _qualified_name(child.tag) == ( + GOVOPLAN_EXTENSION_NAMESPACE, + "config", + ): + continue + result.append(_serialize_element(child)) + return result + + +def _start_kind(element: Element, config: dict[str, object]) -> str: + event_definition = str(config.get("event_definition") or "none") + if event_definition == "timer": + return "schedule" + if event_definition in {"message", "signal", "conditional"}: + return "event" + for child in element: + if _qualified_name(child.tag) != ( + BPMN_MODEL_NAMESPACE, + "extensionElements", + ): + continue + for extension in child.iter(): + if _qualified_name(extension.tag) == ( + GOVOPLAN_EXTENSION_NAMESPACE, + "property", + ) and extension.attrib.get("key") == "start_kind": + return (extension.text or "manual").strip() + return "manual" + + +def _edge_references( + element: Element, + local_name: str, +) -> tuple[str | None, str | None]: + if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: + return element.attrib.get("sourceRef"), element.attrib.get("targetRef") + source_ref = next( + ( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "sourceRef") + and (child.text or "").strip() + ), + None, + ) + target_ref = next( + ( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "targetRef") + and (child.text or "").strip() + ), + None, + ) + return source_ref, target_ref + + +def _edge_process_id(edge: WorkflowEdge, nodes: list[WorkflowNode]) -> str: + configured = str(edge.config.get("process_id") or "") + if configured: + return configured + source = next((node for node in nodes if node.id == edge.source), None) + target = next((node for node in nodes if node.id == edge.target), None) + return (source and source.process_id) or (target and target.process_id) or "Process_1" + + +def _nodes_parent_first(nodes: list[WorkflowNode]) -> list[WorkflowNode]: + remaining = list(nodes) + ordered: list[WorkflowNode] = [] + emitted: set[str] = set() + while remaining: + progressed = False + for node in list(remaining): + if not node.parent_id or node.parent_id in emitted: + ordered.append(node) + emitted.add(node.id) + remaining.remove(node) + progressed = True + if not progressed: + ordered.extend(remaining) + break + return ordered + + +def _fallback_shape(local_name: str, index: int) -> _DiagramShape: + size = _default_size(local_name) + return _DiagramShape( + position=WorkflowPosition( + x=80 + (index % 5) * 190, + y=100 + (index // 5) * 140, + ), + size=size, + ) + + +def _default_size(local_name: str) -> WorkflowSize: + width, height = _DEFAULT_NODE_SIZE.get(local_name, (120.0, 80.0)) + return WorkflowSize(width=width, height=height) + + +def _fallback_waypoints( + source: WorkflowNode | None, + target: WorkflowNode | None, +) -> list[WorkflowWaypoint]: + if source is None or target is None: + return [] + source_size = source.size or _default_size( + source.type.removeprefix("bpmn.") + ) + target_size = target.size or _default_size( + target.type.removeprefix("bpmn.") + ) + return [ + WorkflowWaypoint( + x=source.position.x + source_size.width, + y=source.position.y + source_size.height / 2, + ), + WorkflowWaypoint( + x=target.position.x, + y=target.position.y + target_size.height / 2, + ), + ] + + +def _graph_id_map(values: Iterable[str]) -> dict[str, str]: + return {value: _graph_id(value) for value in values} + + +def _graph_id(value: str) -> str: + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + return f"{value[:103]}-{digest}" + + +def _xml_id_map( + values: Iterable[WorkflowNode] | Iterable[WorkflowEdge], + *, + prefix: str, +) -> dict[str, str]: + result: dict[str, str] = {} + used: set[str] = set() + for item in values: + configured = str(item.config.get("bpmn_id") or item.id) + candidate = _xml_id(prefix, configured) + if candidate in used: + candidate = _xml_id( + prefix, + f"{candidate}_{hashlib.sha256(item.id.encode()).hexdigest()[:8]}", + ) + used.add(candidate) + result[item.id] = candidate + return result + + +def _xml_id(prefix: str, value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", value.strip()) + if not cleaned or not re.match(r"[A-Za-z_]", cleaned): + cleaned = f"{prefix}_{cleaned}" + return cleaned + + +def _serialize_element(element: Element) -> str: + return ElementTree.tostring(element, encoding="unicode") + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _tag(namespace: str, local_name: str) -> str: + return f"{{{namespace}}}{local_name}" + + +def _number(value: float) -> str: + return f"{value:.3f}".rstrip("0").rstrip(".") + + +__all__ = [ + "BPMN_EDGE_LOCAL_NAMES", + "BPMN_NODE_LOCAL_NAMES", + "BpmnGraphError", + "NATIVE_BPMN_ADAPTER_ID", + "NATIVE_BPMN_ADAPTER_VERSION", + "canonical_bpmn_graph", + "export_bpmn_graph", + "import_bpmn_graph", + "is_native_bpmn_graph", + "legacy_graph_to_bpmn", + "materialize_runtime_graph", + "runtime_diagnostics", +] diff --git a/src/govoplan_workflow_engine/backend/contributions.py b/src/govoplan_workflow_engine/backend/contributions.py new file mode 100644 index 0000000..436d8ba --- /dev/null +++ b/src/govoplan_workflow_engine/backend/contributions.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, cast + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from govoplan_core.core.workflows import ( + WorkflowDefinitionContribution, + workflow_definition_contribution_hash, +) +from govoplan_core.db.base import utcnow +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, +) +from govoplan_workflow_engine.backend.schemas import ( + BpmnRevisionInput, + WorkflowGraph, +) +from govoplan_workflow_engine.backend.service import ( + WorkflowConflictError, + WorkflowNotFoundError, + _available_key, + _new_revision, + _prepare_revision_content, + _slug, + _validate_bpmn_activation, + _validate_execution_mode_activation, + get_definition, +) + + +def reconcile_workflow_definition_contributions( + session: Session, + *, + registry: object | None, + tenant_ids: Sequence[str] = (), +) -> dict[str, object]: + contributions = _registered_contributions(registry) + normalized_tenant_ids = tuple( + dict.fromkeys(str(item).strip() for item in tenant_ids if str(item).strip()) + ) + result: dict[str, object] = { + "discovered": len(contributions), + "created": 0, + "updated": 0, + "unchanged": 0, + "blocked": 0, + "pending_tenant_scope": 0, + "items": [], + } + items = cast(list[dict[str, object]], result["items"]) + for contribution in contributions: + targets: tuple[str | None, ...] + if contribution.scope_type == "system": + targets = (None,) + elif normalized_tenant_ids: + targets = normalized_tenant_ids + else: + result["pending_tenant_scope"] = ( + int(result["pending_tenant_scope"]) + 1 + ) + items.append( + _item( + contribution, + state="pending_tenant_scope", + message="A tenant-scoped standard needs an explicit tenant target.", + ) + ) + continue + missing = _missing_requirements(contribution, registry) + if missing: + result["blocked"] = int(result["blocked"]) + len(targets) + for tenant_id in targets: + items.append( + _item( + contribution, + tenant_id=tenant_id, + state="blocked", + message="Required Engine integration is unavailable.", + missing=missing, + ) + ) + continue + for tenant_id in targets: + try: + with session.begin_nested(): + state, definition = _reconcile_contribution( + session, + contribution=contribution, + tenant_id=tenant_id, + ) + except (ValueError, WorkflowConflictError) as exc: + result["blocked"] = int(result["blocked"]) + 1 + items.append( + _item( + contribution, + tenant_id=tenant_id, + state="blocked", + message=str(exc), + ) + ) + continue + result[state] = int(result[state]) + 1 + items.append( + _item( + contribution, + tenant_id=tenant_id, + state=state, + definition_id=definition.id, + revision=definition.current_revision, + active_revision=definition.active_revision, + ) + ) + session.flush() + return result + + +def reset_workflow_override_to_standard( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, +) -> WorkflowDefinition: + 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 reset." + ) + 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." + ) + now = utcnow() + override.status = "archived" + override.updated_by = actor_id + override.metadata_ = { + **dict(override.metadata_), + "standard_reset": { + "baseline_definition_id": baseline.id, + "baseline_revision": baseline.current_revision, + "baseline_hash": baseline.standard_contribution_hash, + "reset_by": actor_id, + "reset_at": now.isoformat(), + }, + } + session.flush() + return baseline + + +class SqlWorkflowDefinitionContributionProvider: + def __init__(self, *, registry: object | None = None) -> None: + self._registry = registry + + def reconcile( + self, + session: object, + *, + tenant_ids: Sequence[str] = (), + ) -> Mapping[str, object]: + if not isinstance(session, Session): + raise TypeError("Workflow contribution reconciliation requires a Session.") + return reconcile_workflow_definition_contributions( + session, + registry=self._registry, + tenant_ids=tenant_ids, + ) + + +def _registered_contributions( + registry: object | None, +) -> tuple[WorkflowDefinitionContribution, ...]: + if registry is None or not hasattr(registry, "manifests"): + return () + return tuple( + contribution + for manifest in registry.manifests() + for contribution in manifest.workflow_definitions + ) + + +def _missing_requirements( + contribution: WorkflowDefinitionContribution, + registry: object | None, +) -> tuple[str, ...]: + if registry is None: + return tuple( + [ + *(f"capability:{item}" for item in contribution.required_capabilities), + *(f"interface:{item}" for item in contribution.required_interfaces), + ] + ) + missing = [ + f"capability:{item}" + for item in contribution.required_capabilities + if not ( + hasattr(registry, "has_capability") + and registry.has_capability(item) + ) + ] + available_interfaces = { + item.name + for manifest in registry.manifests() + for item in manifest.provides_interfaces + } + missing.extend( + f"interface:{item}" + for item in contribution.required_interfaces + if item not in available_interfaces + ) + return tuple(missing) + + +def _reconcile_contribution( + session: Session, + *, + contribution: WorkflowDefinitionContribution, + tenant_id: str | None, +) -> tuple[str, WorkflowDefinition]: + scope_key = "system" if tenant_id is None else f"tenant:{tenant_id}" + contribution_hash = ( + contribution.content_hash + or workflow_definition_contribution_hash(contribution) + ) + definition = 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, + ) + ) + graph, bpmn = _prepare_revision_content( + graph=WorkflowGraph.model_validate(contribution.graph), + bpmn=( + BpmnRevisionInput( + xml=contribution.bpmn_xml, + adapter_id=contribution.bpmn_adapter_id, + adapter_version=contribution.bpmn_adapter_version, + ) + if contribution.bpmn_xml + else None + ), + ) + now = utcnow() + if definition is None: + definition = WorkflowDefinition( + tenant_id=tenant_id, + scope_type=contribution.scope_type, + scope_id=tenant_id, + scope_key=scope_key, + definition_kind=contribution.definition_kind, + inherit_to_lower_scopes=contribution.inherit_to_lower_scopes, + allow_start=contribution.allow_start, + allow_reuse=contribution.allow_reuse, + allow_automation=contribution.allow_automation, + standard_origin_module_id=contribution.origin_module_id, + standard_definition_key=contribution.definition_key, + standard_origin_module_version=contribution.origin_module_version, + standard_contribution_schema_version=( + contribution.contribution_schema_version + ), + standard_contribution_hash=contribution_hash, + standard_reconciled_at=now, + definition_key=_available_key( + session, + scope_key=scope_key, + requested=_standard_storage_key(contribution), + name=contribution.name, + ), + name=contribution.name.strip(), + description=_clean_optional(contribution.description), + status="draft", + current_revision=1, + active_revision=None, + metadata_={ + **dict(contribution.metadata), + "workflow_standard": _contribution_metadata(contribution), + }, + created_by=f"module:{contribution.origin_module_id}", + updated_by=f"module:{contribution.origin_module_id}", + ) + revision = _contribution_revision( + contribution, + tenant_id=tenant_id, + revision=1, + graph=graph, + bpmn=bpmn, + contribution_hash=contribution_hash, + ) + _validate_contribution_activation(contribution, revision) + definition.revisions.append(revision) + if contribution.activate_on_install and contribution.definition_kind == "flow": + definition.status = "active" + definition.active_revision = 1 + session.add(definition) + session.flush() + return "created", definition + + if definition.standard_contribution_hash == contribution_hash: + if ( + definition.standard_origin_module_version + != contribution.origin_module_version + or definition.standard_contribution_schema_version + != contribution.contribution_schema_version + ): + definition.standard_origin_module_version = ( + contribution.origin_module_version + ) + definition.standard_contribution_schema_version = ( + contribution.contribution_schema_version + ) + definition.standard_reconciled_at = now + definition.updated_by = f"module:{contribution.origin_module_id}" + session.flush() + return "unchanged", definition + + definition.standard_origin_module_version = contribution.origin_module_version + definition.standard_contribution_schema_version = ( + contribution.contribution_schema_version + ) + definition.standard_reconciled_at = now + definition.updated_by = f"module:{contribution.origin_module_id}" + revision_number = definition.current_revision + 1 + revision = _contribution_revision( + contribution, + tenant_id=tenant_id, + revision=revision_number, + graph=graph, + bpmn=bpmn, + contribution_hash=contribution_hash, + ) + _validate_contribution_activation(contribution, revision) + definition.name = contribution.name.strip() + definition.description = _clean_optional(contribution.description) + definition.definition_kind = contribution.definition_kind + definition.inherit_to_lower_scopes = contribution.inherit_to_lower_scopes + definition.allow_start = contribution.allow_start + definition.allow_reuse = contribution.allow_reuse + definition.allow_automation = contribution.allow_automation + definition.metadata_ = { + **dict(contribution.metadata), + "workflow_standard": _contribution_metadata(contribution), + } + definition.current_revision = revision_number + definition.standard_contribution_hash = contribution_hash + definition.revisions.append(revision) + if ( + definition.active_revision is None + and contribution.activate_on_install + and contribution.definition_kind == "flow" + ): + definition.active_revision = revision_number + definition.status = "active" + session.flush() + return "updated", definition + + +def _contribution_revision( + contribution: WorkflowDefinitionContribution, + *, + tenant_id: str | None, + revision: int, + graph: WorkflowGraph, + bpmn: Any, + contribution_hash: str, +) -> WorkflowDefinitionRevision: + item = _new_revision( + tenant_id=tenant_id, + revision=revision, + graph=graph, + bpmn=bpmn, + execution_mode=contribution.execution_mode, + view_id=contribution.view_id, + view_revision_id=contribution.view_revision_id, + actor_id=f"module:{contribution.origin_module_id}", + ) + item.contribution_origin_module_version = contribution.origin_module_version + item.contribution_schema_version = contribution.contribution_schema_version + item.contribution_hash = contribution_hash + item.contribution_metadata = _contribution_metadata(contribution) + return item + + +def _validate_contribution_activation( + contribution: WorkflowDefinitionContribution, + revision: WorkflowDefinitionRevision, +) -> None: + if not contribution.activate_on_install or contribution.definition_kind == "template": + return + _validate_bpmn_activation(revision) + _validate_execution_mode_activation(revision) + + +def _standard_storage_key( + contribution: WorkflowDefinitionContribution, +) -> str: + base = _slug( + f"{contribution.origin_module_id}-{contribution.definition_key}" + ) + return base[:120] + + +def _contribution_metadata( + contribution: WorkflowDefinitionContribution, +) -> dict[str, object]: + return { + "origin_module_id": contribution.origin_module_id, + "origin_module_version": contribution.origin_module_version, + "definition_key": contribution.definition_key, + "schema_version": contribution.contribution_schema_version, + "required_capabilities": list(contribution.required_capabilities), + "required_interfaces": list(contribution.required_interfaces), + "policy": dict(contribution.policy_metadata), + } + + +def _clean_optional(value: str | None) -> str | None: + cleaned = str(value or "").strip() + return cleaned or None + + +def _item( + contribution: WorkflowDefinitionContribution, + *, + state: str, + tenant_id: str | None = None, + message: str | None = None, + missing: Sequence[str] = (), + definition_id: str | None = None, + revision: int | None = None, + active_revision: int | None = None, +) -> dict[str, object]: + return { + "origin_module_id": contribution.origin_module_id, + "origin_module_version": contribution.origin_module_version, + "definition_key": contribution.definition_key, + "scope_type": contribution.scope_type, + "tenant_id": tenant_id, + "state": state, + "message": message, + "missing": list(missing), + "definition_id": definition_id, + "revision": revision, + "active_revision": active_revision, + } + + +__all__ = [ + "SqlWorkflowDefinitionContributionProvider", + "reconcile_workflow_definition_contributions", + "reset_workflow_override_to_standard", +] diff --git a/src/govoplan_workflow_engine/backend/db/__init__.py b/src/govoplan_workflow_engine/backend/db/__init__.py new file mode 100644 index 0000000..b6ba4fe --- /dev/null +++ b/src/govoplan_workflow_engine/backend/db/__init__.py @@ -0,0 +1,15 @@ +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, + WorkflowInstance, + WorkflowInstanceEvent, + WorkflowInstanceStep, +) + +__all__ = [ + "WorkflowDefinition", + "WorkflowDefinitionRevision", + "WorkflowInstance", + "WorkflowInstanceEvent", + "WorkflowInstanceStep", +] diff --git a/src/govoplan_workflow_engine/backend/db/models.py b/src/govoplan_workflow_engine/backend/db/models.py new file mode 100644 index 0000000..6b6e996 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/db/models.py @@ -0,0 +1,528 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class WorkflowDefinition(Base, TimestampMixin): + __tablename__ = "workflow_definitions" + __table_args__ = ( + UniqueConstraint( + "scope_key", + "definition_key", + name="uq_workflow_definition_key", + ), + Index("ix_workflow_definitions_tenant_status", "tenant_id", "status"), + Index("ix_workflow_definitions_tenant_updated", "tenant_id", "updated_at"), + Index( + "uq_workflow_standard_origin_scope", + "scope_key", + "standard_origin_module_id", + "standard_definition_key", + unique=True, + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + scope_type: Mapped[str] = mapped_column( + String(20), + default="tenant", + nullable=False, + index=True, + ) + scope_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + scope_key: Mapped[str] = mapped_column( + String(80), + nullable=False, + index=True, + ) + definition_kind: Mapped[str] = mapped_column( + String(20), + default="flow", + nullable=False, + index=True, + ) + inherit_to_lower_scopes: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + allow_start: Mapped[bool] = mapped_column( + Boolean, + default=True, + nullable=False, + ) + allow_reuse: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + allow_automation: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + derived_from_definition_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + derived_from_revision: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + ) + derived_from_hash: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + ) + derivation_provenance: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + standard_origin_module_id: Mapped[str | None] = mapped_column( + String(120), + nullable=True, + index=True, + ) + standard_definition_key: Mapped[str | None] = mapped_column( + String(120), + nullable=True, + index=True, + ) + standard_origin_module_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + standard_contribution_schema_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + standard_contribution_hash: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + index=True, + ) + standard_reconciled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + definition_key: Mapped[str] = mapped_column(String(120), nullable=False) + name: Mapped[str] = mapped_column(String(300), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column( + String(32), + default="draft", + nullable=False, + index=True, + ) + current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + active_revision: Mapped[int | None] = mapped_column(Integer, nullable=True) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", + JSON, + default=dict, + nullable=False, + ) + created_by: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + updated_by: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True, + ) + + revisions: Mapped[list["WorkflowDefinitionRevision"]] = relationship( + back_populates="definition", + cascade="all, delete-orphan", + order_by="WorkflowDefinitionRevision.revision", + ) + instances: Mapped[list["WorkflowInstance"]] = relationship( + back_populates="definition", + cascade="all, delete-orphan", + order_by="WorkflowInstance.created_at", + ) + + +class WorkflowDefinitionRevision(Base, TimestampMixin): + __tablename__ = "workflow_definition_revisions" + __table_args__ = ( + UniqueConstraint( + "definition_id", + "revision", + name="uq_workflow_definition_revision", + ), + Index( + "ix_workflow_definition_revisions_tenant_definition", + "tenant_id", + "definition_id", + ), + Index( + "ix_workflow_definition_revisions_content_hash", + "tenant_id", + "content_hash", + ), + Index( + "ix_workflow_definition_revisions_contribution_hash", + "contribution_hash", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + definition_id: Mapped[str] = mapped_column( + ForeignKey("workflow_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + graph: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False) + library_id: Mapped[str] = mapped_column(String(100), nullable=False) + library_version: Mapped[str] = mapped_column(String(40), nullable=False) + execution_mode: Mapped[str] = mapped_column( + String(20), + default="hybrid", + nullable=False, + ) + view_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + view_revision_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + ) + bpmn_xml: Mapped[str | None] = mapped_column(Text, nullable=True) + bpmn_hash: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + index=True, + ) + bpmn_adapter_id: Mapped[str | None] = mapped_column( + String(120), + nullable=True, + ) + bpmn_adapter_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + bpmn_runtime_kind: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + ) + bpmn_executable: Mapped[bool | None] = mapped_column( + Boolean, + nullable=True, + ) + contribution_origin_module_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + contribution_schema_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + contribution_hash: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + ) + contribution_metadata: Mapped[dict[str, Any] | None] = mapped_column( + JSON, + nullable=True, + ) + created_by: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + + definition: Mapped[WorkflowDefinition] = relationship(back_populates="revisions") + + +class WorkflowInstance(Base, TimestampMixin): + __tablename__ = "workflow_instances" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "definition_id", + "idempotency_key", + name="uq_workflow_instance_idempotency", + ), + Index( + "ix_workflow_instances_tenant_status", + "tenant_id", + "status", + ), + Index( + "ix_workflow_instances_reconcile", + "status", + "updated_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + definition_id: Mapped[str] = mapped_column( + ForeignKey("workflow_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + definition_revision_id: Mapped[str] = mapped_column( + ForeignKey("workflow_definition_revisions.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column( + String(30), + default="running", + nullable=False, + index=True, + ) + start_origin: Mapped[str] = mapped_column( + String(30), + default="user", + nullable=False, + index=True, + ) + idempotency_key: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + ) + correlation_id: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + index=True, + ) + current_step_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + input_: Mapped[dict[str, Any]] = mapped_column( + "input", + JSON, + default=dict, + nullable=False, + ) + context_: Mapped[dict[str, Any]] = mapped_column( + "context", + JSON, + default=dict, + nullable=False, + ) + output_: Mapped[dict[str, Any]] = mapped_column( + "output", + JSON, + default=dict, + nullable=False, + ) + authorization_: Mapped[dict[str, Any]] = mapped_column( + "authorization", + JSON, + default=dict, + nullable=False, + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + cancellation_requested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + + definition: Mapped[WorkflowDefinition] = relationship( + back_populates="instances" + ) + steps: Mapped[list["WorkflowInstanceStep"]] = relationship( + back_populates="instance", + cascade="all, delete-orphan", + order_by="WorkflowInstanceStep.sequence", + ) + events: Mapped[list["WorkflowInstanceEvent"]] = relationship( + back_populates="instance", + cascade="all, delete-orphan", + order_by="WorkflowInstanceEvent.sequence", + ) + + +class WorkflowInstanceStep(Base, TimestampMixin): + __tablename__ = "workflow_instance_steps" + __table_args__ = ( + UniqueConstraint( + "instance_id", + "sequence", + name="uq_workflow_instance_step_sequence", + ), + Index( + "ix_workflow_instance_steps_tenant_status", + "tenant_id", + "status", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + instance_id: Mapped[str] = mapped_column( + ForeignKey("workflow_instances.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + node_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + node_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + status: Mapped[str] = mapped_column( + String(30), + nullable=False, + index=True, + ) + attempt: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + idempotency_key: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + input_: Mapped[dict[str, Any]] = mapped_column( + "input", + JSON, + default=dict, + nullable=False, + ) + output_: Mapped[dict[str, Any]] = mapped_column( + "output", + JSON, + default=dict, + nullable=False, + ) + handoff: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + external_ref: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + index=True, + ) + started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_by: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) + + instance: Mapped[WorkflowInstance] = relationship(back_populates="steps") + + +class WorkflowInstanceEvent(Base): + __tablename__ = "workflow_instance_events" + __table_args__ = ( + UniqueConstraint( + "instance_id", + "sequence", + name="uq_workflow_instance_event_sequence", + ), + Index( + "ix_workflow_instance_events_tenant_created", + "tenant_id", + "created_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + instance_id: Mapped[str] = mapped_column( + ForeignKey("workflow_instances.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + step_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + index=True, + ) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + kind: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + actor_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + payload: Mapped[dict[str, Any]] = mapped_column( + JSON, + default=dict, + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + instance: Mapped[WorkflowInstance] = relationship(back_populates="events") + + +__all__ = [ + "WorkflowDefinition", + "WorkflowDefinitionRevision", + "WorkflowInstance", + "WorkflowInstanceEvent", + "WorkflowInstanceStep", + "new_uuid", +] diff --git a/src/govoplan_workflow_engine/backend/governance.py b/src/govoplan_workflow_engine/backend/governance.py new file mode 100644 index 0000000..e4a3492 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/governance.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal + +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.policy import ( + DefinitionGovernanceAction, + DefinitionGovernanceRequest, + DefinitionScopeRef, + PolicyDecision, + PolicySourceStep, + definition_governance_policy, +) +from govoplan_core.core.workflows import workflow_runtime_worker +from govoplan_workflow_engine.backend.db.models import WorkflowDefinition + + +WorkflowAction = Literal[ + "view", + "edit", + "start", + "reuse", + "derive", + "automate", +] +WORKFLOW_ACTIONS: tuple[WorkflowAction, ...] = ( + "view", + "edit", + "start", + "reuse", + "derive", + "automate", +) + + +def normalize_definition_scope( + principal: ApiPrincipal, + *, + scope_type: str, + scope_id: str | None, + administrative: bool, +) -> tuple[str | None, str, str | None, str]: + clean_type = scope_type.strip().casefold() + clean_id = str(scope_id or "").strip() or None + if clean_type == "system": + if not has_scope(principal, "system:governance:write"): + raise PermissionError( + "System definitions require system governance permission." + ) + if clean_id is not None: + raise ValueError("System definitions do not carry a scope ID.") + return None, "system", None, "system" + if clean_type == "tenant": + if clean_id not in {None, principal.tenant_id}: + raise PermissionError( + "Definitions can only be created for the active tenant." + ) + return ( + principal.tenant_id, + "tenant", + principal.tenant_id, + f"tenant:{principal.tenant_id}", + ) + if clean_type == "group": + if not clean_id: + raise ValueError("Group definitions require a group ID.") + if clean_id not in principal.group_ids and not administrative: + raise PermissionError( + "Definitions can only be created for one of the actor's groups." + ) + return principal.tenant_id, "group", clean_id, f"group:{clean_id}" + if clean_type == "user": + clean_id = clean_id or principal.account_id + if ( + clean_id not in {principal.membership_id, principal.account_id} + and not administrative + ): + raise PermissionError( + "Definitions can only be created for the current user." + ) + if clean_id == principal.membership_id: + clean_id = principal.account_id + return principal.tenant_id, "user", clean_id, f"user:{clean_id}" + raise ValueError( + "Definition scope must be system, tenant, group, or user." + ) + + +def definition_decision( + definition: WorkflowDefinition, + *, + principal: ApiPrincipal, + registry: object | None, + action: WorkflowAction, +) -> PolicyDecision: + policy_action: DefinitionGovernanceAction = ( + "run" if action == "start" else action + ) + request = DefinitionGovernanceRequest( + module_id="workflow", + definition_ref=f"workflow-definition:{definition.id}", + tenant_id=principal.tenant_id, + definition_scope=DefinitionScopeRef( + scope_type=definition.scope_type, # type: ignore[arg-type] + scope_id=definition.scope_id, + ), + target_scope=_target_scope(definition, principal), + definition_kind=definition.definition_kind, # type: ignore[arg-type] + action=policy_action, + actor=principal.to_platform_principal(), + status=definition.status, + inherit_to_lower_scopes=definition.inherit_to_lower_scopes, + allow_run=definition.allow_start, + allow_reuse=definition.allow_reuse, + allow_automation=definition.allow_automation, + context=_ancestor_context(definition), + ) + provider = definition_governance_policy(registry) + if provider is not None: + return provider.resolve_definition_action(request=request) + return _tenant_local_fallback(request, displayed_action=action) + + +def definition_governance_payload( + definition: WorkflowDefinition, + *, + principal: ApiPrincipal, + registry: object | None, +) -> dict[str, object]: + runtime_available = workflow_runtime_worker(registry) is not None + actions = { + action: definition_decision( + definition, + principal=principal, + registry=registry, + action=action, + ).to_dict() + for action in WORKFLOW_ACTIONS + } + return { + "scope_type": definition.scope_type, + "scope_id": definition.scope_id, + "definition_kind": definition.definition_kind, + "inherit_to_lower_scopes": definition.inherit_to_lower_scopes, + "allow_start": definition.allow_start, + "allow_reuse": definition.allow_reuse, + "allow_automation": definition.allow_automation, + "derived_from_definition_id": ( + definition.derived_from_definition_id + ), + "derived_from_revision": definition.derived_from_revision, + "derived_from_hash": definition.derived_from_hash, + "derivation_provenance": dict(definition.derivation_provenance), + "actions": actions, + "automation_runtime_available": runtime_available, + "automation_runtime_reason": ( + None + if runtime_available + else "Automatic reconciliation requires the Workflow runtime worker." + ), + } + + +def require_definition_action( + definition: WorkflowDefinition, + *, + principal: ApiPrincipal, + registry: object | None, + action: WorkflowAction, +) -> PolicyDecision: + decision = definition_decision( + definition, + principal=principal, + registry=registry, + action=action, + ) + if not decision.allowed: + raise PermissionError( + decision.reason or f"Definition action is not allowed: {action}" + ) + return decision + + +def _target_scope( + definition: WorkflowDefinition, + principal: ApiPrincipal, +) -> DefinitionScopeRef: + if ( + definition.scope_type == "group" + and definition.scope_id in principal.group_ids + ): + return DefinitionScopeRef("group", definition.scope_id) + if definition.scope_type == "user" and definition.scope_id in { + principal.membership_id, + principal.account_id, + }: + return DefinitionScopeRef("user", definition.scope_id) + return DefinitionScopeRef("tenant", principal.tenant_id) + + +def _ancestor_context( + definition: WorkflowDefinition, +) -> dict[str, object]: + provenance = definition.derivation_provenance + limits = provenance.get("source_effective_limits") + source = provenance.get("source_scope") + context: dict[str, object] = {} + if isinstance(limits, Mapping): + context["ancestor_limits"] = { + "inherit_to_lower_scopes": bool( + limits.get("inherit_to_lower_scopes", True) + ), + "allow_run": bool(limits.get("allow_start")), + "allow_reuse": bool(limits.get("allow_reuse")), + "allow_automation": bool(limits.get("allow_automation")), + } + if isinstance(source, Mapping): + context["ancestor_source"] = dict(source) + return context + + +def _tenant_local_fallback( + request: DefinitionGovernanceRequest, + *, + displayed_action: WorkflowAction, +) -> PolicyDecision: + ancestor = request.context.get("ancestor_limits") + ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {} + effective_limits = { + "inherit_to_lower_scopes": ( + request.inherit_to_lower_scopes + and _fallback_ancestor_flag( + ancestor_limits, + "inherit_to_lower_scopes", + ) + ), + "allow_run": request.allow_run + and _fallback_ancestor_flag(ancestor_limits, "allow_run"), + "allow_reuse": request.allow_reuse + and _fallback_ancestor_flag(ancestor_limits, "allow_reuse"), + "allow_automation": request.allow_automation + and _fallback_ancestor_flag( + ancestor_limits, + "allow_automation", + ), + } + local = ( + request.definition_scope.scope_type == "tenant" + and request.definition_scope.scope_id == request.tenant_id + and request.actor.tenant_id == request.tenant_id + ) + allowed = False + reason: str | None = None + if not local: + reason = ( + "Inherited definitions require the Policy module; only local " + "tenant definitions are available." + ) + elif request.action in {"view", "edit"}: + allowed = True + elif request.action == "run": + allowed = ( + request.definition_kind == "flow" + and request.status == "active" + and effective_limits["allow_run"] + ) + reason = ( + None + if allowed + else "Only active local flows with starting enabled can start." + ) + else: + reason = "Definition reuse and automation require the Policy module." + return PolicyDecision( + allowed=allowed, + reason=reason, + source_path=( + PolicySourceStep( + scope_type=request.definition_scope.scope_type, + scope_id=request.definition_scope.scope_id, + label="Tenant-local conservative fallback", + applied_fields=( + "definition_kind", + "status", + "allow_run", + ), + policy={ + "policy_module_available": False, + "definition_kind": request.definition_kind, + "status": request.status, + "allow_start": request.allow_run, + }, + ), + ), + requirements=( + () + if allowed + else (f"workflow.definition.{displayed_action}",) + ), + details={ + "fallback": "tenant_local", + "action": displayed_action, + "effective_limits": effective_limits, + }, + ) + + +def _fallback_ancestor_flag( + limits: Mapping[str, object], + key: str, +) -> bool: + value = limits.get(key) + return True if value is None else value is True + + +__all__ = [ + "WORKFLOW_ACTIONS", + "definition_decision", + "definition_governance_payload", + "normalize_definition_scope", + "require_definition_action", +] diff --git a/src/govoplan_workflow_engine/backend/instance_service.py b/src/govoplan_workflow_engine/backend/instance_service.py new file mode 100644 index 0000000..0f4ae13 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/instance_service.py @@ -0,0 +1,2389 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +import hashlib +import logging + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.automation import ( + ActionDefinition, + ActionExecutionRequest, + ActionExecutionResult, + ActionPreview, + AutomationInvocation, + AutomationPrincipalRequest, + action_effect_provider, + automation_principal_provider, +) +from govoplan_core.core.dataflows import ( + DataflowPublicationTarget, + DataflowRunDescriptor, + DataflowRunRequest, + dataflow_run_lifecycle, +) +from govoplan_core.core.notifications import ( + NotificationDispatchRequest, + notification_dispatch_provider, +) +from govoplan_core.db.base import utcnow +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, + WorkflowInstance, + WorkflowInstanceEvent, + WorkflowInstanceStep, +) +from govoplan_workflow_engine.backend.governance import require_definition_action +from govoplan_workflow_engine.backend.bpmn_graph import ( + BpmnGraphError, + materialize_runtime_graph, +) +from govoplan_workflow_engine.backend.schemas import ( + WorkflowGraph, + WorkflowInstanceEventResponse, + WorkflowInstanceResponse, + WorkflowInstanceStartRequest, + WorkflowInstanceStepResponse, + WorkflowNode, + WorkflowStepActionRequest, + WorkflowViewContextResponse, +) +from govoplan_workflow_engine.backend.service import ( + WorkflowConflictError, + WorkflowNotFoundError, + get_definition, + get_definition_revision, +) + + +INSTANCE_START_SCOPE = "workflow:instance:start" +INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition" +DATAFLOW_RUN_SCOPE = "dataflow:pipeline:run" +MAX_INSTANCE_TRANSITIONS = 100 +logger = logging.getLogger(__name__) + + +def list_instances( + session: Session, + *, + tenant_id: str, + definition_id: str | None = None, + limit: int = 100, +) -> list[WorkflowInstance]: + statement = ( + select(WorkflowInstance) + .where(WorkflowInstance.tenant_id == tenant_id) + .options( + selectinload(WorkflowInstance.steps), + selectinload(WorkflowInstance.events), + selectinload(WorkflowInstance.definition), + ) + .order_by( + WorkflowInstance.updated_at.desc(), + WorkflowInstance.id.desc(), + ) + .limit(max(1, min(int(limit), 200))) + ) + if definition_id: + statement = statement.where( + WorkflowInstance.definition_id == definition_id + ) + return list(session.scalars(statement)) + + +def get_instance( + session: Session, + *, + tenant_id: str, + instance_id: str, + for_update: bool = False, +) -> WorkflowInstance: + statement = ( + select(WorkflowInstance) + .where( + WorkflowInstance.id == instance_id, + WorkflowInstance.tenant_id == tenant_id, + ) + .options( + selectinload(WorkflowInstance.steps), + selectinload(WorkflowInstance.events), + selectinload(WorkflowInstance.definition), + ) + ) + if for_update: + statement = statement.with_for_update() + instance = session.scalar(statement) + if instance is None: + raise WorkflowNotFoundError("Workflow instance not found.") + return instance + + +def start_instance( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, + principal: ApiPrincipal, + registry: object | None, + payload: WorkflowInstanceStartRequest, + start_origin: str = "user", +) -> tuple[WorkflowInstance, bool]: + definition = get_definition( + session, + tenant_id=tenant_id, + definition_id=definition_id, + ) + if definition.definition_kind == "template": + raise WorkflowConflictError("Workflow templates cannot be started.") + if definition.status != "active" or definition.active_revision is None: + raise WorkflowConflictError( + "Activate a Workflow revision before starting an instance." + ) + try: + require_definition_action( + definition, + principal=principal, + registry=registry, + action="start", + ) + except PermissionError as exc: + raise WorkflowConflictError(str(exc)) from exc + revision = get_definition_revision( + session, + definition=definition, + revision=definition.active_revision, + ) + normalized_origin = _normalize_start_origin(start_origin) + if normalized_origin != "user" and not definition.allow_automation: + raise WorkflowConflictError( + "This Workflow does not allow automated starts." + ) + if revision.execution_mode == "guided" and normalized_origin != "user": + raise WorkflowConflictError( + "Guided workflows must be started by a user; use hybrid mode " + "for triggered workflows with human handoffs." + ) + graph = _runtime_graph(revision) + _require_runtime_dependencies( + graph, + principal=principal, + registry=registry, + ) + key = payload.idempotency_key.strip() + existing = session.scalar( + select(WorkflowInstance).where( + WorkflowInstance.tenant_id == tenant_id, + WorkflowInstance.definition_id == definition.id, + WorkflowInstance.idempotency_key == key, + ) + ) + if existing is not None: + if ( + dict(existing.input_) != dict(payload.input) + or existing.correlation_id != payload.correlation_id + or existing.start_origin != normalized_origin + ): + raise WorkflowConflictError( + "The Workflow idempotency key was already used with " + "different input." + ) + return get_instance( + session, + tenant_id=tenant_id, + instance_id=existing.id, + ), True + start_node = _start_node( + graph, + kind=_start_kind_for_origin(normalized_origin), + ) + now = utcnow() + instance = WorkflowInstance( + tenant_id=tenant_id, + definition_id=definition.id, + definition_revision_id=revision.id, + status="running", + start_origin=normalized_origin, + idempotency_key=key, + correlation_id=payload.correlation_id, + input_=dict(payload.input), + context_={"input": dict(payload.input), "steps": {}}, + output_={}, + authorization_=_authorization_payload( + principal, + graph=graph, + registry=registry, + ), + started_at=now, + created_by=actor_id, + ) + session.add(instance) + session.flush() + instance.authorization_ = { + **dict(instance.authorization_), + "authorization_ref": f"workflow-instance:{instance.id}", + } + _record_event( + session, + instance, + kind="workflow.instance.started", + actor_id=actor_id, + payload={ + "definition_ref": f"workflow-definition:{definition.id}", + "revision": revision.revision, + "definition_hash": revision.content_hash, + "execution_mode": revision.execution_mode, + "start_origin": normalized_origin, + "input": dict(payload.input), + }, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=start_node.id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + session.flush() + return instance, False + + +def reconcile_instance( + session: Session, + *, + instance: WorkflowInstance, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None = None, +) -> bool: + if instance.status not in {"running", "waiting"}: + return False + step = _current_step(session, instance) + if step is None: + return False + revision = session.get( + WorkflowDefinitionRevision, + instance.definition_revision_id, + ) + if revision is None: + _fail_instance( + session, + instance, + message="Pinned Workflow revision no longer exists.", + ) + return True + graph = _runtime_graph(revision) + node = _node(graph, step.node_id) + if step.node_type == "workflow.capability": + if str(step.handoff.get("state") or "") not in { + "", + "pending", + "running", + "retrying", + }: + return False + return _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + if step.node_type != "workflow.dataflow": + return False + if not step.external_ref: + _start_dataflow_step( + session, + instance=instance, + step=step, + node=node, + principal=principal, + registry=registry, + graph=graph, + actor_id=actor_id, + ) + return True + provider = dataflow_run_lifecycle(registry) + if provider is None: + _set_dependency_handoff( + session, + instance=instance, + step=step, + message="The Dataflow module is not available.", + ) + return False + try: + descriptor = provider.get_run( + session, + principal, + run_ref=step.external_ref, + ) + except ValueError as exc: + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message=str(exc), + ) + return True + if descriptor is None: + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message="The linked Dataflow run no longer exists.", + ) + return True + if descriptor.status in {"queued", "retrying", "running"}: + step.handoff = { + **dict(step.handoff), + "state": descriptor.status, + "progress_percent": int( + descriptor.metadata.get("progress_percent") or 0 + ), + "progress_phase": str( + descriptor.metadata.get("progress_phase") or descriptor.status + ), + } + return False + if descriptor.status == "succeeded": + return _handle_dataflow_success( + session, + instance=instance, + step=step, + node=node, + graph=graph, + descriptor=descriptor, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + if descriptor.status == "cancelled": + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message="The linked Dataflow run was cancelled.", + state="cancelled", + ) + return True + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message=descriptor.error or "The linked Dataflow run failed.", + ) + return True + + +def resolve_step( + session: Session, + *, + tenant_id: str, + instance_id: str, + step_id: str, + actor_id: str | None, + principal: ApiPrincipal, + registry: object | None, + payload: WorkflowStepActionRequest, +) -> WorkflowInstance: + instance = get_instance( + session, + tenant_id=tenant_id, + instance_id=instance_id, + for_update=True, + ) + if instance.status != "waiting" or instance.current_step_id != step_id: + raise WorkflowConflictError( + "Only the current waiting Workflow step can be resolved." + ) + step = session.get(WorkflowInstanceStep, step_id) + if step is None or step.instance_id != instance.id: + raise WorkflowNotFoundError("Workflow step not found.") + revision = session.get( + WorkflowDefinitionRevision, + instance.definition_revision_id, + ) + if revision is None: + raise WorkflowConflictError( + "Pinned Workflow revision no longer exists." + ) + graph = _runtime_graph(revision) + node = _node(graph, step.node_id) + allowed_actions = { + str(action) + for action in step.handoff.get("allowed_actions") or () + } + if payload.action not in allowed_actions: + raise WorkflowConflictError( + f"Action {payload.action!r} is not available for this handoff." + ) + _record_event( + session, + instance, + step=step, + kind="workflow.step.action", + actor_id=actor_id, + payload={ + "action": payload.action, + "comment": payload.comment, + "evidence": list(payload.evidence), + "output": dict(payload.output), + }, + ) + if payload.action == "cancel": + return cancel_instance( + session, + tenant_id=tenant_id, + instance_id=instance_id, + actor_id=actor_id, + principal=principal, + registry=registry, + ) + if payload.action == "changes": + step.handoff = { + **dict(step.handoff), + "state": "changes_requested", + "last_comment": payload.comment, + } + return instance + if payload.action == "retry": + if step.node_type == "workflow.capability": + step.status = "running" + step.error = None + step.handoff = { + **dict(step.handoff), + "state": "retrying", + } + instance.status = "running" + instance.error = None + _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return instance + if step.node_type != "workflow.dataflow": + raise WorkflowConflictError( + "Only failed module-action or Dataflow handoffs can be retried." + ) + step.status = "superseded" + step.finished_at = utcnow() + step.completed_by = actor_id + instance.status = "running" + instance.current_step_id = None + instance.error = None + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=node.id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return instance + port = _action_port(step, payload.action) + output = { + **dict(step.output_), + **dict(payload.output), + "decision": payload.action, + "comment": payload.comment, + "evidence": list(payload.evidence), + } + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port=port, + output=output, + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return instance + + +def cancel_instance( + session: Session, + *, + tenant_id: str, + instance_id: str, + actor_id: str | None, + principal: ApiPrincipal, + registry: object | None, +) -> WorkflowInstance: + instance = get_instance( + session, + tenant_id=tenant_id, + instance_id=instance_id, + for_update=True, + ) + if instance.status in {"completed", "failed", "cancelled"}: + raise WorkflowConflictError( + f"Workflow instance is already {instance.status}." + ) + now = utcnow() + instance.cancellation_requested_at = now + step = _current_step(session, instance) + if step is not None and step.external_ref: + provider = dataflow_run_lifecycle(registry) + if provider is not None: + try: + provider.cancel_run( + session, + principal, + run_ref=step.external_ref, + ) + except ValueError as exc: + logger.info( + "Linked Dataflow run could not be cancelled for " + "Workflow instance %s: %s", + instance.id, + exc, + ) + step.status = "cancelled" + step.finished_at = now + step.completed_by = actor_id + instance.status = "cancelled" + instance.finished_at = now + instance.current_step_id = None + instance.error = "Cancelled by request." + _record_event( + session, + instance, + step=step, + kind="workflow.instance.cancelled", + actor_id=actor_id, + payload={"external_ref": step.external_ref if step else None}, + ) + return instance + + +def reconcile_pending_instances( + session: Session, + *, + registry: object | None, + limit: int = 50, +) -> dict[str, object]: + instances = list( + session.scalars( + select(WorkflowInstance) + .where( + WorkflowInstance.status.in_(("running", "waiting")), + WorkflowInstance.current_step_id.is_not(None), + ) + .order_by(WorkflowInstance.updated_at, WorkflowInstance.id) + .limit(max(1, min(int(limit), 200))) + .with_for_update(skip_locked=True) + ) + ) + summary: dict[str, object] = { + "inspected": len(instances), + "advanced": 0, + "waiting": 0, + "failed": 0, + "skipped": 0, + } + for instance in instances: + step = _current_step(session, instance) + if step is None or step.node_type not in { + "workflow.capability", + "workflow.dataflow", + }: + summary["waiting"] = int(summary["waiting"]) + 1 + continue + principal = _resolve_instance_principal( + session, + instance=instance, + registry=registry, + ) + if principal is None: + summary["skipped"] = int(summary["skipped"]) + 1 + continue + changed = reconcile_instance( + session, + instance=instance, + principal=principal, + registry=registry, + ) + if instance.status == "failed": + summary["failed"] = int(summary["failed"]) + 1 + elif changed: + summary["advanced"] = int(summary["advanced"]) + 1 + else: + summary["waiting"] = int(summary["waiting"]) + 1 + session.flush() + return summary + + +def instance_response( + session: Session, + instance: WorkflowInstance, + *, + replayed: bool = False, +) -> WorkflowInstanceResponse: + revision = session.get( + WorkflowDefinitionRevision, + instance.definition_revision_id, + ) + definition = session.get(WorkflowDefinition, instance.definition_id) + if revision is None or definition is None: + raise WorkflowNotFoundError( + "Workflow instance definition evidence is incomplete." + ) + view_context = _instance_view_context(instance, revision) + return WorkflowInstanceResponse( + id=instance.id, + definition_id=instance.definition_id, + definition_name=definition.name, + definition_revision=revision.revision, + definition_hash=revision.content_hash, + execution_mode=revision.execution_mode, # type: ignore[arg-type] + start_origin=instance.start_origin, # type: ignore[arg-type] + view_context=view_context, + status=instance.status, # type: ignore[arg-type] + idempotency_key=instance.idempotency_key, + correlation_id=instance.correlation_id, + current_step_id=instance.current_step_id, + input=dict(instance.input_), + context=dict(instance.context_), + output=dict(instance.output_), + started_at=instance.started_at, + finished_at=instance.finished_at, + cancellation_requested_at=instance.cancellation_requested_at, + error=instance.error, + created_by=instance.created_by, + created_at=instance.created_at, + updated_at=instance.updated_at, + steps=[ + WorkflowInstanceStepResponse( + id=step.id, + sequence=step.sequence, + node_id=step.node_id, + node_type=step.node_type, + status=step.status, # type: ignore[arg-type] + attempt=step.attempt, + input=dict(step.input_), + output=dict(step.output_), + handoff=dict(step.handoff), + external_ref=step.external_ref, + started_at=step.started_at, + finished_at=step.finished_at, + error=step.error, + completed_by=step.completed_by, + created_at=step.created_at, + updated_at=step.updated_at, + ) + for step in instance.steps + ], + events=[ + WorkflowInstanceEventResponse( + id=event.id, + sequence=event.sequence, + step_id=event.step_id, + kind=event.kind, + actor_id=event.actor_id, + payload=dict(event.payload), + created_at=event.created_at, + ) + for event in instance.events + ], + replayed=replayed, + ) + + +def _drive_instance( + session: Session, + *, + instance: WorkflowInstance, + graph: WorkflowGraph, + next_node_id: str | None, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, +) -> None: + transitions = 0 + while ( + next_node_id is not None + and instance.status == "running" + and transitions < MAX_INSTANCE_TRANSITIONS + ): + transitions += 1 + node = _node(graph, next_node_id) + step = _new_step( + session, + instance=instance, + node=node, + ) + instance.current_step_id = step.id + _record_event( + session, + instance, + step=step, + kind="workflow.step.started", + actor_id=actor_id, + payload={"node_id": node.id, "node_type": node.type}, + ) + if node.type.startswith("workflow.start."): + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port="output", + output={"input": dict(instance.input_)}, + actor_id=actor_id, + ) + continue + if node.type == "workflow.dataflow": + _start_dataflow_step( + session, + instance=instance, + step=step, + node=node, + principal=principal, + registry=registry, + graph=graph, + actor_id=actor_id, + ) + return + if node.type == "workflow.capability": + _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return + if node.type in { + "workflow.activity", + "workflow.review", + "workflow.wait", + }: + _set_human_handoff( + session, + instance=instance, + step=step, + node=node, + registry=registry, + ) + return + if node.type == "workflow.end.completed": + _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port="output", + output=dict(instance.context_), + actor_id=actor_id, + ) + instance.status = "completed" + instance.finished_at = utcnow() + instance.current_step_id = None + instance.output_ = dict(instance.context_) + _record_event( + session, + instance, + step=step, + kind="workflow.instance.completed", + actor_id=actor_id, + payload={"output": dict(instance.output_)}, + ) + return + if node.type == "workflow.end.cancelled": + step.status = "completed" + step.finished_at = utcnow() + instance.status = "cancelled" + instance.finished_at = utcnow() + instance.current_step_id = None + _record_event( + session, + instance, + step=step, + kind="workflow.instance.cancelled", + actor_id=actor_id, + payload={"reason": node.config.get("reason")}, + ) + return + _set_dependency_handoff( + session, + instance=instance, + step=step, + message=( + f"Runtime support for {node.type} requires an explicit " + "operator transition." + ), + ) + return + if next_node_id is None and instance.status == "running": + _fail_instance( + session, + instance, + message="Workflow reached a step without a configured transition.", + ) + return + if transitions >= MAX_INSTANCE_TRANSITIONS: + _fail_instance( + session, + instance, + message="Workflow exceeded the bounded transition limit.", + ) + + +def _execute_capability_step( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + graph: WorkflowGraph, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, +) -> bool: + try: + provider, definition = _capability_action_definition( + node, + principal=principal, + registry=registry, + ) + execution_context = { + **dict(instance.context_), + "instance": { + "id": instance.id, + "definition_id": instance.definition_id, + "correlation_id": instance.correlation_id, + }, + "step": { + "id": step.id, + "node_id": step.node_id, + "sequence": step.sequence, + "attempt": step.attempt, + }, + } + action_input = _mapped_action_input( + node.config.get("input_mapping"), + execution_context, + ) + request = ActionExecutionRequest( + tenant_id=instance.tenant_id, + action_key=definition.action_key, + input=action_input, + idempotency_key=_action_idempotency_key( + node, + step=step, + capability_name=str(node.config.get("capability") or ""), + action_key=definition.action_key, + context=execution_context, + ), + invocation=AutomationInvocation( + kind="workflow", + trigger_ref=f"workflow-instance:{instance.id}", + correlation_id=instance.correlation_id, + causation_id=f"workflow-step:{step.id}", + requested_by=instance.created_by, + metadata={ + "workflow_definition_ref": ( + f"workflow-definition:{instance.definition_id}" + ), + "workflow_node_id": node.id, + }, + ), + actor_ref=instance.created_by, + metadata={ + "workflow_instance_ref": f"workflow-instance:{instance.id}", + "workflow_step_ref": f"workflow-step:{step.id}", + }, + ) + preview = provider.preview_action( + session, + principal, + request=request, + ) + except WorkflowConflictError as exc: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=str(exc), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + except ValueError as exc: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=str(exc), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + except Exception as exc: + logger.exception( + "Workflow action preview failed for instance %s step %s", + instance.id, + step.id, + ) + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The module action preview failed unexpectedly. No action " + "execution was attempted." + ), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"error_type": type(exc).__name__}, + ) + return True + if not isinstance(preview, ActionPreview): + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned an invalid preview.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + preview_payload = _action_preview_payload(preview) + if preview.action_key != definition.action_key: + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned a preview for another action.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + if not preview.allowed: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=preview.summary or "The module action is not allowed.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + request = ActionExecutionRequest( + tenant_id=request.tenant_id, + action_key=request.action_key, + input=request.input, + idempotency_key=request.idempotency_key, + invocation=request.invocation, + actor_ref=request.actor_ref, + preview_ref=preview.preview_ref, + metadata=request.metadata, + ) + try: + result = provider.execute_action( + session, + principal, + request=request, + ) + except Exception as exc: + logger.exception( + "Workflow action execution failed for instance %s step %s", + instance.id, + step.id, + ) + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The module action outcome is unknown. Inspect the provider " + "before retrying to avoid a duplicate effect." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "preview": preview_payload, + "error_type": type(exc).__name__, + "outcome_unknown": True, + }, + ) + return True + if not isinstance(result, ActionExecutionResult): + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned an invalid execution result.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + allowed_states = { + "pending", + "running", + "completed", + "blocked", + "retryable", + "quarantined", + "manual_required", + "compensation_required", + } + announced_effects = { + item.effect_key for item in provider.effect_definitions() + } + unknown_effects = sorted( + { + effect.effect_key + for effect in result.observed_effects + if effect.effect_key not in announced_effects + } + ) + if result.state not in allowed_states or unknown_effects: + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The action provider returned an unsupported state." + if result.state not in allowed_states + else "The action provider reported unannounced effects." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "state": result.state, + "unknown_effects": unknown_effects, + }, + ) + return True + result_payload = _action_result_payload(result) + step.output_ = { + "action_key": definition.action_key, + "capability": str(node.config.get("capability") or ""), + "idempotency_key": request.idempotency_key, + "preview": preview_payload, + "execution": result_payload, + } + if result.state != "completed": + _set_action_handoff( + session, + instance=instance, + step=step, + state=result.state, + message=( + result.error + or result.manual_instructions + or f"Module action is {result.state.replace('_', ' ')}." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "preview": preview_payload, + "execution": result_payload, + }, + ) + return True + _record_event( + session, + instance, + step=step, + kind="workflow.action.completed", + actor_id=actor_id, + payload={ + "action_key": definition.action_key, + "capability": str(node.config.get("capability") or ""), + "idempotency_key": request.idempotency_key, + "observed_effects": result_payload["observed_effects"], + "audit_event_refs": result_payload["audit_event_refs"], + }, + ) + suggested_port = str(result.output.get("outcome") or "success") + if suggested_port not in {"success", "warning"}: + suggested_port = "success" + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port=suggested_port, + output=dict(step.output_), + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return True + + +def _capability_action_definition( + node: WorkflowNode, + *, + principal: ApiPrincipal, + registry: object | None, +) -> tuple[object, ActionDefinition]: + capability_name = str(node.config.get("capability") or "").strip() + action_key = str(node.config.get("operation") or "").strip() + if not capability_name or not action_key: + raise WorkflowConflictError( + "Module-action steps require a capability and operation." + ) + provider = action_effect_provider(registry, capability_name) + if provider is None: + raise WorkflowConflictError( + f"Action capability {capability_name!r} is not available." + ) + definitions = [ + item + for item in provider.action_definitions() + if item.action_key == action_key + ] + if len(definitions) != 1: + raise WorkflowConflictError( + f"Action {action_key!r} is not uniquely announced by " + f"{capability_name!r}." + ) + definition = definitions[0] + missing_scopes = [ + scope + for scope in definition.required_scopes + if not has_scope(principal, scope) + ] + if missing_scopes: + raise WorkflowConflictError( + "Module action requires scopes: " + + ", ".join(sorted(missing_scopes)) + ) + missing_capabilities = [ + capability + for capability in definition.required_capabilities + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(capability) + ) + ] + if missing_capabilities: + raise WorkflowConflictError( + "Module action requires capabilities: " + + ", ".join(sorted(missing_capabilities)) + ) + effect_keys = { + item.effect_key for item in provider.effect_definitions() + } + missing_effects = sorted( + set(definition.expected_effect_keys) - effect_keys + ) + if missing_effects: + raise WorkflowConflictError( + "Action provider does not define its expected effects: " + + ", ".join(missing_effects) + ) + return provider, definition + + +def _mapped_action_input( + raw_mapping: object, + context: Mapping[str, object], +) -> dict[str, object]: + if raw_mapping is None or raw_mapping == "": + return dict(context) + if not isinstance(raw_mapping, Mapping): + raise WorkflowConflictError( + "Module-action input mapping must be an object." + ) + return { + str(key): _resolve_action_value(value, context, depth=0) + for key, value in raw_mapping.items() + if str(key).strip() + } + + +def _resolve_action_value( + value: object, + context: Mapping[str, object], + *, + depth: int, +) -> object: + if depth > 10: + raise WorkflowConflictError( + "Module-action input mapping is nested too deeply." + ) + if isinstance(value, str) and value.startswith("$"): + path = value[1:].lstrip(".") + current: object = context + if not path: + return dict(context) + for segment in path.split("."): + if not isinstance(current, Mapping) or segment not in current: + raise WorkflowConflictError( + f"Module-action input path {value!r} is unavailable." + ) + current = current[segment] + return current + if isinstance(value, Mapping): + return { + str(key): _resolve_action_value( + nested, + context, + depth=depth + 1, + ) + for key, nested in value.items() + } + if isinstance(value, list): + return [ + _resolve_action_value(item, context, depth=depth + 1) + for item in value + ] + return value + + +def _action_idempotency_key( + node: WorkflowNode, + *, + step: WorkflowInstanceStep, + capability_name: str, + action_key: str, + context: Mapping[str, object], +) -> str: + expression = str( + node.config.get("idempotency_key") or "workflow-step" + ).strip() + if expression == "workflow-step": + return step.idempotency_key + resolved = _resolve_action_value(expression, context, depth=0) + key = f"{capability_name}:{action_key}:{resolved}" + if len(key) <= 255: + return key + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return f"{capability_name[:80]}:{action_key[:80]}:{digest}" + + +def _action_preview_payload(preview: object) -> dict[str, object]: + return { + "action_key": str(getattr(preview, "action_key", "")), + "allowed": bool(getattr(preview, "allowed", False)), + "summary": str(getattr(preview, "summary", "")), + "risk_level": str(getattr(preview, "risk_level", "")), + "reversibility": str(getattr(preview, "reversibility", "")), + "preview_ref": getattr(preview, "preview_ref", None), + "blockers": list(getattr(preview, "blockers", ()) or ()), + "policy_provenance": [ + dict(item) + for item in getattr(preview, "policy_provenance", ()) or () + ], + "effects": [ + { + "effect_key": item.effect_key, + "summary": item.summary, + "resource_refs": list(item.resource_refs), + "external_system_refs": list(item.external_system_refs), + } + for item in getattr(preview, "effects", ()) or () + ], + } + + +def _action_result_payload( + result: ActionExecutionResult, +) -> dict[str, object]: + return { + "state": result.state, + "output": dict(result.output), + "observed_effects": [ + { + "effect_key": effect.effect_key, + "operation": effect.operation, + "resource_ref": effect.resource_ref, + "external_system_ref": effect.external_system_ref, + "audit_event_ref": effect.audit_event_ref, + "summary": effect.summary, + "metadata": dict(effect.metadata), + } + for effect in result.observed_effects + ], + "error": result.error, + "retry_after": ( + result.retry_after.isoformat() + if result.retry_after is not None + else None + ), + "manual_instructions": result.manual_instructions, + "compensation_action_key": result.compensation_action_key, + "audit_event_refs": list(result.audit_event_refs), + } + + +def _set_action_handoff( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + state: str, + message: str, + action_key: str, + capability_name: str, + registry: object | None, + details: Mapping[str, object] | None = None, +) -> None: + allowed_actions = ( + ["cancel"] + if state in {"pending", "running"} + else ["retry", "reject", "cancel"] + ) + previous = dict(step.handoff) + step.status = "waiting" + step.error = message if state not in {"pending", "running"} else None + step.handoff = { + "kind": "module_action", + "state": state, + "message": message, + "action_key": action_key, + "capability": capability_name, + "allowed_actions": allowed_actions, + "suggested_port": "failure", + **dict(details or {}), + } + instance.status = "waiting" + instance.error = step.error + if ( + previous.get("state") != state + or previous.get("message") != message + ): + _record_event( + session, + instance, + step=step, + kind=f"workflow.action.{state}", + actor_id=None, + payload=dict(step.handoff), + ) + if state not in {"pending", "running"}: + _notify_handoff( + session, + registry=registry, + instance=instance, + step=step, + subject=f"Workflow action requires attention: {action_key}", + ) + + +def _start_dataflow_step( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + principal: ApiPrincipal, + registry: object | None, + graph: WorkflowGraph, + actor_id: str | None, +) -> None: + provider = dataflow_run_lifecycle(registry) + if provider is None: + _set_dependency_handoff( + session, + instance=instance, + step=step, + message="Enable Dataflow to execute this Workflow step.", + ) + return + pipeline_ref = str(node.config.get("pipeline_ref") or "").strip() + try: + revision = int(node.config.get("revision") or 0) + row_limit = max( + 1, + min(int(node.config.get("row_limit") or 500), 10_000), + ) + except (TypeError, ValueError): + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message="Dataflow revision and row limit must be integers.", + ) + return + if not pipeline_ref or revision < 1: + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message="Dataflow steps require a pipeline and pinned revision.", + ) + return + target_ref = str( + node.config.get("publication_target_ref") or "" + ).strip() + try: + run = provider.start_run( + session, + principal, + request=DataflowRunRequest( + pipeline_ref=pipeline_ref, + revision=revision, + idempotency_key=step.idempotency_key, + row_limit=row_limit, + environment=str( + node.config.get("environment") or "development" + ), + publication=( + DataflowPublicationTarget( + target_datasource_ref=target_ref + ) + if target_ref + else None + ), + invocation=AutomationInvocation( + kind="workflow", + correlation_id=instance.correlation_id, + causation_id=f"workflow-step:{step.id}", + requested_by=instance.created_by, + metadata={ + "workflow_instance_ref": ( + f"workflow-instance:{instance.id}" + ), + "workflow_step_ref": f"workflow-step:{step.id}", + }, + ), + ), + ) + except ValueError as exc: + _handle_dataflow_failure( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + message=str(exc), + ) + return + step.external_ref = run.ref + step.status = "waiting" + step.output_ = _dataflow_output(run) + step.handoff = { + "kind": "dataflow_run", + "state": run.status, + "run_ref": run.ref, + "pipeline_ref": pipeline_ref, + "pipeline_revision": revision, + "action_url": _dataflow_action_url(pipeline_ref, run.ref), + "allowed_actions": ["cancel"], + "progress_percent": int( + run.metadata.get("progress_percent") or 0 + ), + "progress_phase": str( + run.metadata.get("progress_phase") or run.status + ), + } + instance.status = "waiting" + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.started", + actor_id=instance.created_by, + payload=dict(step.handoff), + ) + + +def _handle_dataflow_success( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + graph: WorkflowGraph, + descriptor: DataflowRunDescriptor, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, +) -> bool: + diagnostics = descriptor.metadata.get("diagnostics") + items = diagnostics if isinstance(diagnostics, list) else [] + warnings = [ + dict(item) + for item in items + if isinstance(item, Mapping) + and str(item.get("severity") or "") == "warning" + ] + explicit_review = any( + str(item.get("code") or "") in { + "review.required", + "reconciliation.review_required", + } + for item in items + if isinstance(item, Mapping) + ) + output = _dataflow_output(descriptor) + step.output_ = output + if explicit_review or ( + warnings + and str(node.config.get("warning_policy") or "review") == "review" + ): + step.status = "waiting" + step.handoff = { + "kind": "dataflow_review", + "state": "review_required", + "run_ref": descriptor.ref, + "pipeline_ref": descriptor.pipeline_ref, + "action_url": _dataflow_action_url( + descriptor.pipeline_ref, + descriptor.ref, + ), + "allowed_actions": [ + "approve", + "changes", + "reject", + "retry", + "cancel", + ], + "suggested_port": ( + "review_required" if explicit_review else "warning" + ), + "warnings": warnings, + "output": output, + } + instance.status = "waiting" + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.review_required", + actor_id=actor_id, + payload=dict(step.handoff), + ) + _notify_handoff( + session, + registry=registry, + instance=instance, + step=step, + subject="Workflow Dataflow review required", + ) + return True + port = "warning" if warnings else "success" + if port == "warning" and _next_node_id(graph, node.id, port) is None: + port = "success" + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port=port, + output=output, + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return True + + +def _complete_step( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + graph: WorkflowGraph, + port: str, + output: Mapping[str, object], + actor_id: str | None, +) -> str | None: + step.status = "completed" + step.output_ = dict(output) + step.finished_at = utcnow() + step.completed_by = actor_id + step.handoff = {} + context = dict(instance.context_) + step_values = dict(context.get("steps") or {}) + step_values[step.node_id] = dict(output) + context["steps"] = step_values + instance.context_ = context + instance.status = "running" + instance.current_step_id = None + _record_event( + session, + instance, + step=step, + kind="workflow.step.completed", + actor_id=actor_id, + payload={"port": port, "output": dict(output)}, + ) + return _next_node_id(graph, step.node_id, port) + + +def _set_human_handoff( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + registry: object | None, +) -> None: + if node.type == "workflow.review": + actions = ["approve", "changes", "reject", "cancel"] + kind = "review" + elif node.type == "workflow.wait": + actions = ["resume", "cancel"] + kind = "wait" + else: + actions = ["complete", "cancel"] + kind = "activity" + step.status = "waiting" + step.handoff = { + "kind": kind, + "state": "waiting", + "title": str(node.config.get("title") or node.label or node.type), + "instructions": str(node.config.get("instructions") or ""), + "assignee": node.config.get("reviewer") + or node.config.get("assignee"), + "required_evidence": list( + node.config.get("required_evidence") or [] + ), + "allowed_actions": actions, + } + instance.status = "waiting" + _record_event( + session, + instance, + step=step, + kind="workflow.handoff.created", + actor_id=instance.created_by, + payload=dict(step.handoff), + ) + _notify_handoff( + session, + registry=registry, + instance=instance, + step=step, + subject=str(step.handoff["title"]), + ) + + +def _set_dependency_handoff( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + message: str, +) -> None: + step.status = "waiting" + step.error = message + step.handoff = { + "kind": "dependency", + "state": "blocked", + "message": message, + "allowed_actions": ["retry", "cancel"], + } + instance.status = "waiting" + instance.error = message + _record_event( + session, + instance, + step=step, + kind="workflow.step.blocked", + actor_id=None, + payload=dict(step.handoff), + ) + + +def _handle_dataflow_failure( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + graph: WorkflowGraph, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, + message: str, + state: str = "failed", +) -> None: + policy = str(node.config.get("failure_policy") or "manual") + if policy == "manual": + _set_failure_handoff( + session, + instance=instance, + step=step, + message=message, + state=state, + ) + return + step.error = message + step.output_ = { + **dict(step.output_), + "status": state, + "error": message, + } + if policy == "continue": + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.failure_continued", + actor_id=actor_id, + payload={ + "error": message, + "external_ref": step.external_ref, + }, + ) + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port="failure", + output=dict(step.output_), + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return + step.status = "failed" + step.finished_at = utcnow() + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.failed", + actor_id=actor_id, + payload={ + "error": message, + "external_ref": step.external_ref, + "failure_policy": "fail", + }, + ) + _fail_instance(session, instance, message=message) + + +def _set_failure_handoff( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + message: str, + state: str = "failed", +) -> None: + step.status = "waiting" + step.error = message + step.handoff = { + "kind": "dataflow_failure", + "state": state, + "message": message, + "run_ref": step.external_ref, + "allowed_actions": ["retry", "reject", "cancel"], + "suggested_port": "failure", + } + instance.status = "waiting" + instance.error = message + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.failed", + actor_id=None, + payload=dict(step.handoff), + ) + + +def _fail_instance( + session: Session, + instance: WorkflowInstance, + *, + message: str, +) -> None: + instance.status = "failed" + instance.finished_at = utcnow() + instance.error = message + instance.current_step_id = None + _record_event( + session, + instance, + kind="workflow.instance.failed", + actor_id=None, + payload={"error": message}, + ) + + +def _new_step( + session: Session, + *, + instance: WorkflowInstance, + node: WorkflowNode, +) -> WorkflowInstanceStep: + sequence = int( + session.scalar( + select(func.max(WorkflowInstanceStep.sequence)).where( + WorkflowInstanceStep.instance_id == instance.id + ) + ) + or 0 + ) + 1 + attempt = int( + session.scalar( + select(func.count()) + .select_from(WorkflowInstanceStep) + .where( + WorkflowInstanceStep.instance_id == instance.id, + WorkflowInstanceStep.node_id == node.id, + ) + ) + or 0 + ) + 1 + step = WorkflowInstanceStep( + tenant_id=instance.tenant_id, + instance=instance, + sequence=sequence, + node_id=node.id, + node_type=node.type, + status="running", + attempt=attempt, + idempotency_key=( + f"workflow:{instance.id}:node:{node.id}:attempt:{attempt}" + ), + input_=dict(instance.context_), + output_={}, + handoff={}, + started_at=utcnow(), + ) + session.add(step) + session.flush() + return step + + +def _record_event( + session: Session, + instance: WorkflowInstance, + *, + kind: str, + actor_id: str | None, + payload: Mapping[str, object], + step: WorkflowInstanceStep | None = None, +) -> None: + sequence = int( + session.scalar( + select(func.max(WorkflowInstanceEvent.sequence)).where( + WorkflowInstanceEvent.instance_id == instance.id + ) + ) + or 0 + ) + 1 + event = WorkflowInstanceEvent( + tenant_id=instance.tenant_id, + instance=instance, + step_id=step.id if step else None, + sequence=sequence, + kind=kind, + actor_id=actor_id, + payload=dict(payload), + created_at=utcnow(), + ) + session.add(event) + session.flush() + + +def _current_step( + session: Session, + instance: WorkflowInstance, +) -> WorkflowInstanceStep | None: + if not instance.current_step_id: + return None + return session.get(WorkflowInstanceStep, instance.current_step_id) + + +def _start_node(graph: WorkflowGraph, *, kind: str) -> WorkflowNode: + expected = f"workflow.start.{kind}" + node = next((item for item in graph.nodes if item.type == expected), None) + if node is None: + starts = [ + item for item in graph.nodes + if item.type.startswith("workflow.start.") + ] + if len(starts) == 1: + node = starts[0] + if node is None: + raise WorkflowConflictError( + f"Workflow has no {kind} start node." + ) + return node + + +def _normalize_start_origin(value: str) -> str: + normalized = value.strip().lower() + allowed = { + "user", + "api", + "schedule", + "event", + "parent_workflow", + "dependency", + "retry", + "replay", + "backfill", + } + if normalized not in allowed: + raise WorkflowConflictError( + f"Unsupported Workflow start origin {value!r}." + ) + return normalized + + +def _start_kind_for_origin(origin: str) -> str: + return { + "user": "manual", + "api": "api", + "schedule": "schedule", + "event": "event", + "parent_workflow": "workflow", + "dependency": "workflow", + "retry": "api", + "replay": "api", + "backfill": "api", + }[origin] + + +def _instance_view_context( + instance: WorkflowInstance, + revision: WorkflowDefinitionRevision, +) -> WorkflowViewContextResponse | None: + if ( + not revision.view_id + or instance.status not in {"running", "waiting"} + ): + return None + step = next( + ( + item + for item in instance.steps + if item.id == instance.current_step_id + ), + None, + ) + node = None + if step is not None: + graph = _runtime_graph(revision) + node = next( + (item for item in graph.nodes if item.id == step.node_id), + None, + ) + surface_ids = ( + [ + str(surface_id).strip() + for surface_id in node.config.get("view_surface_ids") or () + if str(surface_id).strip() + ] + if node is not None + else [] + ) + return WorkflowViewContextResponse( + view_id=revision.view_id, + revision_id=revision.view_revision_id, + visible_surface_ids=list(dict.fromkeys(surface_ids)), + step_id=step.id if step is not None else None, + node_id=node.id if node is not None else None, + ) + + +def _node(graph: WorkflowGraph, node_id: str) -> WorkflowNode: + node = next((item for item in graph.nodes if item.id == node_id), None) + if node is None: + raise WorkflowConflictError( + f"Workflow node {node_id!r} no longer exists." + ) + return node + + +def _runtime_graph(revision: WorkflowDefinitionRevision) -> WorkflowGraph: + try: + return materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + raise WorkflowConflictError(str(exc)) from exc + + +def _next_node_id( + graph: WorkflowGraph, + source_id: str, + port: str, +) -> str | None: + outgoing = [edge for edge in graph.edges if edge.source == source_id] + exact = [edge for edge in outgoing if edge.source_port == port] + if len(exact) == 1: + return exact[0].target + if not exact and len(outgoing) == 1: + return outgoing[0].target + if not exact: + return None + raise WorkflowConflictError( + f"Workflow node {source_id!r} has multiple {port!r} transitions." + ) + + +def _action_port( + step: WorkflowInstanceStep, + action: str, +) -> str: + if step.node_type == "workflow.review": + return { + "approve": "approved", + "complete": "approved", + "reject": "rejected", + }.get(action, "changes") + if step.node_type == "workflow.wait": + return "resumed" + if step.node_type == "workflow.dataflow": + if action == "reject": + return "failure" + return str(step.handoff.get("suggested_port") or "success") + if step.node_type == "workflow.capability": + if action == "reject": + return "failure" + return str(step.handoff.get("suggested_port") or "success") + return "output" + + +def _dataflow_output( + descriptor: DataflowRunDescriptor, +) -> dict[str, object]: + return { + "run_ref": descriptor.ref, + "status": descriptor.status, + "definition_hash": descriptor.definition_hash, + "output_publication_ref": descriptor.output_publication_ref, + "output_datasource_ref": descriptor.output_datasource_ref, + "output_materialization_ref": descriptor.output_materialization_ref, + "input_row_count": descriptor.input_row_count, + "output_row_count": descriptor.output_row_count, + "diagnostics": list( + descriptor.metadata.get("diagnostics") or [] + ), + } + + +def _dataflow_action_url(pipeline_ref: str, run_ref: str) -> str: + pipeline_id = pipeline_ref.removeprefix("pipeline:") + return f"/dataflow?pipelineId={pipeline_id}&runRef={run_ref}" + + +def _require_runtime_dependencies( + graph: WorkflowGraph, + *, + principal: ApiPrincipal, + registry: object | None, +) -> None: + if any(node.type == "workflow.dataflow" for node in graph.nodes): + if dataflow_run_lifecycle(registry) is None: + raise WorkflowConflictError( + "This Workflow requires the optional Dataflow module." + ) + if not has_scope(principal, DATAFLOW_RUN_SCOPE): + raise WorkflowConflictError( + "Starting this Workflow requires dataflow:pipeline:run." + ) + for node in graph.nodes: + if node.type == "workflow.capability": + _capability_action_definition( + node, + principal=principal, + registry=registry, + ) + + +def _authorization_payload( + principal: ApiPrincipal, + *, + graph: WorkflowGraph, + registry: object | None, +) -> dict[str, object]: + principal_ref = principal.to_platform_principal() + scopes = {INSTANCE_START_SCOPE} + if any(node.type == "workflow.dataflow" for node in graph.nodes): + scopes.add(DATAFLOW_RUN_SCOPE) + for node in graph.nodes: + if node.type != "workflow.capability": + continue + _provider, definition = _capability_action_definition( + node, + principal=principal, + registry=registry, + ) + scopes.update(definition.required_scopes) + return { + "contract_version": "1", + "subject_kind": ( + "service_account" + if principal_ref.service_account_id + else "delegated_user" + ), + "account_id": principal_ref.account_id, + "membership_id": principal_ref.membership_id, + "service_account_id": principal_ref.service_account_id, + "grant_scopes": sorted(scopes), + "authorization_ref": None, + } + + +def _resolve_instance_principal( + session: Session, + *, + instance: WorkflowInstance, + registry: object | None, +) -> ApiPrincipal | None: + provider = automation_principal_provider(registry) + if provider is None: + return None + value = dict(instance.authorization_) + common = { + "tenant_id": instance.tenant_id, + "authorization_ref": str( + value.get("authorization_ref") + or f"workflow-instance:{instance.id}" + ), + "grant_scopes": tuple( + str(scope) for scope in value.get("grant_scopes") or () + ), + "context": { + "workflow_instance_ref": f"workflow-instance:{instance.id}", + "definition_ref": ( + f"workflow-definition:{instance.definition_id}" + ), + }, + } + try: + if value.get("subject_kind") == "service_account": + request = AutomationPrincipalRequest.service_account( + service_account_id=str( + value.get("service_account_id") or "" + ), + **common, + ) + else: + request = AutomationPrincipalRequest.delegated_user( + account_id=str(value.get("account_id") or ""), + membership_id=str(value.get("membership_id") or ""), + **common, + ) + except ValueError as exc: + instance.authorization_ = { + **value, + "last_resolution": { + "allowed": False, + "reason": str(exc), + }, + "resolved_at": utcnow().isoformat(), + } + return None + resolution = provider.resolve_automation_principal( + session, + request=request, + ) + instance.authorization_ = { + **value, + "last_resolution": dict(resolution.provenance), + "resolved_at": utcnow().isoformat(), + } + return ( + resolution.principal + if resolution.allowed + and isinstance(resolution.principal, ApiPrincipal) + else None + ) + + +def _notify_handoff( + session: Session, + *, + registry: object | None, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + subject: str, +) -> None: + provider = notification_dispatch_provider(registry) + account_id = str( + instance.authorization_.get("account_id") or "" + ).strip() + if provider is None or not account_id: + return + try: + provider.enqueue_notification( + session, + NotificationDispatchRequest( + tenant_id=instance.tenant_id, + source_module="workflow", + source_resource_type="workflow_instance", + source_resource_id=instance.id, + event_kind="workflow.handoff.required", + recipient_type="account", + recipient_id=account_id, + subject=subject, + action_url=( + "/workflow?" + f"definition={instance.definition_id}" + f"&run={instance.id}" + ), + payload={ + "instance_id": instance.id, + "step_id": step.id, + "handoff": dict(step.handoff), + }, + ), + ) + except Exception: + logger.warning( + "Workflow handoff notification enqueue failed for instance %s", + instance.id, + exc_info=True, + ) + + +class SqlWorkflowRuntimeWorker: + def __init__(self, *, registry: object | None = None) -> None: + self._registry = registry + self._standards_reconciled = False + + def reconcile_pending( + self, + session: object, + *, + now: datetime | None = None, + limit: int = 50, + ) -> Mapping[str, object]: + del now + if not isinstance(session, Session): + raise TypeError("Workflow reconciliation requires a Session.") + standards: Mapping[str, object] | None = None + if not self._standards_reconciled: + from govoplan_workflow_engine.backend.contributions import ( + reconcile_workflow_definition_contributions, + ) + + standards = reconcile_workflow_definition_contributions( + session, + registry=self._registry, + ) + self._standards_reconciled = int(standards.get("blocked") or 0) == 0 + runtime = reconcile_pending_instances( + session, + registry=self._registry, + limit=limit, + ) + return { + **runtime, + **({"standards": standards} if standards is not None else {}), + } + + +__all__ = [ + "SqlWorkflowRuntimeWorker", + "cancel_instance", + "get_instance", + "instance_response", + "list_instances", + "reconcile_instance", + "reconcile_pending_instances", + "resolve_step", + "start_instance", +] diff --git a/src/govoplan_workflow_engine/backend/manifest.py b/src/govoplan_workflow_engine/backend/manifest.py new file mode 100644 index 0000000..935609b --- /dev/null +++ b/src/govoplan_workflow_engine/backend/manifest.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.access import ( + CAPABILITY_ACCESS_DIRECTORY, + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + DocumentationTopic, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleInterfaceRequirement, + ModuleManifest, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.policy import ( + CAPABILITY_POLICY_DEFINITION_GOVERNANCE, +) +from govoplan_core.core.notifications import ( + CAPABILITY_NOTIFICATIONS_DISPATCH, +) +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_RUNTIME_WORKER, +) +from govoplan_core.db.base import Base +from govoplan_workflow_engine.backend.db import models as workflow_models + + +MODULE_ID = "workflow_engine" +MODULE_NAME = "Workflow Engine" +MODULE_VERSION = "0.1.14" + +DEFINITION_READ_SCOPE = "workflow:definition:read" +DEFINITION_WRITE_SCOPE = "workflow:definition:write" +INSTANCE_READ_SCOPE = "workflow:instance:read" +INSTANCE_START_SCOPE = "workflow:instance:start" +INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition" +ADMIN_SCOPE = "workflow:instance:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Workflow", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission( + DEFINITION_READ_SCOPE, + "View workflow definitions", + "Read workflow graphs, versions, diagnostics, and referenced contracts.", + ), + _permission( + DEFINITION_WRITE_SCOPE, + "Manage workflow definitions", + "Create, edit, validate, and publish workflow definitions.", + ), + _permission( + INSTANCE_READ_SCOPE, + "View workflow instances", + "Read workflow progress, pending actions, and transition evidence.", + ), + _permission( + INSTANCE_START_SCOPE, + "Start workflows", + "Start approved workflow definitions for authorized subjects.", + ), + _permission( + INSTANCE_TRANSITION_SCOPE, + "Advance workflows", + "Complete activities and invoke authorized workflow transitions.", + ), + _permission( + ADMIN_SCOPE, + "Administer workflows", + "Manage workflow definitions and instances across the tenant.", + ), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="workflow_designer", + name="Workflow designer", + description="Design, validate, and publish workflow definitions.", + permissions=(DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE), + ), + RoleTemplate( + slug="workflow_operator", + name="Workflow operator", + description="Start and advance approved workflows.", + permissions=( + DEFINITION_READ_SCOPE, + INSTANCE_READ_SCOPE, + INSTANCE_START_SCOPE, + INSTANCE_TRANSITION_SCOPE, + ), + ), +) + + +def _router(context: ModuleContext): + from govoplan_workflow_engine.backend.runtime import configure_runtime + + configure_runtime(registry=context.registry, settings=context.settings) + from govoplan_workflow_engine.backend.router import router + + return router + + +def _runtime_worker(context: ModuleContext): + from govoplan_workflow_engine.backend.instance_service import ( + SqlWorkflowRuntimeWorker, + ) + + return SqlWorkflowRuntimeWorker(registry=context.registry) + + +def _definition_contribution_provider(context: ModuleContext): + from govoplan_workflow_engine.backend.contributions import ( + SqlWorkflowDefinitionContributionProvider, + ) + + return SqlWorkflowDefinitionContributionProvider(registry=context.registry) + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + permission_namespace="workflow", + dependencies=(), + optional_dependencies=( + "access", + "audit", + "dataflow", + "datasources", + "notifications", + "policy", + "tasks", + "views", + ), + optional_capabilities=( + CAPABILITY_ACCESS_DIRECTORY, + CAPABILITY_ACCESS_REFERENCE_OPTIONS, + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_DATAFLOW_RUN_LIFECYCLE, + CAPABILITY_NOTIFICATIONS_DISPATCH, + CAPABILITY_POLICY_DEFINITION_GOVERNANCE, + CAPABILITY_VIEWS_RESOLVER, + ), + provides_interfaces=( + ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"), + ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"), + ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"), + ModuleInterfaceProvider( + name="workflow.definition_contributions", + version="0.1.0", + ), + ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION), + ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"), + ModuleInterfaceProvider( + name="workflow.bpmn_execution_adapters", + version="1.0.0", + ), + ), + requires_interfaces=( + ModuleInterfaceRequirement( + name=CAPABILITY_ACCESS_REFERENCE_OPTIONS, + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), + ModuleInterfaceRequirement( + name="dataflow.run_lifecycle", + version_min="0.1.14", + version_max_exclusive="1.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name="auth.automation_principal", + version_min="0.1.0", + version_max_exclusive="1.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name=CAPABILITY_NOTIFICATIONS_DISPATCH, + version_min="0.1.0", + version_max_exclusive="1.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name="policy.definition_governance", + version_min="0.1.0", + version_max_exclusive="1.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name="views.resolver", + version_min="0.1.0", + version_max_exclusive="1.0.0", + optional=True, + ), + ), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_router, + capability_factories={ + CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS: ( + _definition_contribution_provider + ), + CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker, + }, + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + workflow_models.WorkflowInstanceEvent, + workflow_models.WorkflowInstanceStep, + workflow_models.WorkflowInstance, + workflow_models.WorkflowDefinitionRevision, + workflow_models.WorkflowDefinition, + label="Workflow", + ), + retirement_notes=( + "Destructive retirement drops Workflow definitions and immutable revisions " + "after the installer captures a database snapshot." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + workflow_models.WorkflowDefinition, + workflow_models.WorkflowDefinitionRevision, + workflow_models.WorkflowInstance, + workflow_models.WorkflowInstanceStep, + workflow_models.WorkflowInstanceEvent, + label="Workflow", + ), + ), + documentation=( + DocumentationTopic( + id="workflow.definition-graphs", + title="Workflow definition graphs", + summary="Governed process graphs exposed independently of an editor.", + body=( + "Workflow Engine provides trigger, activity, decision, wait, integration, " + "and outcome node library on top of Core's domain-neutral graph contract. " + "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." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "product_owner"), + related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"), + order=76, + ), + DocumentationTopic( + id="workflow.bpmn-interchange", + title="BPMN modeling and execution profiles", + summary=( + "Lossless BPMN 2.0 revisions with explicit, fail-closed " + "execution conformance." + ), + body=( + "BPMN 2.0 is Workflow's canonical native graph language. The " + "shared graph editor models BPMN nodes, flows, containment, and " + "diagram geometry directly without a separate browser-side " + "modeler. Imported XML is normalized into the graph and every " + "immutable revision pins a deterministic XML artifact. Activation " + "requires a pinned execution adapter and version whose declared " + "profile accepts every modeled runtime semantic. Unsupported " + "runtime constructs remain editable and exportable. " + "Adapter packages integrate through the " + "govoplan.workflow.bpmn_adapters entry-point group and must " + "materialize canonical Workflow runtime state; Workflow Engine never " + "imports a concrete engine module." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "product_owner"), + related_modules=("audit", "policy", "views"), + order=77, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + + +__all__ = [ + "ADMIN_SCOPE", + "DEFINITION_READ_SCOPE", + "DEFINITION_WRITE_SCOPE", + "INSTANCE_READ_SCOPE", + "INSTANCE_START_SCOPE", + "INSTANCE_TRANSITION_SCOPE", + "MODULE_ID", + "MODULE_VERSION", + "get_manifest", + "manifest", +] diff --git a/src/govoplan_workflow_engine/backend/migrations/__init__.py b/src/govoplan_workflow_engine/backend/migrations/__init__.py new file mode 100644 index 0000000..863565f --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Workflow database migrations.""" diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/0b4e7c9a2d6f_v0114_workflow_standards.py b/src/govoplan_workflow_engine/backend/migrations/versions/0b4e7c9a2d6f_v0114_workflow_standards.py new file mode 100644 index 0000000..3a19d75 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/0b4e7c9a2d6f_v0114_workflow_standards.py @@ -0,0 +1,124 @@ +"""v0.1.14 module Workflow standards and contribution provenance + +Revision ID: 0b4e7c9a2d6f +Revises: f1b7d3e5a9c2 +Create Date: 2026-07-31 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0b4e7c9a2d6f" +down_revision = "f1b7d3e5a9c2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definitions") as batch: + batch.add_column( + sa.Column("standard_origin_module_id", sa.String(120), nullable=True) + ) + batch.add_column( + sa.Column("standard_definition_key", sa.String(120), nullable=True) + ) + batch.add_column( + sa.Column( + "standard_origin_module_version", + sa.String(40), + nullable=True, + ) + ) + batch.add_column( + sa.Column( + "standard_contribution_schema_version", + sa.String(40), + nullable=True, + ) + ) + batch.add_column( + sa.Column("standard_contribution_hash", sa.String(64), nullable=True) + ) + batch.add_column( + sa.Column( + "standard_reconciled_at", + sa.DateTime(timezone=True), + nullable=True, + ) + ) + batch.create_index( + "ix_workflow_definitions_standard_origin_module_id", + ["standard_origin_module_id"], + ) + batch.create_index( + "ix_workflow_definitions_standard_definition_key", + ["standard_definition_key"], + ) + batch.create_index( + "ix_workflow_definitions_standard_contribution_hash", + ["standard_contribution_hash"], + ) + batch.create_index( + "uq_workflow_standard_origin_scope", + [ + "scope_key", + "standard_origin_module_id", + "standard_definition_key", + ], + unique=True, + ) + + with op.batch_alter_table("workflow_definition_revisions") as batch: + batch.add_column( + sa.Column( + "contribution_origin_module_version", + sa.String(40), + nullable=True, + ) + ) + batch.add_column( + sa.Column( + "contribution_schema_version", + sa.String(40), + nullable=True, + ) + ) + batch.add_column( + sa.Column("contribution_hash", sa.String(64), nullable=True) + ) + batch.add_column( + sa.Column("contribution_metadata", sa.JSON(), nullable=True) + ) + batch.create_index( + "ix_workflow_definition_revisions_contribution_hash", + ["contribution_hash"], + ) + + +def downgrade() -> None: + with op.batch_alter_table("workflow_definition_revisions") as batch: + batch.drop_index( + "ix_workflow_definition_revisions_contribution_hash" + ) + batch.drop_column("contribution_metadata") + batch.drop_column("contribution_hash") + batch.drop_column("contribution_schema_version") + batch.drop_column("contribution_origin_module_version") + + with op.batch_alter_table("workflow_definitions") as batch: + batch.drop_index("uq_workflow_standard_origin_scope") + batch.drop_index( + "ix_workflow_definitions_standard_contribution_hash" + ) + batch.drop_index("ix_workflow_definitions_standard_definition_key") + batch.drop_index( + "ix_workflow_definitions_standard_origin_module_id" + ) + batch.drop_column("standard_reconciled_at") + batch.drop_column("standard_contribution_hash") + batch.drop_column("standard_contribution_schema_version") + batch.drop_column("standard_origin_module_version") + batch.drop_column("standard_definition_key") + batch.drop_column("standard_origin_module_id") diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/__init__.py b/src/govoplan_workflow_engine/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..7dd88e5 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Workflow Alembic revisions.""" diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py b/src/govoplan_workflow_engine/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py new file mode 100644 index 0000000..bfa46c8 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/a7c4e2f9b1d3_v0114_workflow_definitions.py @@ -0,0 +1,201 @@ +"""v0.1.14 Workflow definitions + +Revision ID: a7c4e2f9b1d3 +Revises: None +Create Date: 2026-07-28 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "a7c4e2f9b1d3" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "workflow_definitions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("definition_key", sa.String(length=120), nullable=False), + sa.Column("name", sa.String(length=300), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("current_revision", sa.Integer(), nullable=False), + sa.Column("active_revision", sa.Integer(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_workflow_definitions")), + sa.UniqueConstraint( + "tenant_id", + "definition_key", + name="uq_workflow_definition_key", + ), + ) + op.create_index( + op.f("ix_workflow_definitions_created_by"), + "workflow_definitions", + ["created_by"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definitions_deleted_at"), + "workflow_definitions", + ["deleted_at"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definitions_status"), + "workflow_definitions", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definitions_tenant_id"), + "workflow_definitions", + ["tenant_id"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definitions_updated_by"), + "workflow_definitions", + ["updated_by"], + unique=False, + ) + op.create_index( + "ix_workflow_definitions_tenant_status", + "workflow_definitions", + ["tenant_id", "status"], + unique=False, + ) + op.create_index( + "ix_workflow_definitions_tenant_updated", + "workflow_definitions", + ["tenant_id", "updated_at"], + unique=False, + ) + + op.create_table( + "workflow_definition_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("definition_id", sa.String(length=36), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.Column("graph", sa.JSON(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("library_id", sa.String(length=100), nullable=False), + sa.Column("library_version", sa.String(length=40), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["definition_id"], + ["workflow_definitions.id"], + name=op.f( + "fk_workflow_definition_revisions_definition_id_workflow_definitions" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_workflow_definition_revisions"), + ), + sa.UniqueConstraint( + "definition_id", + "revision", + name="uq_workflow_definition_revision", + ), + ) + op.create_index( + op.f("ix_workflow_definition_revisions_created_by"), + "workflow_definition_revisions", + ["created_by"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definition_revisions_definition_id"), + "workflow_definition_revisions", + ["definition_id"], + unique=False, + ) + op.create_index( + op.f("ix_workflow_definition_revisions_tenant_id"), + "workflow_definition_revisions", + ["tenant_id"], + unique=False, + ) + op.create_index( + "ix_workflow_definition_revisions_content_hash", + "workflow_definition_revisions", + ["tenant_id", "content_hash"], + unique=False, + ) + op.create_index( + "ix_workflow_definition_revisions_tenant_definition", + "workflow_definition_revisions", + ["tenant_id", "definition_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_workflow_definition_revisions_tenant_definition", + table_name="workflow_definition_revisions", + ) + op.drop_index( + "ix_workflow_definition_revisions_content_hash", + table_name="workflow_definition_revisions", + ) + op.drop_index( + op.f("ix_workflow_definition_revisions_tenant_id"), + table_name="workflow_definition_revisions", + ) + op.drop_index( + op.f("ix_workflow_definition_revisions_definition_id"), + table_name="workflow_definition_revisions", + ) + op.drop_index( + op.f("ix_workflow_definition_revisions_created_by"), + table_name="workflow_definition_revisions", + ) + op.drop_table("workflow_definition_revisions") + + op.drop_index( + "ix_workflow_definitions_tenant_updated", + table_name="workflow_definitions", + ) + op.drop_index( + "ix_workflow_definitions_tenant_status", + table_name="workflow_definitions", + ) + op.drop_index( + op.f("ix_workflow_definitions_updated_by"), + table_name="workflow_definitions", + ) + op.drop_index( + op.f("ix_workflow_definitions_tenant_id"), + table_name="workflow_definitions", + ) + op.drop_index( + op.f("ix_workflow_definitions_status"), + table_name="workflow_definitions", + ) + op.drop_index( + op.f("ix_workflow_definitions_deleted_at"), + table_name="workflow_definitions", + ) + op.drop_index( + op.f("ix_workflow_definitions_created_by"), + table_name="workflow_definitions", + ) + op.drop_table("workflow_definitions") diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py b/src/govoplan_workflow_engine/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py new file mode 100644 index 0000000..245275d --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py @@ -0,0 +1,209 @@ +"""v0.1.14 governed Workflow definitions + +Revision ID: c6d8f1a3e5b7 +Revises: a7c4e2f9b1d3 +Create Date: 2026-07-28 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c6d8f1a3e5b7" +down_revision = "a7c4e2f9b1d3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definitions") as batch_op: + batch_op.drop_constraint( + "uq_workflow_definition_key", + type_="unique", + ) + batch_op.alter_column( + "tenant_id", + existing_type=sa.String(length=36), + nullable=True, + ) + batch_op.add_column( + sa.Column( + "scope_type", + sa.String(length=20), + nullable=False, + server_default="tenant", + ) + ) + batch_op.add_column( + sa.Column("scope_id", sa.String(length=36), nullable=True) + ) + batch_op.add_column( + sa.Column( + "scope_key", + sa.String(length=80), + nullable=False, + server_default="tenant:legacy", + ) + ) + batch_op.add_column( + sa.Column( + "definition_kind", + sa.String(length=20), + nullable=False, + server_default="flow", + ) + ) + batch_op.add_column( + sa.Column( + "inherit_to_lower_scopes", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column( + sa.Column( + "allow_start", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ) + ) + batch_op.add_column( + sa.Column( + "allow_reuse", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column( + sa.Column( + "allow_automation", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column( + sa.Column( + "derived_from_definition_id", + sa.String(length=36), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column("derived_from_revision", sa.Integer(), nullable=True) + ) + batch_op.add_column( + sa.Column( + "derived_from_hash", + sa.String(length=64), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "derivation_provenance", + sa.JSON(), + nullable=False, + server_default=sa.text("'{}'"), + ) + ) + op.execute( + sa.text( + "UPDATE workflow_definitions " + "SET scope_id = tenant_id, " + "scope_key = 'tenant:' || tenant_id " + "WHERE tenant_id IS NOT NULL" + ) + ) + with op.batch_alter_table("workflow_definitions") as batch_op: + batch_op.create_unique_constraint( + "uq_workflow_definition_key", + ["scope_key", "definition_key"], + ) + for column in ( + "scope_type", + "scope_id", + "scope_key", + "definition_kind", + "derived_from_definition_id", + ): + op.create_index( + op.f(f"ix_workflow_definitions_{column}"), + "workflow_definitions", + [column], + unique=False, + ) + + with op.batch_alter_table( + "workflow_definition_revisions" + ) as batch_op: + batch_op.alter_column( + "tenant_id", + existing_type=sa.String(length=36), + nullable=True, + ) + + +def downgrade() -> None: + op.execute( + sa.text( + "UPDATE workflow_definition_revisions " + "SET tenant_id = COALESCE(tenant_id, 'system')" + ) + ) + with op.batch_alter_table( + "workflow_definition_revisions" + ) as batch_op: + batch_op.alter_column( + "tenant_id", + existing_type=sa.String(length=36), + nullable=False, + ) + + for column in ( + "derived_from_definition_id", + "definition_kind", + "scope_key", + "scope_id", + "scope_type", + ): + op.drop_index( + op.f(f"ix_workflow_definitions_{column}"), + table_name="workflow_definitions", + ) + op.execute( + sa.text( + "UPDATE workflow_definitions " + "SET tenant_id = COALESCE(tenant_id, 'system')" + ) + ) + with op.batch_alter_table("workflow_definitions") as batch_op: + batch_op.drop_constraint( + "uq_workflow_definition_key", + type_="unique", + ) + batch_op.create_unique_constraint( + "uq_workflow_definition_key", + ["tenant_id", "definition_key"], + ) + batch_op.drop_column("derivation_provenance") + batch_op.drop_column("derived_from_hash") + batch_op.drop_column("derived_from_revision") + batch_op.drop_column("derived_from_definition_id") + batch_op.drop_column("allow_automation") + batch_op.drop_column("allow_reuse") + batch_op.drop_column("allow_start") + batch_op.drop_column("inherit_to_lower_scopes") + batch_op.drop_column("definition_kind") + batch_op.drop_column("scope_key") + batch_op.drop_column("scope_id") + batch_op.drop_column("scope_type") + batch_op.alter_column( + "tenant_id", + existing_type=sa.String(length=36), + nullable=False, + ) diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py b/src/govoplan_workflow_engine/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py new file mode 100644 index 0000000..a7479a9 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/d8f2a5c7e1b4_v0114_workflow_runtime.py @@ -0,0 +1,213 @@ +"""v0.1.14 Workflow instances and resumable handoffs + +Revision ID: d8f2a5c7e1b4 +Revises: c6d8f1a3e5b7 +Create Date: 2026-07-30 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d8f2a5c7e1b4" +down_revision = "c6d8f1a3e5b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "workflow_instances", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("definition_id", sa.String(length=36), nullable=False), + sa.Column( + "definition_revision_id", + sa.String(length=36), + nullable=False, + ), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("correlation_id", sa.String(length=128), nullable=True), + sa.Column("current_step_id", sa.String(length=36), nullable=True), + sa.Column("input", sa.JSON(), nullable=False), + sa.Column("context", sa.JSON(), nullable=False), + sa.Column("output", sa.JSON(), nullable=False), + sa.Column("authorization", sa.JSON(), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "cancellation_requested_at", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["definition_id"], + ["workflow_definitions.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["definition_revision_id"], + ["workflow_definition_revisions.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "definition_id", + "idempotency_key", + name="uq_workflow_instance_idempotency", + ), + ) + for column in ( + "tenant_id", + "definition_id", + "definition_revision_id", + "status", + "idempotency_key", + "correlation_id", + "current_step_id", + "created_by", + ): + op.create_index( + op.f(f"ix_workflow_instances_{column}"), + "workflow_instances", + [column], + unique=False, + ) + op.create_index( + "ix_workflow_instances_tenant_status", + "workflow_instances", + ["tenant_id", "status"], + unique=False, + ) + op.create_index( + "ix_workflow_instances_reconcile", + "workflow_instances", + ["status", "updated_at"], + unique=False, + ) + + op.create_table( + "workflow_instance_steps", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("instance_id", sa.String(length=36), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("node_id", sa.String(length=120), nullable=False), + sa.Column("node_type", sa.String(length=120), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("input", sa.JSON(), nullable=False), + sa.Column("output", sa.JSON(), nullable=False), + sa.Column("handoff", sa.JSON(), nullable=False), + sa.Column("external_ref", sa.String(length=500), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("completed_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["instance_id"], + ["workflow_instances.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "instance_id", + "sequence", + name="uq_workflow_instance_step_sequence", + ), + ) + for column in ( + "tenant_id", + "instance_id", + "node_id", + "node_type", + "status", + "external_ref", + ): + op.create_index( + op.f(f"ix_workflow_instance_steps_{column}"), + "workflow_instance_steps", + [column], + unique=False, + ) + op.create_index( + "ix_workflow_instance_steps_tenant_status", + "workflow_instance_steps", + ["tenant_id", "status"], + unique=False, + ) + + op.create_table( + "workflow_instance_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("instance_id", sa.String(length=36), nullable=False), + sa.Column("step_id", sa.String(length=36), nullable=True), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("kind", sa.String(length=120), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["instance_id"], + ["workflow_instances.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "instance_id", + "sequence", + name="uq_workflow_instance_event_sequence", + ), + ) + for column in ( + "tenant_id", + "instance_id", + "step_id", + "kind", + "actor_id", + ): + op.create_index( + op.f(f"ix_workflow_instance_events_{column}"), + "workflow_instance_events", + [column], + unique=False, + ) + op.create_index( + "ix_workflow_instance_events_tenant_created", + "workflow_instance_events", + ["tenant_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_workflow_instance_events_tenant_created", + table_name="workflow_instance_events", + ) + op.drop_table("workflow_instance_events") + op.drop_index( + "ix_workflow_instance_steps_tenant_status", + table_name="workflow_instance_steps", + ) + op.drop_table("workflow_instance_steps") + op.drop_index( + "ix_workflow_instances_reconcile", + table_name="workflow_instances", + ) + op.drop_index( + "ix_workflow_instances_tenant_status", + table_name="workflow_instances", + ) + op.drop_table("workflow_instances") diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py b/src/govoplan_workflow_engine/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py new file mode 100644 index 0000000..27ccdcf --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py @@ -0,0 +1,68 @@ +"""v0.1.14 normalized BPMN revision artifacts + +Revision ID: e9a4c6b8d2f1 +Revises: d8f2a5c7e1b4 +Create Date: 2026-07-30 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e9a4c6b8d2f1" +down_revision = "d8f2a5c7e1b4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.add_column(sa.Column("bpmn_xml", sa.Text(), nullable=True)) + batch_op.add_column( + sa.Column("bpmn_hash", sa.String(length=64), nullable=True) + ) + batch_op.add_column( + sa.Column( + "bpmn_adapter_id", + sa.String(length=120), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "bpmn_adapter_version", + sa.String(length=40), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "bpmn_runtime_kind", + sa.String(length=20), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column("bpmn_executable", sa.Boolean(), nullable=True) + ) + op.create_index( + op.f("ix_workflow_definition_revisions_bpmn_hash"), + "workflow_definition_revisions", + ["bpmn_hash"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_workflow_definition_revisions_bpmn_hash"), + table_name="workflow_definition_revisions", + ) + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.drop_column("bpmn_executable") + batch_op.drop_column("bpmn_runtime_kind") + batch_op.drop_column("bpmn_adapter_version") + batch_op.drop_column("bpmn_adapter_id") + batch_op.drop_column("bpmn_hash") + batch_op.drop_column("bpmn_xml") diff --git a/src/govoplan_workflow_engine/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py b/src/govoplan_workflow_engine/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py new file mode 100644 index 0000000..f0ccbc5 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py @@ -0,0 +1,62 @@ +"""v0.1.14 workflow execution modes and focused Views + +Revision ID: f1b7d3e5a9c2 +Revises: e9a4c6b8d2f1 +Create Date: 2026-07-30 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "f1b7d3e5a9c2" +down_revision = "e9a4c6b8d2f1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.add_column( + sa.Column( + "execution_mode", + sa.String(length=20), + nullable=False, + server_default="hybrid", + ) + ) + batch_op.add_column( + sa.Column("view_id", sa.String(length=36), nullable=True) + ) + batch_op.add_column( + sa.Column( + "view_revision_id", + sa.String(length=36), + nullable=True, + ) + ) + with op.batch_alter_table("workflow_instances") as batch_op: + batch_op.add_column( + sa.Column( + "start_origin", + sa.String(length=30), + nullable=False, + server_default="user", + ) + ) + batch_op.create_index( + "ix_workflow_instances_start_origin", + ["start_origin"], + unique=False, + ) + + +def downgrade() -> None: + with op.batch_alter_table("workflow_instances") as batch_op: + batch_op.drop_index("ix_workflow_instances_start_origin") + batch_op.drop_column("start_origin") + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.drop_column("view_revision_id") + batch_op.drop_column("view_id") + batch_op.drop_column("execution_mode") diff --git a/src/govoplan_workflow_engine/backend/node_library.py b/src/govoplan_workflow_engine/backend/node_library.py new file mode 100644 index 0000000..92ce1c3 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/node_library.py @@ -0,0 +1,1150 @@ +from __future__ import annotations + +from govoplan_core.core.definition_graphs import ( + DefinitionConfigField, + DefinitionGraphConstraints, + DefinitionGraphLibrary, + DefinitionNodeType, + DefinitionPort, +) + + +CATEGORY_LABELS = { + "bpmn_event": "Events", + "bpmn_activity": "Activities", + "bpmn_gateway": "Gateways", + "bpmn_data": "Data", + "bpmn_collaboration": "Collaboration", + "bpmn_artifact": "Artifacts", + "trigger": "Start", + "activity": "Activities", + "decision": "Decisions", + "wait": "Wait", + "integration": "Integrations", + "outcome": "Outcomes", +} + +FOCUSED_VIEW_SURFACES_FIELD = DefinitionConfigField( + id="view_surface_ids", + label="Focused View surfaces", + kind="view_surfaces", + description=( + "Optionally narrow the workflow View while this step requires " + "attention. Administrator limits and protected surfaces still apply." + ), +) + +LEGACY_WORKFLOW_NODE_TYPES = ( + DefinitionNodeType( + type="workflow.start.manual", + category="trigger", + label="Manual start", + description="Start an instance through an explicit user action.", + icon="circle-play", + default_config={"input_schema_ref": ""}, + config_fields=( + DefinitionConfigField( + id="input_schema_ref", + label="Input schema", + kind="text", + description="Optional schema contract for instance input.", + ), + ), + ), + DefinitionNodeType( + type="workflow.start.api", + category="trigger", + label="API start", + description=( + "Start through an authenticated API request with an explicit " + "input contract." + ), + icon="braces", + default_config={ + "input_schema_ref": "", + "authorization_policy_ref": "", + }, + config_fields=( + DefinitionConfigField( + id="input_schema_ref", + label="Input schema", + kind="text", + required=True, + ), + DefinitionConfigField( + id="authorization_policy_ref", + label="Authorization policy", + kind="text", + required=True, + ), + ), + ), + DefinitionNodeType( + type="workflow.start.workflow", + category="trigger", + label="Parent workflow", + description=( + "Start as a pinned child or dependency of another Workflow " + "instance." + ), + icon="git-branch", + default_config={ + "parent_definition_ref": "", + "parent_outcome": "completed", + "input_mapping": {}, + }, + config_fields=( + DefinitionConfigField( + id="parent_definition_ref", + label="Parent definition", + kind="text", + required=True, + ), + DefinitionConfigField( + id="parent_outcome", + label="Parent outcome", + kind="text", + required=True, + ), + DefinitionConfigField( + id="input_mapping", + label="Input mapping", + kind="mapping", + ), + ), + ), + DefinitionNodeType( + type="workflow.start.event", + category="trigger", + label="Event start", + description="Start when a matching platform event is received.", + icon="radio", + config_fields=( + DefinitionConfigField( + id="event_type", + label="Event type", + kind="text", + required=True, + ), + DefinitionConfigField( + id="filter", + label="Event filter", + kind="expression", + description="A constrained expression evaluated against the event envelope.", + ), + ), + default_config={"event_type": "", "filter": ""}, + ), + DefinitionNodeType( + type="workflow.start.schedule", + category="trigger", + label="Scheduled start", + description="Start according to a governed schedule.", + icon="calendar-clock", + config_fields=( + DefinitionConfigField( + id="schedule", + label="Schedule", + kind="schedule", + required=True, + ), + DefinitionConfigField( + id="timezone", + label="Time zone", + kind="timezone", + required=True, + ), + ), + default_config={"schedule": "", "timezone": "Europe/Berlin"}, + ), + DefinitionNodeType( + type="workflow.activity", + category="activity", + label="Activity", + description="Record and complete a governed unit of work.", + icon="square-check-big", + input_ports=(DefinitionPort(id="input", label="Input"),), + config_fields=( + DefinitionConfigField(id="title", label="Title", kind="text", required=True), + DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), + DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), + DefinitionConfigField(id="due_after", label="Due after", kind="duration"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "view_surface_ids": [], + }, + ), + DefinitionNodeType( + type="workflow.review", + category="activity", + label="Review", + description="Pause for a human review with explicit outcomes.", + icon="clipboard-check", + input_ports=(DefinitionPort(id="input", label="Input"),), + output_ports=( + DefinitionPort(id="approved", label="Approved", required=False), + DefinitionPort(id="changes", label="Changes requested", required=False), + DefinitionPort(id="rejected", label="Rejected", required=False), + ), + config_fields=( + DefinitionConfigField(id="title", label="Title", kind="text", required=True), + DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"), + DefinitionConfigField( + id="required_evidence", + label="Required evidence", + kind="string_list", + ), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "title": "", + "reviewer": "", + "required_evidence": [], + "view_surface_ids": [], + }, + ), + DefinitionNodeType( + type="workflow.decision", + category="decision", + label="Decision", + description="Choose a path using a constrained expression.", + icon="split", + input_ports=(DefinitionPort(id="input", label="Input"),), + output_ports=( + DefinitionPort(id="true", label="Matches", required=False), + DefinitionPort(id="false", label="Does not match", required=False), + ), + config_fields=( + DefinitionConfigField( + id="expression", + label="Expression", + kind="expression", + required=True, + ), + ), + default_config={"expression": ""}, + ), + DefinitionNodeType( + type="workflow.wait", + category="wait", + label="Wait", + description="Wait for a duration, deadline, event, or explicit resume action.", + icon="timer", + input_ports=(DefinitionPort(id="input", label="Input"),), + output_ports=( + DefinitionPort(id="resumed", label="Resumed", required=False), + DefinitionPort(id="timed_out", label="Timed out", required=False), + ), + config_fields=( + DefinitionConfigField( + id="mode", + label="Wait for", + kind="select", + required=True, + options=( + ("duration", "Duration"), + ("deadline", "Deadline"), + ("event", "Event"), + ("manual", "Manual resume"), + ), + ), + DefinitionConfigField(id="value", label="Value", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "mode": "manual", + "value": "", + "view_surface_ids": [], + }, + ), + DefinitionNodeType( + type="workflow.capability", + category="integration", + label="Module action", + description="Invoke a versioned module capability without importing its implementation.", + icon="plug-zap", + input_ports=(DefinitionPort(id="input", label="Input"),), + output_ports=( + DefinitionPort(id="success", label="Success", required=False), + DefinitionPort(id="warning", label="Warning", required=False), + DefinitionPort( + id="review_required", + label="Review required", + required=False, + ), + DefinitionPort(id="failure", label="Failure", required=False), + ), + config_fields=( + DefinitionConfigField( + id="capability", + label="Capability", + kind="capability", + required=True, + ), + DefinitionConfigField( + id="operation", + label="Operation", + kind="text", + required=True, + ), + DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), + DefinitionConfigField( + id="idempotency_key", + label="Idempotency key", + kind="expression", + required=True, + ), + DefinitionConfigField( + id="failure_policy", + label="On failure", + kind="select", + required=True, + options=( + ("retry", "Retry"), + ("manual", "Require intervention"), + ("continue", "Continue on failure"), + ), + ), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "capability": "", + "operation": "", + "input_mapping": {}, + "idempotency_key": "workflow-step", + "failure_policy": "manual", + "view_surface_ids": [], + }, + ), + DefinitionNodeType( + type="workflow.dataflow", + category="integration", + label="Run dataflow", + description="Run a published Dataflow pipeline and retain its output reference.", + icon="waypoints", + input_ports=(DefinitionPort(id="input", label="Input"),), + output_ports=( + DefinitionPort(id="success", label="Success", required=False), + DefinitionPort(id="warning", label="Warning", required=False), + DefinitionPort( + id="review_required", + label="Review required", + required=False, + ), + DefinitionPort(id="failure", label="Failure", required=False), + ), + config_fields=( + DefinitionConfigField( + id="pipeline_ref", + label="Pipeline", + kind="dataflow", + required=True, + ), + DefinitionConfigField( + id="revision", + label="Pinned revision", + kind="number", + required=True, + ), + DefinitionConfigField( + id="environment", + label="Environment", + kind="select", + required=True, + options=( + ("development", "Development"), + ("staging", "Staging"), + ("production", "Production"), + ), + ), + DefinitionConfigField( + id="row_limit", + label="Output row limit", + kind="number", + required=True, + ), + DefinitionConfigField( + id="publication_target_ref", + label="Publication datasource", + kind="text", + description=( + "Optional stable Datasource target for materialized " + "output." + ), + ), + DefinitionConfigField( + id="warning_policy", + label="Warnings", + kind="select", + required=True, + options=( + ("review", "Require review"), + ("continue", "Continue"), + ), + ), + DefinitionConfigField( + id="failure_policy", + label="Failures", + kind="select", + options=( + ("manual", "Require intervention"), + ("fail", "Fail the workflow"), + ("continue", "Follow failure path"), + ), + ), + DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "pipeline_ref": "", + "revision": 1, + "environment": "development", + "row_limit": 500, + "publication_target_ref": "", + "warning_policy": "review", + "failure_policy": "manual", + "input_mapping": {}, + "view_surface_ids": [], + }, + ), + DefinitionNodeType( + type="workflow.end.completed", + category="outcome", + label="Completed", + description="Complete the workflow successfully.", + icon="circle-check-big", + input_ports=( + DefinitionPort( + id="input", + label="Input", + multiple=True, + minimum_connections=1, + ), + ), + output_ports=(), + config_fields=( + DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"), + ), + default_config={"output_mapping": {}}, + ), + DefinitionNodeType( + type="workflow.end.cancelled", + category="outcome", + label="Cancelled", + description="End the workflow without a successful outcome.", + icon="circle-x", + input_ports=( + DefinitionPort( + id="input", + label="Input", + multiple=True, + minimum_connections=1, + ), + ), + output_ports=(), + config_fields=( + DefinitionConfigField(id="reason", label="Reason", kind="text"), + ), + default_config={"reason": ""}, + ), +) + +_INCOMING = ( + DefinitionPort( + id="incoming", + label="Incoming", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_OPTIONAL_INCOMING = ( + DefinitionPort( + id="incoming", + label="Incoming", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_OUTGOING = ( + DefinitionPort( + id="outgoing", + label="Outgoing", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_DOCUMENTATION_FIELD = DefinitionConfigField( + id="documentation", + label="Documentation", + kind="textarea", +) +_EVENT_DEFINITION_FIELD = DefinitionConfigField( + id="event_definition", + label="Event definition", + kind="select", + options=( + ("none", "None"), + ("message", "Message"), + ("timer", "Timer"), + ("conditional", "Conditional"), + ("signal", "Signal"), + ("error", "Error"), + ("escalation", "Escalation"), + ("compensation", "Compensation"), + ("link", "Link"), + ("cancel", "Cancel"), + ("terminate", "Terminate"), + ("multiple", "Multiple"), + ("parallelMultiple", "Parallel multiple"), + ), +) + + +def _bpmn_node( + *, + type_id: str, + category: str, + label: str, + description: str, + icon: str, + shape: str, + input_ports: tuple[DefinitionPort, ...] = _INCOMING, + output_ports: tuple[DefinitionPort, ...] = _OUTGOING, + config_fields: tuple[DefinitionConfigField, ...] = (), + default_config: dict[str, object] | None = None, + runtime_support: str = "model_only", +) -> DefinitionNodeType: + return DefinitionNodeType( + type=type_id, + category=category, + label=label, + description=description, + icon=icon, + input_ports=input_ports, + output_ports=output_ports, + config_fields=(*config_fields, _DOCUMENTATION_FIELD), + default_config={ + **(default_config or {}), + "documentation": "", + }, + metadata={ + "notation": "bpmn-2.0", + "shape": shape, + "runtime_support": runtime_support, + }, + ) + + +_TASK_FIELDS = ( + DefinitionConfigField(id="title", label="Task title", kind="text"), + DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), +) +_HUMAN_TASK_FIELDS = ( + *_TASK_FIELDS, + DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), + DefinitionConfigField(id="due_after", label="Due after", kind="duration"), + FOCUSED_VIEW_SURFACES_FIELD, +) +_SERVICE_TASK_FIELDS = ( + *_TASK_FIELDS, + DefinitionConfigField( + id="implementation", + label="Implementation", + kind="select", + options=( + ("capability", "Module capability"), + ("dataflow", "Dataflow"), + ), + ), + DefinitionConfigField(id="capability", label="Capability", kind="capability"), + DefinitionConfigField(id="operation", label="Operation", kind="text"), + DefinitionConfigField(id="pipeline_ref", label="Dataflow", kind="dataflow"), + DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), + DefinitionConfigField( + id="idempotency_key", + label="Idempotency key", + kind="expression", + ), + DefinitionConfigField( + id="failure_policy", + label="On failure", + kind="select", + options=( + ("retry", "Retry"), + ("manual", "Require intervention"), + ("continue", "Continue"), + ("fail", "Fail the workflow"), + ), + ), + FOCUSED_VIEW_SURFACES_FIELD, +) + +BPMN_NODE_TYPES = ( + _bpmn_node( + type_id="bpmn.startEvent", + category="bpmn_event", + label="Start event", + description="Start a BPMN process from a user, API, schedule, event, or parent flow.", + icon="circle-play", + shape="event-start", + input_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="start_kind", + label="GovOPlaN start", + kind="select", + required=True, + options=( + ("manual", "Manual"), + ("api", "API"), + ("schedule", "Schedule"), + ("event", "Platform event"), + ("parent_workflow", "Parent workflow"), + ), + ), + DefinitionConfigField( + id="input_schema_ref", + label="Input schema", + kind="text", + ), + DefinitionConfigField( + id="authorization_policy_ref", + label="Authorization policy", + kind="text", + ), + DefinitionConfigField(id="schedule", label="Schedule", kind="schedule"), + DefinitionConfigField(id="timezone", label="Time zone", kind="timezone"), + DefinitionConfigField(id="event_type", label="Event type", kind="text"), + DefinitionConfigField(id="filter", label="Event filter", kind="expression"), + ), + default_config={ + "event_definition": "none", + "start_kind": "manual", + "input_schema_ref": "", + "authorization_policy_ref": "", + "schedule": "", + "timezone": "Europe/Berlin", + "event_type": "", + "filter": "", + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.intermediateCatchEvent", + category="bpmn_event", + label="Intermediate catch event", + description="Wait until the configured BPMN event is caught.", + icon="circle-dot", + shape="event-intermediate-catch", + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="wait_mode", + label="Wait for", + kind="select", + options=( + ("duration", "Duration"), + ("deadline", "Deadline"), + ("event", "Event"), + ("manual", "Manual resume"), + ), + ), + DefinitionConfigField(id="value", label="Value", kind="text"), + DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "event_definition": "none", + "wait_mode": "manual", + "value": "", + "event_ref": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.intermediateThrowEvent", + category="bpmn_event", + label="Intermediate throw event", + description="Emit an intermediate BPMN event.", + icon="circle-dot-dashed", + shape="event-intermediate-throw", + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), + ), + default_config={"event_definition": "none", "event_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.boundaryEvent", + category="bpmn_event", + label="Boundary event", + description="Catch an interrupting or non-interrupting event attached to an activity.", + icon="circle-dashed", + shape="event-boundary", + input_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="attached_to_ref", + label="Attached activity", + kind="text", + required=True, + ), + DefinitionConfigField( + id="cancel_activity", + label="Activity behavior", + kind="select", + options=( + ("true", "Interrupt activity"), + ("false", "Keep activity running"), + ), + ), + ), + default_config={ + "event_definition": "none", + "attached_to_ref": "", + "cancel_activity": "true", + }, + ), + _bpmn_node( + type_id="bpmn.endEvent", + category="bpmn_event", + label="End event", + description="End a BPMN process with an optional result event.", + icon="circle-stop", + shape="event-end", + output_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="outcome", + label="GovOPlaN outcome", + kind="select", + options=( + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + ), + DefinitionConfigField( + id="output_mapping", + label="Output mapping", + kind="mapping", + ), + ), + default_config={ + "event_definition": "none", + "outcome": "completed", + "output_mapping": {}, + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.task", + category="bpmn_activity", + label="Task", + description="A generic BPMN task completed as a governed human activity.", + icon="square", + shape="activity", + config_fields=_HUMAN_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.userTask", + category="bpmn_activity", + label="User task", + description="A task requiring a user action, review, or evidence.", + icon="user-round-check", + shape="activity", + config_fields=( + *_HUMAN_TASK_FIELDS, + DefinitionConfigField( + id="task_mode", + label="Task mode", + kind="select", + options=( + ("activity", "Activity"), + ("review", "Review"), + ), + ), + DefinitionConfigField( + id="required_evidence", + label="Required evidence", + kind="string_list", + ), + ), + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "task_mode": "activity", + "required_evidence": [], + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.manualTask", + category="bpmn_activity", + label="Manual task", + description="A task performed outside the automated runtime.", + icon="hand", + shape="activity", + config_fields=_HUMAN_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.serviceTask", + category="bpmn_activity", + label="Service task", + description="Invoke a versioned module capability or published Dataflow.", + icon="cog", + shape="activity", + config_fields=_SERVICE_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "implementation": "capability", + "capability": "", + "operation": "", + "pipeline_ref": "", + "input_mapping": {}, + "idempotency_key": "workflow-step", + "failure_policy": "manual", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.sendTask", + category="bpmn_activity", + label="Send task", + description="Send a message through a configured implementation.", + icon="send", + shape="activity", + config_fields=_SERVICE_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "implementation": "capability", + "capability": "", + "operation": "", + "pipeline_ref": "", + "input_mapping": {}, + "idempotency_key": "workflow-step", + "failure_policy": "manual", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.receiveTask", + category="bpmn_activity", + label="Receive task", + description="Wait until a matching message is received.", + icon="inbox", + shape="activity", + config_fields=( + *_TASK_FIELDS, + DefinitionConfigField(id="message_ref", label="Message reference", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "title": "", + "instructions": "", + "message_ref": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_activity", + label=label, + description=description, + icon=icon, + shape="activity", + config_fields=fields, + default_config=defaults, + ) + for type_name, label, description, icon, fields, defaults in ( + ( + "scriptTask", + "Script task", + "A BPMN script task retained as notation; arbitrary scripts are not executed.", + "file-code-2", + (*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")), + {"title": "", "instructions": "", "script_format": "", "script": ""}, + ), + ( + "businessRuleTask", + "Business rule task", + "Evaluate a governed business-rule implementation.", + "scale", + (*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")), + {"title": "", "instructions": "", "implementation_ref": ""}, + ), + ( + "callActivity", + "Call activity", + "Call another reusable BPMN process or GovOPlaN workflow.", + "external-link", + (*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)), + {"title": "", "instructions": "", "called_element": ""}, + ), + ( + "subProcess", + "Sub-process", + "Contain a nested BPMN process.", + "box-select", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ( + "transaction", + "Transaction", + "Contain transaction-scoped BPMN activity semantics.", + "badge-dollar-sign", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ( + "adHocSubProcess", + "Ad-hoc sub-process", + "Contain activities whose order is governed at runtime.", + "shuffle", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ) + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_gateway", + label=label, + description=description, + icon=icon, + shape="gateway", + config_fields=(), + default_config={}, + runtime_support=runtime_support, + ) + for type_name, label, description, icon, runtime_support in ( + ("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"), + ("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"), + ("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"), + ("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"), + ("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"), + ) + ), + _bpmn_node( + type_id="bpmn.dataObjectReference", + category="bpmn_data", + label="Data object", + description="Reference data produced or consumed by an activity.", + icon="file", + shape="data-object", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"), + DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), + ), + default_config={"data_object_ref": "", "item_subject_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.dataStoreReference", + category="bpmn_data", + label="Data store", + description="Reference persistent data used by the process.", + icon="database", + shape="data-store", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"), + DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), + ), + default_config={"data_store_ref": "", "item_subject_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.participant", + category="bpmn_collaboration", + label="Participant / pool", + description="Represent a BPMN collaboration participant and its process.", + icon="rectangle-horizontal", + shape="participant", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="process_ref", label="Process reference", kind="text"), + ), + default_config={"process_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.lane", + category="bpmn_collaboration", + label="Lane", + description="Group flow nodes by role or responsibility.", + icon="rows-3", + shape="lane", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"), + ), + default_config={"flow_node_refs": []}, + ), + _bpmn_node( + type_id="bpmn.textAnnotation", + category="bpmn_artifact", + label="Text annotation", + description="Attach explanatory text without changing process execution.", + icon="text-quote", + shape="text-annotation", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="text", label="Text", kind="textarea"), + DefinitionConfigField(id="text_format", label="Text format", kind="text"), + ), + default_config={"text": "", "text_format": "text/plain"}, + ), + _bpmn_node( + type_id="bpmn.group", + category="bpmn_artifact", + label="Group", + description="Visually group BPMN elements without changing execution.", + icon="box-select", + shape="group", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"), + ), + default_config={"category_value_ref": ""}, + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_collaboration", + label=label, + description=description, + icon=icon, + shape=shape, + input_ports=ports, + config_fields=( + DefinitionConfigField( + id="participant_refs", + label="Participants", + kind="string_list", + ), + DefinitionConfigField( + id="called_element", + label="Called element", + kind="text", + ), + ), + default_config={"participant_refs": [], "called_element": ""}, + ) + for type_name, label, description, icon, shape, ports in ( + ( + "choreographyTask", + "Choreography task", + "Model a message exchange between two or more participants.", + "messages-square", + "choreography", + _INCOMING, + ), + ( + "callChoreography", + "Call choreography", + "Call a reusable choreography.", + "external-link", + "choreography", + _INCOMING, + ), + ( + "subChoreography", + "Sub-choreography", + "Contain a nested choreography.", + "box-select", + "choreography", + _INCOMING, + ), + ( + "conversation", + "Conversation", + "Group logically related message exchanges.", + "messages-square", + "conversation", + _OPTIONAL_INCOMING, + ), + ( + "callConversation", + "Call conversation", + "Call a reusable global conversation.", + "external-link", + "conversation", + _OPTIONAL_INCOMING, + ), + ( + "subConversation", + "Sub-conversation", + "Contain a nested conversation.", + "box-select", + "conversation", + _OPTIONAL_INCOMING, + ), + ) + ), +) + +WORKFLOW_NODE_TYPES = (*BPMN_NODE_TYPES, *LEGACY_WORKFLOW_NODE_TYPES) + +WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary( + id="workflow", + version="1.0.0", + category_labels=CATEGORY_LABELS, + node_types=WORKFLOW_NODE_TYPES, + constraints=DefinitionGraphConstraints( + max_nodes=150, + max_edges=300, + allow_cycles=True, + require_connected=False, + ), +) + +WORKFLOW_NODE_TYPES_BY_ID = { + definition.type: definition for definition in WORKFLOW_NODE_TYPES +} + + +__all__ = [ + "BPMN_NODE_TYPES", + "CATEGORY_LABELS", + "LEGACY_WORKFLOW_NODE_TYPES", + "WORKFLOW_GRAPH_LIBRARY", + "WORKFLOW_NODE_TYPES", + "WORKFLOW_NODE_TYPES_BY_ID", +] diff --git a/src/govoplan_workflow_engine/backend/router.py b/src/govoplan_workflow_engine/backend/router.py new file mode 100644 index 0000000..f46681f --- /dev/null +++ b/src/govoplan_workflow_engine/backend/router.py @@ -0,0 +1,1468 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.api.v1.schemas import ( + ReferenceOptionListResponse, + ReferenceOptionResponse, +) +from govoplan_core.audit.logging import audit_event +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.references import ( + access_scope_reference_page, + access_scope_reference_provider_available, + validate_access_scope_reference, +) +from govoplan_core.db.session import get_session +from govoplan_workflow_engine.backend.governance import ( + definition_decision, + normalize_definition_scope, + require_definition_action, +) +from govoplan_workflow_engine.backend.contributions import ( + reconcile_workflow_definition_contributions, + reset_workflow_override_to_standard, +) +from govoplan_workflow_engine.backend.manifest import ( + ADMIN_SCOPE, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + INSTANCE_READ_SCOPE, + INSTANCE_START_SCOPE, + INSTANCE_TRANSITION_SCOPE, +) +from govoplan_workflow_engine.backend.node_library import ( + BPMN_NODE_TYPES, + WORKFLOW_GRAPH_LIBRARY, +) +from govoplan_workflow_engine.backend.bpmn import ( + BPMN_MODEL_NAMESPACE, + BpmnDiagnostic, + BpmnInspectionError, + NATIVE_EXECUTION_ELEMENTS, + NATIVE_MAPPING_ELEMENTS, + inspect_bpmn_xml, +) +from govoplan_workflow_engine.backend.bpmn_adapters import ( + BpmnAdapterError, + bpmn_adapter_registry, + compile_bpmn_to_graph, +) +from govoplan_workflow_engine.backend.bpmn_graph import ( + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + canonical_bpmn_graph, + export_bpmn_graph, +) +from govoplan_workflow_engine.backend.schemas import ( + BpmnAdapterProfileResponse, + BpmnCompileRequest, + BpmnCompileResponse, + BpmnDiagnosticResponse, + BpmnElementSupportResponse, + BpmnInspectionRequest, + BpmnInspectionResponse, + BpmnRevisionDocumentResponse, + BpmnRenderRequest, + BpmnRenderResponse, + BpmnSupportProfileResponse, + WorkflowConfigFieldResponse, + WorkflowDefinitionActivateRequest, + WorkflowDefinitionCreateRequest, + WorkflowDefinitionDeleteResponse, + WorkflowDefinitionDeriveRequest, + WorkflowDefinitionListResponse, + WorkflowDefinitionResponse, + WorkflowDefinitionRevisionListResponse, + WorkflowDefinitionRevisionResponse, + WorkflowDefinitionUpdateRequest, + WorkflowDiagnosticResponse, + WorkflowGraphValidationRequest, + WorkflowGraphValidationResponse, + WorkflowInstanceListResponse, + WorkflowInstanceResponse, + WorkflowInstanceStartRequest, + WorkflowNodeLibraryResponse, + WorkflowNodeTypeResponse, + WorkflowPortResponse, + WorkflowStepActionRequest, +) +from govoplan_workflow_engine.backend.instance_service import ( + cancel_instance, + get_instance, + instance_response, + list_instances, + reconcile_instance, + resolve_step, + start_instance, +) +from govoplan_workflow_engine.backend.runtime import get_registry +from govoplan_workflow_engine.backend.service import ( + WorkflowBpmnValidationError, + WorkflowConflictError, + WorkflowError, + WorkflowNotFoundError, + WorkflowValidationError, + activate_definition, + archive_definition, + create_definition, + definition_response, + delete_definition, + derive_definition, + get_definition, + get_definition_revision, + list_definition_revisions, + list_definitions, + revision_response, + revision_bpmn_summary, + update_definition, +) +from govoplan_workflow_engine.backend.validation import validate_workflow_graph + + +router = APIRouter(prefix="/workflow", tags=["workflow"]) + + +def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: + if any(has_scope(principal, scope) for scope in scopes): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing one of the required scopes: {', '.join(scopes)}", + ) + + +def _http_error(exc: WorkflowError) -> HTTPException: + if isinstance(exc, WorkflowNotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + if isinstance(exc, WorkflowConflictError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if isinstance(exc, WorkflowBpmnValidationError): + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": str(exc), + "diagnostics": [ + { + "severity": item.severity, + "code": item.code, + "message": item.message, + "element_id": item.element_id, + } + for item in exc.diagnostics + ], + }, + ) + if isinstance(exc, WorkflowValidationError): + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": str(exc), + "diagnostics": [ + { + "severity": item.severity, + "code": item.code, + "message": item.message, + "node_id": item.node_id, + "field": item.field, + } + for item in exc.diagnostics + ], + }, + ) + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) + + +def _governance_http_error( + exc: PermissionError | ValueError, +) -> HTTPException: + return HTTPException( + status_code=( + status.HTTP_403_FORBIDDEN + if isinstance(exc, PermissionError) + else status.HTTP_422_UNPROCESSABLE_CONTENT + ), + detail=str(exc), + ) + + +def _definition_response( + session: Session, + definition, + principal: ApiPrincipal, + *, + revision: int | None = None, +) -> WorkflowDefinitionResponse: + return definition_response( + session, + definition, + principal=principal, + registry=get_registry(), + revision=revision, + ) + + +def _actor_id(principal: ApiPrincipal) -> str | None: + return principal.account_id or principal.membership_id or principal.identity_id + + +def _audit( + session: Session, + principal: ApiPrincipal, + *, + action: str, + definition_id: str, + details: dict[str, object], +) -> None: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=getattr(principal.user, "id", None), + api_key_id=principal.api_key_id, + action=action, + object_type="workflow_definition", + object_id=definition_id, + details=details, + ) + + +def _audit_instance( + session: Session, + principal: ApiPrincipal, + *, + action: str, + instance_id: str, + details: dict[str, object], +) -> None: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=getattr(principal.user, "id", None), + api_key_id=principal.api_key_id, + action=action, + object_type="workflow_instance", + object_id=instance_id, + details=details, + ) + + +def _require_instance_view(instance, principal: ApiPrincipal) -> None: + require_definition_action( + instance.definition, + principal=principal, + registry=get_registry(), + action="view", + ) + + +def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse: + return BpmnAdapterProfileResponse( + id=profile.id, + version=profile.version, + label=profile.label, + description=profile.description, + conformance=profile.conformance, + runtime_kind=profile.runtime_kind, + executable=profile.executable, + supported_elements=list(profile.supported_elements), + supported_event_definitions=list(profile.supported_event_definitions), + requirements=list(profile.requirements), + ) + + +def _bpmn_inspection_response( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool, +) -> BpmnInspectionResponse: + result = inspect_bpmn_xml(xml) + adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) + adapter_diagnostics = ( + adapter.diagnostics(xml, result, activation=activation) + if adapter is not None + else () + ) + diagnostics = list(result.diagnostics) + diagnostics.extend(adapter_diagnostics) + if adapter is None: + suffix = f"@{adapter_version}" if adapter_version else "" + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.unavailable", + message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.", + ) + ) + deduplicated = { + (item.code, item.element_id, item.message): item + for item in diagnostics + }.values() + diagnostic_items = list(deduplicated) + return BpmnInspectionResponse( + valid_xml=result.valid_xml, + definitions_id=result.definitions_id, + target_namespace=result.target_namespace, + process_count=result.process_count, + executable_process_count=result.executable_process_count, + collaboration_count=result.collaboration_count, + choreography_count=result.choreography_count, + element_counts=result.element_counts, + support_counts=result.support_counts, + elements=[ + BpmnElementSupportResponse( + element_type=item.element_type, + element_id=item.element_id, + name=item.name, + parent_type=item.parent_type, + parent_id=item.parent_id, + support_level=item.support_level, + ) + for item in result.elements + ], + diagnostics=[ + BpmnDiagnosticResponse( + severity=item.severity, + code=item.code, + message=item.message, + element_id=item.element_id, + ) + for item in diagnostic_items + ], + adapter_id=adapter.profile.id if adapter else adapter_id, + adapter_version=( + adapter.profile.version if adapter else adapter_version + ), + runtime_kind=adapter.profile.runtime_kind if adapter else None, + executable=bool(adapter and adapter.profile.executable), + activatable=bool( + activation + and adapter + and adapter.profile.executable + and not any(item.severity == "error" for item in diagnostic_items) + ), + ) + + +@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) +def api_bpmn_support_profile( + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnSupportProfileResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + return BpmnSupportProfileResponse( + specification="BPMN 2.0.2", + model_namespace=BPMN_MODEL_NAMESPACE, + interchange=( + "BPMN 2.0 is the native Workflow graph language. XML import and " + "export preserve standard notation and diagram geometry without " + "a browser-side BPMN modeler." + ), + native_runtime=( + "Activation is fail-closed and requires a pinned adapter whose " + "declared conformance profile covers the complete document." + ), + native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), + native_mapping_elements=sorted( + NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS + ), + adapters=[ + _adapter_profile_response(profile) + for profile in bpmn_adapter_registry().profiles() + ], + ) + + +@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) +def api_inspect_bpmn( + payload: BpmnInspectionRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnInspectionResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + return _bpmn_inspection_response( + payload.xml, + adapter_id=payload.adapter_id, + adapter_version=payload.adapter_version, + activation=payload.activation, + ) + except (BpmnAdapterError, BpmnInspectionError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.post("/bpmn/compile", response_model=BpmnCompileResponse) +def api_compile_bpmn( + payload: BpmnCompileRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnCompileResponse: + _require_any_scope( + principal, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + adapter, _inspection, graph = compile_bpmn_to_graph( + payload.xml, + adapter_id=payload.adapter_id, + adapter_version=payload.adapter_version, + ) + if graph is None: + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.no_runtime_materialization", + message="The selected adapter does not compile to a native graph.", + ), + ) + ) + inspection_response = _bpmn_inspection_response( + payload.xml, + adapter_id=adapter.profile.id, + adapter_version=adapter.profile.version, + activation=False, + ) + except (BpmnAdapterError, BpmnInspectionError) as exc: + diagnostics = getattr(exc, "diagnostics", ()) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": str(exc), + "diagnostics": [ + { + "severity": item.severity, + "code": item.code, + "message": item.message, + "element_id": item.element_id, + } + for item in diagnostics + ], + }, + ) from exc + return BpmnCompileResponse( + adapter=_adapter_profile_response(adapter.profile), + graph=graph, + inspection=inspection_response, + ) + + +@router.post("/bpmn/render", response_model=BpmnRenderResponse) +def api_render_bpmn( + payload: BpmnRenderRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnRenderResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + xml = export_bpmn_graph( + canonical_bpmn_graph(payload.graph), + name=payload.name, + ) + inspection = _bpmn_inspection_response( + xml, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + adapter_version=NATIVE_BPMN_ADAPTER_VERSION, + activation=False, + ) + except (BpmnGraphError, BpmnInspectionError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return BpmnRenderResponse(xml=xml, inspection=inspection) + + +@router.get("/node-types", response_model=WorkflowNodeLibraryResponse) +def api_node_types( + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowNodeLibraryResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + return WorkflowNodeLibraryResponse( + id=WORKFLOW_GRAPH_LIBRARY.id, + version=WORKFLOW_GRAPH_LIBRARY.version, + allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles, + nodes=[ + WorkflowNodeTypeResponse( + type=definition.type, + category=definition.category, + category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category], + label=definition.label, + description=definition.description, + icon=definition.icon, + input_ports=[ + WorkflowPortResponse( + id=port.id, + label=port.label, + required=port.required, + multiple=port.multiple, + minimum_connections=port.minimum_connections, + ) + for port in definition.input_ports + ], + output_ports=[ + WorkflowPortResponse( + id=port.id, + label=port.label, + required=port.required, + multiple=port.multiple, + minimum_connections=port.minimum_connections, + ) + for port in definition.output_ports + ], + config_fields=[ + WorkflowConfigFieldResponse( + id=field.id, + label=field.label, + kind=field.kind, + required=field.required, + description=field.description, + options=list(field.options), + ) + for field in definition.config_fields + ], + default_config=dict(definition.default_config), + metadata=dict(definition.metadata), + ) + for definition in BPMN_NODE_TYPES + ], + ) + + +@router.get("/scope-targets", response_model=ReferenceOptionListResponse) +def api_scope_targets( + scope_type: str, + q: str = "", + selected: list[str] = Query(default=[]), + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> ReferenceOptionListResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + registry = get_registry() + try: + page = access_scope_reference_page( + registry, + principal, + scope_type=scope_type, + query=q, + selected_values=selected, + limit=limit, + cursor=cursor, + administrative=has_scope(principal, ADMIN_SCOPE), + session=session, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return ReferenceOptionListResponse( + options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options], + provider_available=access_scope_reference_provider_available(registry), + next_cursor=page.next_cursor, + has_more=page.has_more, + ) + + +@router.post( + "/definitions/validate", + response_model=WorkflowGraphValidationResponse, +) +def api_validate_definition( + payload: WorkflowGraphValidationRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowGraphValidationResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + diagnostics = validate_workflow_graph(payload.graph) + return WorkflowGraphValidationResponse( + valid=not any(item.severity == "error" for item in diagnostics), + diagnostics=[ + WorkflowDiagnosticResponse( + severity=item.severity, + code=item.code, + message=item.message, + node_id=item.node_id, + field=item.field, + ) + for item in diagnostics + ], + ) + + +@router.get("/definitions", response_model=WorkflowDefinitionListResponse) +def api_list_definitions( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionListResponse: + _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) + registry = get_registry() + definitions = [ + definition + for definition in list_definitions( + session, + tenant_id=principal.tenant_id, + ) + if definition_decision( + definition, + principal=principal, + registry=registry, + action="view", + ).allowed + ] + return WorkflowDefinitionListResponse( + definitions=[ + definition_response( + session, + definition, + principal=principal, + registry=registry, + ) + for definition in definitions + ] + ) + + +@router.get("/instances", response_model=WorkflowInstanceListResponse) +def api_list_instances( + definition_id: str | None = None, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceListResponse: + _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) + try: + instances = [ + instance + for instance in list_instances( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + limit=limit, + ) + if definition_decision( + instance.definition, + principal=principal, + registry=get_registry(), + action="view", + ).allowed + ] + return WorkflowInstanceListResponse( + instances=[ + instance_response(session, instance) + for instance in instances + ] + ) + except WorkflowError as exc: + raise _http_error(exc) from exc + + +@router.post("/standards/reconcile", response_model=dict[str, object]) +def api_reconcile_standards( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, object]: + _require_any_scope(principal, ADMIN_SCOPE) + result = reconcile_workflow_definition_contributions( + session, + registry=get_registry(), + tenant_ids=(principal.tenant_id,), + ) + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=getattr(principal.user, "id", None), + api_key_id=principal.api_key_id, + action="workflow.standards.reconciled", + object_type="workflow_standard_catalogue", + object_id="module-contributions", + details={ + key: value + for key, value in result.items() + if key != "items" + }, + ) + session.commit() + return result + + +@router.post( + "/definitions/{definition_id}/instances", + response_model=WorkflowInstanceResponse, + status_code=status.HTTP_201_CREATED, +) +def api_start_instance( + definition_id: str, + payload: WorkflowInstanceStartRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceResponse: + _require_any_scope(principal, INSTANCE_START_SCOPE, ADMIN_SCOPE) + try: + instance, replayed = start_instance( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + principal=principal, + registry=get_registry(), + payload=payload, + start_origin=( + "user" if principal.auth_method == "session" else "api" + ), + ) + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit_instance( + session, + principal, + action=( + "workflow.instance.replayed" + if replayed + else "workflow.instance.started" + ), + instance_id=instance.id, + details={ + "definition_id": instance.definition_id, + "definition_revision_id": instance.definition_revision_id, + "idempotency_key": instance.idempotency_key, + "start_origin": instance.start_origin, + }, + ) + response = instance_response(session, instance, replayed=replayed) + session.commit() + return response + + +@router.post( + "/definitions/{definition_id}/reset-standard", + response_model=WorkflowDefinitionResponse, +) +def api_reset_definition_to_standard( + definition_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + override = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + override, + principal=principal, + registry=get_registry(), + action="edit", + ) + baseline = reset_workflow_override_to_standard( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.reset_to_standard", + definition_id=definition_id, + details={ + "baseline_definition_id": baseline.id, + "baseline_revision": baseline.current_revision, + "baseline_hash": baseline.standard_contribution_hash, + }, + ) + response = _definition_response(session, baseline, principal) + session.commit() + return response + + +@router.get( + "/instances/{instance_id}", + response_model=WorkflowInstanceResponse, +) +def api_get_instance( + instance_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceResponse: + _require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE) + try: + instance = get_instance( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + ) + _require_instance_view(instance, principal) + return instance_response(session, instance) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + + +@router.post( + "/instances/{instance_id}/reconcile", + response_model=WorkflowInstanceResponse, +) +def api_reconcile_instance( + instance_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceResponse: + _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) + try: + instance = get_instance( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + for_update=True, + ) + _require_instance_view(instance, principal) + changed = reconcile_instance( + session, + instance=instance, + principal=principal, + registry=get_registry(), + actor_id=_actor_id(principal), + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + if changed: + _audit_instance( + session, + principal, + action="workflow.instance.reconciled", + instance_id=instance.id, + details={ + "status": instance.status, + "current_step_id": instance.current_step_id, + }, + ) + response = instance_response(session, instance) + session.commit() + return response + + +@router.post( + "/instances/{instance_id}/steps/{step_id}/actions", + response_model=WorkflowInstanceResponse, +) +def api_resolve_instance_step( + instance_id: str, + step_id: str, + payload: WorkflowStepActionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceResponse: + _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) + try: + existing = get_instance( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + ) + _require_instance_view(existing, principal) + instance = resolve_step( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + step_id=step_id, + actor_id=_actor_id(principal), + principal=principal, + registry=get_registry(), + payload=payload, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit_instance( + session, + principal, + action=f"workflow.instance.{payload.action}", + instance_id=instance.id, + details={ + "step_id": step_id, + "status": instance.status, + }, + ) + response = instance_response(session, instance) + session.commit() + return response + + +@router.post( + "/instances/{instance_id}/cancel", + response_model=WorkflowInstanceResponse, +) +def api_cancel_instance( + instance_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowInstanceResponse: + _require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE) + try: + instance = get_instance( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + ) + _require_instance_view(instance, principal) + instance = cancel_instance( + session, + tenant_id=principal.tenant_id, + instance_id=instance_id, + actor_id=_actor_id(principal), + principal=principal, + registry=get_registry(), + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit_instance( + session, + principal, + action="workflow.instance.cancelled", + instance_id=instance.id, + details={"status": instance.status}, + ) + response = instance_response(session, instance) + session.commit() + return response + + +@router.post( + "/definitions", + response_model=WorkflowDefinitionResponse, + status_code=status.HTTP_201_CREATED, +) +def api_create_definition( + payload: WorkflowDefinitionCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + tenant_id, scope_type, scope_id, _scope_key = ( + normalize_definition_scope( + principal, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + administrative=has_scope(principal, ADMIN_SCOPE), + ) + ) + payload = payload.model_copy( + update={"scope_type": scope_type, "scope_id": scope_id} + ) + canonical_scope_id = validate_access_scope_reference( + get_registry(), + tenant_id=tenant_id or principal.tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + payload = payload.model_copy(update={"scope_id": canonical_scope_id}) + definition = create_definition( + session, + tenant_id=tenant_id or principal.tenant_id, + actor_id=_actor_id(principal), + payload=payload, + ) + except (PermissionError, ValueError) as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.created", + definition_id=definition.id, + details={ + "key": definition.definition_key, + "revision": definition.current_revision, + "scope_type": definition.scope_type, + "scope_id": definition.scope_id, + "definition_kind": definition.definition_kind, + }, + ) + response = _definition_response(session, definition, principal) + session.commit() + return response + + +@router.get( + "/definitions/{definition_id}", + response_model=WorkflowDefinitionResponse, +) +def api_get_definition( + definition_id: str, + revision: int | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _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 _definition_response( + session, + definition, + principal, + revision=revision, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + + +@router.put( + "/definitions/{definition_id}", + response_model=WorkflowDefinitionResponse, +) +def api_update_definition( + definition_id: str, + payload: WorkflowDefinitionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + existing = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + existing, + principal=principal, + registry=get_registry(), + action="edit", + ) + tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope( + principal, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + administrative=has_scope(principal, ADMIN_SCOPE), + ) + scope_id = validate_access_scope_reference( + get_registry(), + tenant_id=tenant_id or principal.tenant_id, + scope_type=scope_type, + scope_id=scope_id, + preserve_existing=( + existing.scope_id + if existing.scope_type == scope_type + else None + ), + ) + payload = payload.model_copy( + update={"scope_type": scope_type, "scope_id": scope_id} + ) + definition = update_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + payload=payload, + ) + except (PermissionError, ValueError) as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.updated", + definition_id=definition.id, + details={ + "revision": definition.current_revision, + "status": definition.status, + }, + ) + response = _definition_response(session, definition, principal) + session.commit() + return response + + +@router.post( + "/definitions/{definition_id}/derive", + response_model=WorkflowDefinitionResponse, + status_code=status.HTTP_201_CREATED, +) +def api_derive_definition( + definition_id: str, + payload: WorkflowDefinitionDeriveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + tenant_id, scope_type, scope_id, _scope_key = ( + normalize_definition_scope( + principal, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + administrative=has_scope(principal, ADMIN_SCOPE), + ) + ) + payload = payload.model_copy( + update={"scope_type": scope_type, "scope_id": scope_id} + ) + canonical_scope_id = validate_access_scope_reference( + get_registry(), + tenant_id=tenant_id or principal.tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + payload = payload.model_copy(update={"scope_id": canonical_scope_id}) + definition = derive_definition( + session, + tenant_id=tenant_id or principal.tenant_id, + actor_id=_actor_id(principal), + principal=principal, + registry=get_registry(), + source_definition_id=definition_id, + payload=payload, + ) + except (PermissionError, ValueError) as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.derived", + definition_id=definition.id, + details={ + "source_definition_id": ( + definition.derived_from_definition_id + ), + "source_revision": definition.derived_from_revision, + "source_hash": definition.derived_from_hash, + "scope_type": definition.scope_type, + "scope_id": definition.scope_id, + }, + ) + response = _definition_response(session, definition, principal) + session.commit() + return response + + +@router.get( + "/definitions/{definition_id}/revisions", + response_model=WorkflowDefinitionRevisionListResponse, +) +def api_list_definition_revisions( + definition_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionRevisionListResponse: + _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", + ) + revisions = list_definition_revisions(session, definition=definition) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + return WorkflowDefinitionRevisionListResponse( + revisions=[revision_response(item) for item in revisions] + ) + + +@router.get( + "/definitions/{definition_id}/revisions/{revision}", + response_model=WorkflowDefinitionRevisionResponse, +) +def api_get_definition_revision( + definition_id: str, + revision: int, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionRevisionResponse: + _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", + ) + item = get_definition_revision( + session, + definition=definition, + revision=revision, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + return revision_response(item) + + +@router.get( + "/definitions/{definition_id}/revisions/{revision}/bpmn", + response_model=BpmnRevisionDocumentResponse, +) +def api_get_definition_revision_bpmn( + definition_id: str, + revision: int, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnRevisionDocumentResponse: + _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", + ) + item = get_definition_revision( + session, + definition=definition, + revision=revision, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + summary = revision_bpmn_summary(item) + if summary is None or not item.bpmn_xml: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="This Workflow revision has no BPMN document.", + ) + try: + inspection = _bpmn_inspection_response( + item.bpmn_xml, + adapter_id=summary.adapter_id, + adapter_version=summary.adapter_version, + activation=True, + ) + except BpmnInspectionError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return BpmnRevisionDocumentResponse( + **summary.model_dump(), + definition_id=definition.id, + revision=item.revision, + xml=item.bpmn_xml, + inspection=inspection, + ) + + +@router.post( + "/definitions/{definition_id}/activate", + response_model=WorkflowDefinitionResponse, +) +def api_activate_definition( + definition_id: str, + payload: WorkflowDefinitionActivateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + existing = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + existing, + principal=principal, + registry=get_registry(), + action="edit", + ) + definition = activate_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + revision=payload.revision, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.activated", + definition_id=definition.id, + details={"active_revision": definition.active_revision}, + ) + response = _definition_response(session, definition, principal) + session.commit() + return response + + +@router.post( + "/definitions/{definition_id}/archive", + response_model=WorkflowDefinitionResponse, +) +def api_archive_definition( + definition_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + existing = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + existing, + principal=principal, + registry=get_registry(), + action="edit", + ) + definition = archive_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.archived", + definition_id=definition.id, + details={"active_revision": definition.active_revision}, + ) + response = _definition_response(session, definition, principal) + session.commit() + return response + + +@router.delete( + "/definitions/{definition_id}", + response_model=WorkflowDefinitionDeleteResponse, +) +def api_delete_definition( + definition_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> WorkflowDefinitionDeleteResponse: + _require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE) + try: + existing = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + existing, + principal=principal, + registry=get_registry(), + action="edit", + ) + definition = delete_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + actor_id=_actor_id(principal), + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + _audit( + session, + principal, + action="workflow.definition.deleted", + definition_id=definition.id, + details={"revision": definition.current_revision}, + ) + session.commit() + return WorkflowDefinitionDeleteResponse( + deleted=True, + definition_id=definition.id, + ) + + +__all__ = ["router"] diff --git a/src/govoplan_workflow_engine/backend/runtime.py b/src/govoplan_workflow_engine/backend/runtime.py new file mode 100644 index 0000000..e947e73 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/runtime.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from govoplan_core.core.runtime import ModuleRuntimeState + + +_runtime = ModuleRuntimeState("Workflow") + +configure_runtime = _runtime.configure_runtime +get_registry = _runtime.get_registry +get_settings = _runtime.get_settings +settings = _runtime.settings diff --git a/src/govoplan_workflow_engine/backend/schemas.py b/src/govoplan_workflow_engine/backend/schemas.py new file mode 100644 index 0000000..b9061d9 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/schemas.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import math +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + + +WorkflowDefinitionStatus = Literal["draft", "active", "archived"] +DefinitionScopeType = Literal["system", "tenant", "group", "user"] +DefinitionKind = Literal["flow", "template"] +BpmnRuntimeKind = Literal["model_only", "native_graph", "external"] +WorkflowExecutionMode = Literal["guided", "automated", "hybrid"] +WorkflowStartOrigin = Literal[ + "user", + "api", + "schedule", + "event", + "parent_workflow", + "dependency", + "retry", + "replay", + "backfill", +] +BpmnSupportLevel = Literal[ + "interchange_only", + "native_mapping", + "native_execution", +] + + +class WorkflowPosition(BaseModel): + x: float = 0 + y: float = 0 + + @field_validator("x", "y") + @classmethod + def finite_coordinate(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("Graph coordinates must be finite.") + return value + + +class WorkflowSize(BaseModel): + width: float = Field(default=100, gt=0, le=10_000) + height: float = Field(default=80, gt=0, le=10_000) + + +class WorkflowNode(BaseModel): + id: str = Field(min_length=1, max_length=120) + type: str = Field(min_length=1, max_length=120) + label: str = Field(default="", max_length=300) + position: WorkflowPosition = Field(default_factory=WorkflowPosition) + size: WorkflowSize | None = None + parent_id: str | None = Field(default=None, max_length=120) + process_id: str | None = Field(default=None, max_length=120) + config: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowWaypoint(BaseModel): + x: float + y: float + + @field_validator("x", "y") + @classmethod + def finite_coordinate(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("Edge coordinates must be finite.") + return value + + +class WorkflowEdge(BaseModel): + id: str = Field(min_length=1, max_length=120) + type: Literal[ + "bpmn.sequenceFlow", + "bpmn.messageFlow", + "bpmn.association", + "bpmn.dataInputAssociation", + "bpmn.dataOutputAssociation", + "bpmn.conversationLink", + ] = "bpmn.sequenceFlow" + label: str = Field(default="", max_length=300) + source: str = Field(min_length=1, max_length=120) + target: str = Field(min_length=1, max_length=120) + source_port: str = Field(default="output", min_length=1, max_length=120) + target_port: str = Field(default="input", min_length=1, max_length=120) + config: dict[str, Any] = Field(default_factory=dict) + waypoints: list[WorkflowWaypoint] = Field(default_factory=list, max_length=500) + + +class WorkflowGraph(BaseModel): + schema_version: Literal[1] = 1 + nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150) + edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowGraphValidationRequest(BaseModel): + graph: WorkflowGraph + + +class WorkflowDiagnosticResponse(BaseModel): + severity: Literal["error", "warning"] + code: str + message: str + node_id: str | None = None + field: str | None = None + + +class WorkflowGraphValidationResponse(BaseModel): + valid: bool + diagnostics: list[WorkflowDiagnosticResponse] + + +class WorkflowPortResponse(BaseModel): + id: str + label: str + required: bool + multiple: bool + minimum_connections: int + + +class WorkflowConfigFieldResponse(BaseModel): + id: str + label: str + kind: str + required: bool + description: str | None + options: list[tuple[str, str]] + + +class WorkflowNodeTypeResponse(BaseModel): + type: str + category: str + category_label: str + label: str + description: str + icon: str + input_ports: list[WorkflowPortResponse] + output_ports: list[WorkflowPortResponse] + config_fields: list[WorkflowConfigFieldResponse] + default_config: dict[str, Any] + metadata: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowNodeLibraryResponse(BaseModel): + id: str + version: str + allows_cycles: bool + nodes: list[WorkflowNodeTypeResponse] + + +class BpmnInspectionRequest(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + activation: bool = False + + +class BpmnElementSupportResponse(BaseModel): + element_type: str + element_id: str | None = None + name: str | None = None + parent_type: str | None = None + parent_id: str | None = None + support_level: BpmnSupportLevel + + +class BpmnDiagnosticResponse(BaseModel): + severity: Literal["error", "warning", "info"] + code: str + message: str + element_id: str | None = None + + +class BpmnInspectionResponse(BaseModel): + valid_xml: bool + definitions_id: str | None = None + target_namespace: str | None = None + process_count: int + executable_process_count: int + collaboration_count: int + choreography_count: int + element_counts: dict[str, int] + support_counts: dict[str, int] + elements: list[BpmnElementSupportResponse] + diagnostics: list[BpmnDiagnosticResponse] + adapter_id: str | None = None + adapter_version: str | None = None + runtime_kind: BpmnRuntimeKind | None = None + executable: bool = False + activatable: bool = False + + +class BpmnAdapterProfileResponse(BaseModel): + id: str + version: str + label: str + description: str + conformance: str + runtime_kind: BpmnRuntimeKind + executable: bool + supported_elements: list[str] + supported_event_definitions: list[str] + requirements: list[str] + + +class BpmnSupportProfileResponse(BaseModel): + specification: str + model_namespace: str + interchange: str + native_runtime: str + native_execution_elements: list[str] + native_mapping_elements: list[str] + adapters: list[BpmnAdapterProfileResponse] = Field(default_factory=list) + + +class BpmnRevisionInput(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + + +class BpmnRevisionSummaryResponse(BaseModel): + format: Literal["bpmn-2.0"] = "bpmn-2.0" + content_hash: str + adapter_id: str + adapter_version: str + runtime_kind: BpmnRuntimeKind + executable: bool + adapter_available: bool + + +class BpmnRevisionDocumentResponse(BpmnRevisionSummaryResponse): + definition_id: str + revision: int + xml: str + inspection: BpmnInspectionResponse + + +class BpmnCompileRequest(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + + +class BpmnCompileResponse(BaseModel): + adapter: BpmnAdapterProfileResponse + graph: WorkflowGraph + inspection: BpmnInspectionResponse + + +class BpmnRenderRequest(BaseModel): + graph: WorkflowGraph + name: str = Field(default="", max_length=300) + + +class BpmnRenderResponse(BaseModel): + xml: str + inspection: BpmnInspectionResponse + + +class WorkflowDefinitionRevisionResponse(BaseModel): + id: str + revision: int + schema_version: int + graph: WorkflowGraph + content_hash: str + library_id: str + library_version: str + execution_mode: WorkflowExecutionMode + view_id: str | None = None + view_revision_id: str | None = None + bpmn: BpmnRevisionSummaryResponse | None = None + contribution_origin_module_version: str | None = None + contribution_schema_version: str | None = None + contribution_hash: str | None = None + contribution_metadata: dict[str, Any] = Field(default_factory=dict) + created_by: str | None + created_at: datetime + + +class WorkflowStandardProvenanceResponse(BaseModel): + kind: Literal["baseline", "override"] + origin_module_id: str + origin_module_version: str | None = None + definition_key: str + contribution_schema_version: str | None = None + contribution_hash: str | None = None + baseline_definition_id: str + latest_baseline_revision: int + active_baseline_revision: int | None = None + pinned_baseline_revision: int | None = None + pinned_baseline_hash: str | None = None + update_available: bool = False + reset_available: bool = False + + +class WorkflowActionDecisionResponse(BaseModel): + allowed: bool + reason: str | None = None + source_path: list[dict[str, Any]] = Field(default_factory=list) + requirements: list[str] = Field(default_factory=list) + details: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowGovernanceResponse(BaseModel): + scope_type: DefinitionScopeType + scope_id: str | None + definition_kind: DefinitionKind + inherit_to_lower_scopes: bool + allow_start: bool + allow_reuse: bool + allow_automation: bool + derived_from_definition_id: str | None + derived_from_revision: int | None + derived_from_hash: str | None + derivation_provenance: dict[str, Any] = Field(default_factory=dict) + actions: dict[str, WorkflowActionDecisionResponse] + automation_runtime_available: bool = False + automation_runtime_reason: str | None = None + + +class WorkflowDefinitionResponse(BaseModel): + id: str + tenant_id: str | None + key: str + name: str + description: str | None + status: WorkflowDefinitionStatus + current_revision: int + active_revision: int | None + metadata: dict[str, Any] + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + revision: WorkflowDefinitionRevisionResponse + governance: WorkflowGovernanceResponse + standard: WorkflowStandardProvenanceResponse | None = None + + +class WorkflowDefinitionListResponse(BaseModel): + definitions: list[WorkflowDefinitionResponse] + + +class WorkflowDefinitionRevisionListResponse(BaseModel): + revisions: list[WorkflowDefinitionRevisionResponse] + + +class WorkflowDefinitionCreateRequest(BaseModel): + key: str | None = Field( + default=None, + min_length=1, + max_length=120, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$", + ) + name: str = Field(min_length=1, max_length=300) + description: str | None = Field(default=None, max_length=4_000) + graph: WorkflowGraph + bpmn: BpmnRevisionInput | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + scope_type: DefinitionScopeType = "tenant" + scope_id: str | None = Field(default=None, max_length=36) + definition_kind: DefinitionKind = "flow" + inherit_to_lower_scopes: bool = False + allow_start: bool = True + allow_reuse: bool = False + allow_automation: bool = False + execution_mode: WorkflowExecutionMode = "hybrid" + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self + + +class WorkflowDefinitionUpdateRequest(BaseModel): + name: str = Field(min_length=1, max_length=300) + description: str | None = Field(default=None, max_length=4_000) + graph: WorkflowGraph + bpmn: BpmnRevisionInput | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + expected_revision: int = Field(ge=1) + scope_type: DefinitionScopeType = "tenant" + scope_id: str | None = Field(default=None, max_length=36) + definition_kind: DefinitionKind = "flow" + inherit_to_lower_scopes: bool = False + allow_start: bool = True + allow_reuse: bool = False + allow_automation: bool = False + execution_mode: WorkflowExecutionMode = "hybrid" + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self + + +class WorkflowDefinitionDeriveRequest(BaseModel): + key: str | None = Field( + default=None, + min_length=1, + max_length=120, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$", + ) + name: str = Field(min_length=1, max_length=300) + description: str | None = Field(default=None, max_length=4_000) + source_revision: int | None = Field(default=None, ge=1) + metadata: dict[str, Any] = Field(default_factory=dict) + scope_type: DefinitionScopeType = "tenant" + scope_id: str | None = Field(default=None, max_length=36) + definition_kind: DefinitionKind = "flow" + inherit_to_lower_scopes: bool = False + allow_start: bool = True + allow_reuse: bool = False + allow_automation: bool = False + execution_mode: WorkflowExecutionMode | None = None + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self + + +class WorkflowDefinitionActivateRequest(BaseModel): + revision: int | None = Field(default=None, ge=1) + + +class WorkflowDefinitionDeleteResponse(BaseModel): + deleted: bool + definition_id: str + + +WorkflowInstanceStatus = Literal[ + "running", + "waiting", + "completed", + "failed", + "cancelled", +] +WorkflowStepStatus = Literal[ + "running", + "waiting", + "completed", + "failed", + "cancelled", + "superseded", +] + + +class WorkflowInstanceStartRequest(BaseModel): + idempotency_key: str = Field(min_length=1, max_length=255) + input: dict[str, Any] = Field(default_factory=dict) + correlation_id: str | None = Field(default=None, max_length=128) + + +class WorkflowStepActionRequest(BaseModel): + action: Literal[ + "complete", + "approve", + "changes", + "reject", + "resume", + "retry", + "cancel", + ] + output: dict[str, Any] = Field(default_factory=dict) + evidence: list[str] = Field(default_factory=list, max_length=100) + comment: str | None = Field(default=None, max_length=4_000) + + +class WorkflowInstanceStepResponse(BaseModel): + id: str + sequence: int + node_id: str + node_type: str + status: WorkflowStepStatus + attempt: int + input: dict[str, Any] + output: dict[str, Any] + handoff: dict[str, Any] + external_ref: str | None + started_at: datetime | None + finished_at: datetime | None + error: str | None + completed_by: str | None + created_at: datetime + updated_at: datetime + + +class WorkflowViewContextResponse(BaseModel): + view_id: str + revision_id: str | None = None + visible_surface_ids: list[str] = Field(default_factory=list) + step_id: str | None = None + node_id: str | None = None + + +class WorkflowInstanceEventResponse(BaseModel): + id: str + sequence: int + step_id: str | None + kind: str + actor_id: str | None + payload: dict[str, Any] + created_at: datetime + + +class WorkflowInstanceResponse(BaseModel): + id: str + definition_id: str + definition_name: str + definition_revision: int + definition_hash: str + execution_mode: WorkflowExecutionMode + start_origin: WorkflowStartOrigin + view_context: WorkflowViewContextResponse | None = None + status: WorkflowInstanceStatus + idempotency_key: str + correlation_id: str | None + current_step_id: str | None + input: dict[str, Any] + context: dict[str, Any] + output: dict[str, Any] + started_at: datetime + finished_at: datetime | None + cancellation_requested_at: datetime | None + error: str | None + created_by: str | None + created_at: datetime + updated_at: datetime + steps: list[WorkflowInstanceStepResponse] + events: list[WorkflowInstanceEventResponse] + replayed: bool = False + + +class WorkflowInstanceListResponse(BaseModel): + instances: list[WorkflowInstanceResponse] diff --git a/src/govoplan_workflow_engine/backend/service.py b/src/govoplan_workflow_engine/backend/service.py new file mode 100644 index 0000000..b9f3d8b --- /dev/null +++ b/src/govoplan_workflow_engine/backend/service.py @@ -0,0 +1,1060 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import hashlib +import json +import re +import unicodedata + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.db.base import utcnow +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, +) +from govoplan_workflow_engine.backend.node_library import WORKFLOW_GRAPH_LIBRARY +from govoplan_workflow_engine.backend.bpmn import ( + BpmnDiagnostic, + BpmnInspectionError, +) +from govoplan_workflow_engine.backend.bpmn_adapters import ( + RuntimeKind, + bpmn_adapter_registry, +) +from govoplan_workflow_engine.backend.bpmn_graph import ( + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + canonical_bpmn_graph, + export_bpmn_graph, + import_bpmn_graph, + materialize_runtime_graph, + runtime_diagnostics, +) +from govoplan_workflow_engine.backend.governance import ( + definition_governance_payload, + require_definition_action, +) +from govoplan_workflow_engine.backend.schemas import ( + BpmnRevisionInput, + BpmnRevisionSummaryResponse, + WorkflowDefinitionCreateRequest, + WorkflowDefinitionDeriveRequest, + WorkflowDefinitionResponse, + WorkflowDefinitionRevisionResponse, + WorkflowStandardProvenanceResponse, + WorkflowDefinitionUpdateRequest, + WorkflowGraph, +) +from govoplan_workflow_engine.backend.validation import validate_workflow_graph + + +class WorkflowError(RuntimeError): + pass + + +class WorkflowNotFoundError(WorkflowError): + pass + + +class WorkflowConflictError(WorkflowError): + pass + + +class WorkflowValidationError(WorkflowError): + def __init__(self, diagnostics: tuple[object, ...]) -> None: + first = diagnostics[0] if diagnostics else None + super().__init__( + str(getattr(first, "message", "Workflow definition validation failed.")) + ) + self.diagnostics = diagnostics + + +class WorkflowBpmnValidationError(WorkflowError): + def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None: + first = next( + (item for item in diagnostics if item.severity == "error"), + diagnostics[0] if diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN validation failed." + ) + self.diagnostics = diagnostics + + +@dataclass(frozen=True, slots=True) +class _BpmnArtifact: + xml: str + content_hash: str + adapter_id: str + adapter_version: str + runtime_kind: RuntimeKind + executable: bool + + +def list_definitions( + session: Session, + *, + tenant_id: str, +) -> list[WorkflowDefinition]: + return list( + session.scalars( + select(WorkflowDefinition) + .where( + or_( + WorkflowDefinition.tenant_id == tenant_id, + WorkflowDefinition.tenant_id.is_(None), + ), + WorkflowDefinition.deleted_at.is_(None), + ) + .order_by( + WorkflowDefinition.updated_at.desc(), + WorkflowDefinition.name.asc(), + ) + ) + ) + + +def get_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, +) -> WorkflowDefinition: + definition = session.scalar( + select(WorkflowDefinition).where( + WorkflowDefinition.id == definition_id, + or_( + WorkflowDefinition.tenant_id == tenant_id, + WorkflowDefinition.tenant_id.is_(None), + ), + WorkflowDefinition.deleted_at.is_(None), + ) + ) + if definition is None: + raise WorkflowNotFoundError("Workflow definition not found.") + return definition + + +def get_definition_revision( + session: Session, + *, + definition: WorkflowDefinition, + revision: int | None = None, +) -> WorkflowDefinitionRevision: + revision_number = revision or definition.current_revision + item = session.scalar( + select(WorkflowDefinitionRevision).where( + WorkflowDefinitionRevision.definition_id == definition.id, + WorkflowDefinitionRevision.tenant_id == definition.tenant_id, + WorkflowDefinitionRevision.revision == revision_number, + ) + ) + if item is None: + raise WorkflowNotFoundError("Workflow definition revision not found.") + return item + + +def list_definition_revisions( + session: Session, + *, + definition: WorkflowDefinition, +) -> list[WorkflowDefinitionRevision]: + return list( + session.scalars( + select(WorkflowDefinitionRevision) + .where( + WorkflowDefinitionRevision.definition_id == definition.id, + WorkflowDefinitionRevision.tenant_id == definition.tenant_id, + ) + .order_by(WorkflowDefinitionRevision.revision.desc()) + ) + ) + + +def create_definition( + session: Session, + *, + tenant_id: str, + actor_id: str | None, + payload: WorkflowDefinitionCreateRequest, +) -> WorkflowDefinition: + graph, bpmn = _prepare_revision_content( + graph=payload.graph, + bpmn=payload.bpmn, + ) + stored_tenant_id = ( + None if payload.scope_type == "system" else tenant_id + ) + scope_id = ( + None + if payload.scope_type == "system" + else tenant_id + if payload.scope_type == "tenant" + else payload.scope_id + ) + scope_key = ( + "system" + if payload.scope_type == "system" + else f"{payload.scope_type}:{scope_id}" + ) + definition = WorkflowDefinition( + tenant_id=stored_tenant_id, + scope_type=payload.scope_type, + scope_id=scope_id, + scope_key=scope_key, + definition_kind=payload.definition_kind, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + allow_start=payload.allow_start, + allow_reuse=payload.allow_reuse, + allow_automation=payload.allow_automation, + definition_key=_available_key( + session, + scope_key=scope_key, + requested=payload.key, + name=payload.name, + ), + name=payload.name.strip(), + description=_clean_optional(payload.description), + status="draft", + current_revision=1, + active_revision=None, + metadata_=dict(payload.metadata), + created_by=actor_id, + updated_by=actor_id, + ) + definition.revisions.append( + _new_revision( + tenant_id=stored_tenant_id, + revision=1, + graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, + actor_id=actor_id, + ) + ) + session.add(definition) + session.flush() + return definition + + +def update_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, + payload: WorkflowDefinitionUpdateRequest, +) -> WorkflowDefinition: + definition = get_definition( + session, + tenant_id=tenant_id, + definition_id=definition_id, + ) + _require_mutable_definition(definition) + if payload.expected_revision != definition.current_revision: + raise WorkflowConflictError( + "Workflow definition changed on the server; " + f"expected revision {payload.expected_revision}, " + f"current revision is {definition.current_revision}." + ) + if ( + payload.scope_type != definition.scope_type + or payload.scope_id != definition.scope_id + and not ( + definition.scope_type == "tenant" + and payload.scope_id in {None, definition.scope_id} + ) + ): + raise WorkflowConflictError( + "Definition scope is immutable; derive a scoped copy instead." + ) + if payload.definition_kind != definition.definition_kind: + raise WorkflowConflictError( + "Definition kind is immutable; derive a flow or template instead." + ) + current = get_definition_revision(session, definition=definition) + graph, bpmn = _prepare_revision_content( + graph=payload.graph, + bpmn=payload.bpmn, + ) + definition.name = payload.name.strip() + definition.description = _clean_optional(payload.description) + definition.metadata_ = dict(payload.metadata) + ancestor_limits = _ancestor_governance_limits( + definition.derivation_provenance + ) + definition.inherit_to_lower_scopes = ( + payload.inherit_to_lower_scopes + and ancestor_limits["inherit_to_lower_scopes"] + ) + definition.allow_start = ( + payload.allow_start and ancestor_limits["allow_start"] + ) + definition.allow_reuse = ( + payload.allow_reuse and ancestor_limits["allow_reuse"] + ) + definition.allow_automation = ( + payload.allow_automation and ancestor_limits["allow_automation"] + ) + definition.updated_by = actor_id + if not _revision_matches( + current, + graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, + ): + definition.current_revision += 1 + definition.revisions.append( + _new_revision( + tenant_id=definition.tenant_id, + revision=definition.current_revision, + graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, + actor_id=actor_id, + ) + ) + if definition.status == "active": + definition.status = "draft" + session.flush() + return definition + + +def derive_definition( + session: Session, + *, + tenant_id: str, + actor_id: str | None, + principal: ApiPrincipal, + registry: object | None, + source_definition_id: str, + payload: WorkflowDefinitionDeriveRequest, +) -> WorkflowDefinition: + source = get_definition( + session, + tenant_id=tenant_id, + definition_id=source_definition_id, + ) + decision = require_definition_action( + source, + principal=principal, + registry=registry, + action="derive", + ) + source_revision = get_definition_revision( + session, + definition=source, + revision=payload.source_revision, + ) + stored_tenant_id = ( + None if payload.scope_type == "system" else tenant_id + ) + scope_id = ( + None + if payload.scope_type == "system" + else tenant_id + if payload.scope_type == "tenant" + else payload.scope_id + ) + scope_key = ( + "system" + if payload.scope_type == "system" + else f"{payload.scope_type}:{scope_id}" + ) + source_limits = _effective_governance_limits( + source, + decision_details=decision.details, + ) + limits = { + "inherit_to_lower_scopes": ( + source_limits["inherit_to_lower_scopes"] + and payload.inherit_to_lower_scopes + ), + "allow_start": ( + source_limits["allow_start"] and payload.allow_start + ), + "allow_reuse": ( + source_limits["allow_reuse"] and payload.allow_reuse + ), + "allow_automation": ( + source_limits["allow_automation"] + and payload.allow_automation + ), + } + provenance = { + "source_ref": f"workflow-definition:{source.id}", + "source_scope": { + "scope_type": source.scope_type, + "scope_id": source.scope_id, + }, + "source_definition_kind": source.definition_kind, + "source_revision": source_revision.revision, + "source_hash": source_revision.content_hash, + "source_effective_limits": limits, + "policy_decision": decision.to_dict(), + "derived_by": actor_id, + "derived_at": utcnow().isoformat(), + } + execution_mode = ( + payload.execution_mode + if "execution_mode" in payload.model_fields_set + else source_revision.execution_mode + ) + view_id = ( + payload.view_id + if "view_id" in payload.model_fields_set + else source_revision.view_id + ) + view_revision_id = ( + payload.view_revision_id + if ( + "view_revision_id" in payload.model_fields_set + or "view_id" in payload.model_fields_set + ) + else source_revision.view_revision_id + ) + if view_id is None: + view_revision_id = None + source_graph, source_bpmn = _prepare_revision_content( + graph=WorkflowGraph.model_validate(source_revision.graph), + bpmn=None, + ) + definition = WorkflowDefinition( + tenant_id=stored_tenant_id, + scope_type=payload.scope_type, + scope_id=scope_id, + scope_key=scope_key, + definition_kind=payload.definition_kind, + inherit_to_lower_scopes=limits["inherit_to_lower_scopes"], + allow_start=limits["allow_start"], + allow_reuse=limits["allow_reuse"], + allow_automation=limits["allow_automation"], + derived_from_definition_id=source.id, + derived_from_revision=source_revision.revision, + derived_from_hash=source_revision.content_hash, + derivation_provenance=provenance, + definition_key=_available_key( + session, + scope_key=scope_key, + requested=payload.key, + name=payload.name, + ), + name=payload.name.strip(), + description=_clean_optional(payload.description), + status="draft", + current_revision=1, + active_revision=None, + metadata_=dict(payload.metadata), + created_by=actor_id, + updated_by=actor_id, + ) + definition.revisions.append( + WorkflowDefinitionRevision( + tenant_id=stored_tenant_id, + revision=1, + schema_version=source_revision.schema_version, + graph=_canonical_graph(source_graph), + content_hash=_content_hash( + source_graph, + bpmn=source_bpmn, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + ), + library_id=source_revision.library_id, + library_version=source_revision.library_version, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + bpmn_xml=source_bpmn.xml, + bpmn_hash=source_bpmn.content_hash, + bpmn_adapter_id=source_bpmn.adapter_id, + bpmn_adapter_version=source_bpmn.adapter_version, + bpmn_runtime_kind=source_bpmn.runtime_kind, + bpmn_executable=source_bpmn.executable, + created_by=actor_id, + ) + ) + session.add(definition) + session.flush() + return definition + + +def activate_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, + revision: int | None = None, +) -> WorkflowDefinition: + definition = get_definition( + session, + tenant_id=tenant_id, + definition_id=definition_id, + ) + selected = get_definition_revision( + session, + definition=definition, + revision=revision, + ) + if definition.definition_kind == "template": + raise WorkflowConflictError( + "Workflow templates cannot be activated or started." + ) + _validated_graph(WorkflowGraph.model_validate(selected.graph)) + _validate_bpmn_activation(selected) + _validate_execution_mode_activation(selected) + definition.active_revision = selected.revision + definition.status = "active" + definition.updated_by = actor_id + session.flush() + return definition + + +def archive_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, +) -> WorkflowDefinition: + definition = get_definition( + session, + tenant_id=tenant_id, + definition_id=definition_id, + ) + _require_mutable_definition(definition) + definition.status = "archived" + definition.updated_by = actor_id + session.flush() + return definition + + +def delete_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, + actor_id: str | None, +) -> WorkflowDefinition: + definition = get_definition( + session, + tenant_id=tenant_id, + definition_id=definition_id, + ) + _require_mutable_definition(definition) + definition.deleted_at = utcnow() + definition.updated_by = actor_id + session.flush() + return definition + + +def definition_response( + session: Session, + definition: WorkflowDefinition, + *, + principal: ApiPrincipal, + registry: object | None, + revision: int | None = None, +) -> WorkflowDefinitionResponse: + selected = get_definition_revision( + session, + definition=definition, + revision=revision, + ) + return WorkflowDefinitionResponse( + id=definition.id, + tenant_id=definition.tenant_id, + key=definition.definition_key, + name=definition.name, + description=definition.description, + status=definition.status, + current_revision=definition.current_revision, + active_revision=definition.active_revision, + metadata=dict(definition.metadata_), + created_by=definition.created_by, + updated_by=definition.updated_by, + created_at=definition.created_at, + updated_at=definition.updated_at, + revision=revision_response(selected), + governance=definition_governance_payload( + definition, + principal=principal, + registry=registry, + ), + standard=_standard_provenance_response(session, definition), + ) + + +def revision_response( + revision: WorkflowDefinitionRevision, +) -> WorkflowDefinitionRevisionResponse: + return WorkflowDefinitionRevisionResponse( + id=revision.id, + revision=revision.revision, + schema_version=revision.schema_version, + graph=canonical_bpmn_graph( + WorkflowGraph.model_validate(revision.graph) + ), + content_hash=revision.content_hash, + library_id=revision.library_id, + library_version=revision.library_version, + execution_mode=revision.execution_mode, + view_id=revision.view_id, + view_revision_id=revision.view_revision_id, + bpmn=revision_bpmn_summary(revision), + contribution_origin_module_version=( + revision.contribution_origin_module_version + ), + contribution_schema_version=revision.contribution_schema_version, + contribution_hash=revision.contribution_hash, + contribution_metadata=dict(revision.contribution_metadata or {}), + created_by=revision.created_by, + created_at=revision.created_at, + ) + + +def _standard_provenance_response( + session: Session, + definition: WorkflowDefinition, +) -> WorkflowStandardProvenanceResponse | None: + if definition.standard_origin_module_id: + return WorkflowStandardProvenanceResponse( + kind="baseline", + origin_module_id=definition.standard_origin_module_id, + origin_module_version=definition.standard_origin_module_version, + definition_key=( + definition.standard_definition_key + or definition.definition_key + ), + contribution_schema_version=( + definition.standard_contribution_schema_version + ), + contribution_hash=definition.standard_contribution_hash, + baseline_definition_id=definition.id, + latest_baseline_revision=definition.current_revision, + active_baseline_revision=definition.active_revision, + update_available=( + definition.active_revision is not None + and definition.active_revision != definition.current_revision + ), + ) + if not definition.derived_from_definition_id: + return None + baseline = session.get( + WorkflowDefinition, + definition.derived_from_definition_id, + ) + if baseline is None or not baseline.standard_origin_module_id: + return None + pinned_revision = next( + ( + item + for item in baseline.revisions + if item.revision == definition.derived_from_revision + ), + None, + ) + pinned_contribution_hash = ( + pinned_revision.contribution_hash + if pinned_revision is not None + else None + ) + return WorkflowStandardProvenanceResponse( + kind="override", + origin_module_id=baseline.standard_origin_module_id, + origin_module_version=baseline.standard_origin_module_version, + definition_key=( + baseline.standard_definition_key or baseline.definition_key + ), + contribution_schema_version=( + baseline.standard_contribution_schema_version + ), + contribution_hash=baseline.standard_contribution_hash, + baseline_definition_id=baseline.id, + latest_baseline_revision=baseline.current_revision, + active_baseline_revision=baseline.active_revision, + pinned_baseline_revision=definition.derived_from_revision, + pinned_baseline_hash=definition.derived_from_hash, + update_available=( + bool(pinned_contribution_hash) + and pinned_contribution_hash + != baseline.standard_contribution_hash + ), + reset_available=definition.status != "archived", + ) + + +def _require_mutable_definition(definition: WorkflowDefinition) -> None: + if definition.standard_origin_module_id: + raise WorkflowConflictError( + "Module standard definitions are immutable; derive a local " + "override or activate a supplied revision instead." + ) + + +def _new_revision( + *, + tenant_id: str | None, + revision: int, + graph: WorkflowGraph, + bpmn: _BpmnArtifact | None, + execution_mode: str, + view_id: str | None, + view_revision_id: str | None, + actor_id: str | None, +) -> WorkflowDefinitionRevision: + return WorkflowDefinitionRevision( + tenant_id=tenant_id, + revision=revision, + schema_version=graph.schema_version, + graph=_canonical_graph(graph), + content_hash=_content_hash( + graph, + bpmn=bpmn, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + ), + library_id=WORKFLOW_GRAPH_LIBRARY.id, + library_version=WORKFLOW_GRAPH_LIBRARY.version, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + bpmn_xml=bpmn.xml if bpmn else None, + bpmn_hash=bpmn.content_hash if bpmn else None, + bpmn_adapter_id=bpmn.adapter_id if bpmn else None, + bpmn_adapter_version=bpmn.adapter_version if bpmn else None, + bpmn_runtime_kind=bpmn.runtime_kind if bpmn else None, + bpmn_executable=bpmn.executable if bpmn else None, + created_by=actor_id, + ) + + +def revision_bpmn_summary( + revision: WorkflowDefinitionRevision, +) -> BpmnRevisionSummaryResponse | None: + if not revision.bpmn_xml: + return None + adapter_id = revision.bpmn_adapter_id or "bpmn.interchange" + adapter_version = revision.bpmn_adapter_version or "1.0.0" + adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) + runtime_kind: RuntimeKind = "model_only" + if revision.bpmn_runtime_kind in {"model_only", "native_graph", "external"}: + runtime_kind = revision.bpmn_runtime_kind + return BpmnRevisionSummaryResponse( + content_hash=revision.bpmn_hash + or hashlib.sha256(revision.bpmn_xml.encode("utf-8")).hexdigest(), + adapter_id=adapter_id, + adapter_version=adapter_version, + runtime_kind=runtime_kind, + executable=bool(revision.bpmn_executable), + adapter_available=adapter is not None, + ) + + +def _prepare_revision_content( + *, + graph: WorkflowGraph, + bpmn: BpmnRevisionInput | None, +) -> tuple[WorkflowGraph, _BpmnArtifact | None]: + try: + effective_graph = ( + import_bpmn_graph(bpmn.xml) + if bpmn is not None + else canonical_bpmn_graph(graph) + ) + except WorkflowBpmnValidationError: + raise + except (BpmnGraphError, BpmnInspectionError) as exc: + diagnostics = getattr(exc, "diagnostics", None) + raise WorkflowBpmnValidationError( + tuple(diagnostics) + if diagnostics + else ( + BpmnDiagnostic( + severity="error", + code="bpmn.invalid", + message=str(exc), + ), + ) + ) from exc + effective_graph = _validated_graph(effective_graph) + xml = export_bpmn_graph(effective_graph) + executable = not any( + item.severity == "error" + for item in runtime_diagnostics(effective_graph) + ) + if executable: + try: + runtime_graph = materialize_runtime_graph(effective_graph) + except BpmnGraphError: + executable = False + else: + executable = not any( + item.severity == "error" + for item in validate_workflow_graph(runtime_graph) + ) + return effective_graph, _BpmnArtifact( + xml=xml, + content_hash=hashlib.sha256(xml.encode("utf-8")).hexdigest(), + adapter_id=NATIVE_BPMN_ADAPTER_ID, + adapter_version=NATIVE_BPMN_ADAPTER_VERSION, + runtime_kind="native_graph", + executable=executable, + ) + + +def _validate_bpmn_activation( + revision: WorkflowDefinitionRevision, +) -> None: + try: + runtime_graph = materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + diagnostics = getattr(exc, "diagnostics", None) + raise WorkflowBpmnValidationError( + tuple(diagnostics) + if diagnostics + else ( + BpmnDiagnostic( + severity="error", + code="bpmn.activation_invalid", + message=str(exc), + ), + ) + ) from exc + runtime_validation = validate_workflow_graph(runtime_graph) + if any(item.severity == "error" for item in runtime_validation): + raise WorkflowValidationError(runtime_validation) + + +def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph: + try: + graph = canonical_bpmn_graph(graph) + except BpmnGraphError as exc: + raise WorkflowBpmnValidationError(exc.diagnostics) from exc + diagnostics = validate_workflow_graph(graph) + if any(item.severity == "error" for item in diagnostics): + raise WorkflowValidationError(diagnostics) + return graph + + +def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]: + return graph.model_dump(mode="json") + + +def _content_hash( + graph: WorkflowGraph, + *, + bpmn: _BpmnArtifact | None = None, + execution_mode: str = "hybrid", + view_id: str | None = None, + view_revision_id: str | None = None, +) -> str: + content: dict[str, object] = { + "graph": _canonical_graph(graph), + "execution": { + "mode": execution_mode, + "view_id": view_id, + "view_revision_id": view_revision_id, + }, + } + if bpmn is not None: + content["bpmn"] = { + "xml": bpmn.xml, + "adapter_id": bpmn.adapter_id, + "adapter_version": bpmn.adapter_version, + } + encoded = json.dumps(content, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _revision_matches( + revision: WorkflowDefinitionRevision, + *, + graph: WorkflowGraph, + bpmn: _BpmnArtifact | None, + execution_mode: str, + view_id: str | None, + view_revision_id: str | None, +) -> bool: + return ( + revision.graph == _canonical_graph(graph) + and revision.execution_mode == execution_mode + and revision.view_id == view_id + and revision.view_revision_id == view_revision_id + and revision.bpmn_xml == (bpmn.xml if bpmn else None) + and revision.bpmn_adapter_id + == (bpmn.adapter_id if bpmn else None) + and revision.bpmn_adapter_version + == (bpmn.adapter_version if bpmn else None) + ) + + +def _validate_execution_mode_activation( + revision: WorkflowDefinitionRevision, +) -> None: + if revision.execution_mode != "automated": + return + try: + graph = materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + raise WorkflowBpmnValidationError(exc.diagnostics) from exc + human_nodes = [ + node.id + for node in graph.nodes + if node.type in {"workflow.activity", "workflow.review"} + or ( + node.type == "workflow.wait" + and str(node.config.get("mode") or "manual") == "manual" + ) + or ( + node.type == "workflow.capability" + and str(node.config.get("failure_policy") or "manual") == "manual" + ) + or ( + node.type == "workflow.dataflow" + and ( + str(node.config.get("warning_policy") or "review") == "review" + or str(node.config.get("failure_policy") or "manual") + == "manual" + ) + ) + ] + if human_nodes: + raise WorkflowConflictError( + "Automated workflows cannot activate with human handoff paths: " + + ", ".join(sorted(human_nodes)) + ) + + +def _available_key( + session: Session, + *, + scope_key: str, + requested: str | None, + name: str, +) -> str: + base = _slug(requested or name) + candidate = base + suffix = 2 + while session.scalar( + select(WorkflowDefinition.id).where( + WorkflowDefinition.scope_key == scope_key, + WorkflowDefinition.definition_key == candidate, + ) + ): + candidate = f"{base[: max(1, 120 - len(str(suffix)) - 1)]}-{suffix}" + suffix += 1 + return candidate + + +def _slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + ascii_value = normalized.encode("ascii", "ignore").decode("ascii").lower() + cleaned = re.sub(r"[^a-z0-9]+", "-", ascii_value).strip("-") + return (cleaned or "workflow")[:120] + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def _ancestor_governance_limits( + provenance: Mapping[str, object], +) -> dict[str, bool]: + raw = provenance.get("source_effective_limits") + limits = raw if isinstance(raw, Mapping) else {} + return { + key: value if isinstance((value := limits.get(key)), bool) else True + for key in ( + "inherit_to_lower_scopes", + "allow_start", + "allow_reuse", + "allow_automation", + ) + } + + +def _effective_governance_limits( + definition: WorkflowDefinition, + *, + decision_details: Mapping[str, object] | None = None, +) -> dict[str, bool]: + ancestor = _ancestor_governance_limits( + definition.derivation_provenance + ) + effective = { + "inherit_to_lower_scopes": ( + definition.inherit_to_lower_scopes + and ancestor["inherit_to_lower_scopes"] + ), + "allow_start": ( + definition.allow_start and ancestor["allow_start"] + ), + "allow_reuse": ( + definition.allow_reuse and ancestor["allow_reuse"] + ), + "allow_automation": ( + definition.allow_automation + and ancestor["allow_automation"] + ), + } + policy_limits = ( + decision_details.get("effective_limits") + if decision_details is not None + else None + ) + if isinstance(policy_limits, Mapping): + policy_key_by_local_key = { + "inherit_to_lower_scopes": "inherit_to_lower_scopes", + "allow_start": "allow_run", + "allow_reuse": "allow_reuse", + "allow_automation": "allow_automation", + } + for local_key, policy_key in policy_key_by_local_key.items(): + value = policy_limits.get(policy_key) + if isinstance(value, bool): + effective[local_key] = effective[local_key] and value + return effective + + +__all__ = [ + "WorkflowBpmnValidationError", + "WorkflowConflictError", + "WorkflowError", + "WorkflowNotFoundError", + "WorkflowValidationError", + "activate_definition", + "archive_definition", + "create_definition", + "derive_definition", + "definition_response", + "delete_definition", + "get_definition", + "get_definition_revision", + "list_definition_revisions", + "list_definitions", + "revision_response", + "revision_bpmn_summary", + "update_definition", +] diff --git a/src/govoplan_workflow_engine/backend/validation.py b/src/govoplan_workflow_engine/backend/validation.py new file mode 100644 index 0000000..7eaa860 --- /dev/null +++ b/src/govoplan_workflow_engine/backend/validation.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from govoplan_core.core.definition_graphs import ( + DefinitionDiagnostic, + DefinitionEdge, + DefinitionNode, + validate_definition_graph, +) +from govoplan_workflow_engine.backend.node_library import ( + WORKFLOW_GRAPH_LIBRARY, + WORKFLOW_NODE_TYPES_BY_ID, +) +from govoplan_workflow_engine.backend.schemas import WorkflowGraph + + +def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ...]: + diagnostics = list( + validate_definition_graph( + WORKFLOW_GRAPH_LIBRARY, + nodes=tuple(DefinitionNode(id=node.id, type=node.type) for node in graph.nodes), + edges=tuple( + DefinitionEdge( + id=edge.id, + source=edge.source, + target=edge.target, + source_port=edge.source_port, + target_port=edge.target_port, + ) + for edge in graph.edges + ), + ) + ) + outgoing = {node.id: 0 for node in graph.nodes} + for edge in graph.edges: + if edge.type == "bpmn.sequenceFlow" and edge.source in outgoing: + outgoing[edge.source] += 1 + for node in graph.nodes: + definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type) + if definition is None: + continue + for field in definition.config_fields: + if node.type.startswith("bpmn."): + continue + if field.required and _empty(node.config.get(field.id)): + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="node.config_required", + message=f"{definition.label} requires {field.label.lower()}.", + node_id=node.id, + field=field.id, + ) + ) + if _requires_outgoing(node.type) and outgoing[node.id] == 0: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="node.outgoing_required", + message=f"{definition.label} must lead to another workflow step.", + node_id=node.id, + ) + ) + diagnostics.extend(_bpmn_semantic_diagnostics(graph)) + diagnostics.extend(_legacy_semantic_diagnostics(graph)) + return _deduplicate(diagnostics) + + +def _requires_outgoing(node_type: str) -> bool: + if not node_type.startswith("bpmn."): + return bool( + WORKFLOW_NODE_TYPES_BY_ID.get(node_type) + and WORKFLOW_NODE_TYPES_BY_ID[node_type].output_ports + ) + return False + + +def _bpmn_semantic_diagnostics( + graph: WorkflowGraph, +) -> list[DefinitionDiagnostic]: + if not graph.nodes or not any( + node.type.startswith("bpmn.") for node in graph.nodes + ): + return [] + diagnostics: list[DefinitionDiagnostic] = [] + if any(not node.type.startswith("bpmn.") for node in graph.nodes): + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="graph.mixed_notation", + message=( + "A Workflow revision cannot mix canonical BPMN and " + "legacy Workflow nodes." + ), + ) + ) + return diagnostics + flow_nodes = { + node.id + for node in graph.nodes + if node.type + not in { + "bpmn.participant", + "bpmn.lane", + "bpmn.dataObjectReference", + "bpmn.dataStoreReference", + "bpmn.textAnnotation", + "bpmn.group", + "bpmn.conversation", + "bpmn.callConversation", + "bpmn.subConversation", + } + } + starts = [ + node for node in graph.nodes if node.type == "bpmn.startEvent" + ] + ends = [node for node in graph.nodes if node.type == "bpmn.endEvent"] + if not starts: + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="bpmn.start_event_missing", + message="A Workflow process needs at least one BPMN start event.", + ) + ) + if not ends: + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="bpmn.end_event_missing", + message="A Workflow process needs at least one BPMN end event.", + ) + ) + node_by_id = {node.id: node for node in graph.nodes} + default_flows_by_source: dict[str, list[str]] = {} + for edge in graph.edges: + source = node_by_id.get(edge.source) + target = node_by_id.get(edge.target) + if source is None or target is None: + continue + if edge.type == "bpmn.sequenceFlow" and ( + edge.source not in flow_nodes or edge.target not in flow_nodes + ): + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.sequence_flow_endpoint", + message=( + "BPMN sequence flows may only connect process flow " + "nodes. Use an association or data association here." + ), + node_id=edge.source, + ) + ) + if edge.type == "bpmn.messageFlow": + same_process = ( + source.process_id + and target.process_id + and source.process_id == target.process_id + ) + if same_process: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.message_flow_same_process", + message=( + "BPMN message flows connect different participants; " + "use a sequence flow inside one process." + ), + node_id=edge.source, + ) + ) + if edge.config.get("default") is True: + if edge.type != "bpmn.sequenceFlow": + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.default_flow_type", + message="Only a BPMN sequence flow can be a default flow.", + node_id=edge.source, + ) + ) + else: + default_flows_by_source.setdefault(edge.source, []).append(edge.id) + for source_id, edge_ids in default_flows_by_source.items(): + if len(edge_ids) > 1: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.multiple_default_flows", + message="A BPMN flow node can have at most one default flow.", + node_id=source_id, + ) + ) + for node in graph.nodes: + if node.type != "bpmn.boundaryEvent": + continue + attached_to = str(node.config.get("attached_to_ref") or "") + if attached_to not in node_by_id: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.boundary_attachment", + message="A boundary event must reference an activity in this graph.", + node_id=node.id, + field="attached_to_ref", + ) + ) + return diagnostics + + +def _legacy_semantic_diagnostics( + graph: WorkflowGraph, +) -> list[DefinitionDiagnostic]: + if not graph.nodes or any( + node.type.startswith("bpmn.") for node in graph.nodes + ): + return [] + diagnostics: list[DefinitionDiagnostic] = [] + starts = [ + node for node in graph.nodes if node.type.startswith("workflow.start.") + ] + outcomes = [ + node for node in graph.nodes if node.type.startswith("workflow.end.") + ] + if len(starts) != 1: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="graph.trigger_count", + message="A definition requires exactly one start node.", + ) + ) + if not outcomes: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="graph.outcome_count", + message="A definition requires at least one outcome node.", + ) + ) + return diagnostics + + +def _empty(value: object) -> bool: + return value is None or value == "" or value == [] or value == {} + + +def _deduplicate( + diagnostics: list[DefinitionDiagnostic], +) -> tuple[DefinitionDiagnostic, ...]: + seen: set[tuple[str, str | None, str | None]] = set() + result: list[DefinitionDiagnostic] = [] + for item in diagnostics: + key = (item.code, item.node_id, item.field) + if key in seen: + continue + seen.add(key) + result.append(item) + return tuple(result) + + +__all__ = ["validate_workflow_graph"] diff --git a/src/govoplan_workflow_engine/py.typed b/src/govoplan_workflow_engine/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_workflow_engine/py.typed @@ -0,0 +1 @@ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c6e6ab3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Workflow test package for both discovery and targeted module execution.""" diff --git a/tests/fixtures/bpmn/choreography.bpmn b/tests/fixtures/bpmn/choreography.bpmn new file mode 100644 index 0000000..b518b4c --- /dev/null +++ b/tests/fixtures/bpmn/choreography.bpmn @@ -0,0 +1,35 @@ + + + + + + + + + + + Participant_Authority + Participant_Applicant + MessageFlow_Approval + + + + + + + diff --git a/tests/fixtures/bpmn/collaboration.bpmn b/tests/fixtures/bpmn/collaboration.bpmn new file mode 100644 index 0000000..b33e5fb --- /dev/null +++ b/tests/fixtures/bpmn/collaboration.bpmn @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/control-flow.bpmn b/tests/fixtures/bpmn/control-flow.bpmn new file mode 100644 index 0000000..2742616 --- /dev/null +++ b/tests/fixtures/bpmn/control-flow.bpmn @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/data.bpmn b/tests/fixtures/bpmn/data.bpmn new file mode 100644 index 0000000..eca5fc1 --- /dev/null +++ b/tests/fixtures/bpmn/data.bpmn @@ -0,0 +1,27 @@ + + + + + + + + + result = input + + DataObjectReference_Request + Transform_Data + + + Transform_Data + DataStoreReference_Archive + + + + diff --git a/tests/fixtures/bpmn/events-transaction-compensation.bpmn b/tests/fixtures/bpmn/events-transaction-compensation.bpmn new file mode 100644 index 0000000..0dc3b19 --- /dev/null +++ b/tests/fixtures/bpmn/events-transaction-compensation.bpmn @@ -0,0 +1,42 @@ + + + + + + + + R3/PT1H + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/process.bpmn b/tests/fixtures/bpmn/process.bpmn new file mode 100644 index 0000000..203f774 --- /dev/null +++ b/tests/fixtures/bpmn/process.bpmn @@ -0,0 +1,50 @@ + + + + + + Preserve this extension exactly. + + + + Flow_1 + + + Flow_1 + Flow_2 + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_bpmn.py b/tests/test_bpmn.py new file mode 100644 index 0000000..5ea7395 --- /dev/null +++ b/tests/test_bpmn.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from collections import Counter +import unittest +from pathlib import Path + +from govoplan_workflow_engine.backend.bpmn import ( + BPMN_MODEL_NAMESPACE, + BpmnInspectionError, + inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow_engine.backend.bpmn_adapters import ( + BpmnAdapterError, + INTERCHANGE_ADAPTER_ID, + NATIVE_LINEAR_ADAPTER_ID, + bpmn_adapter_registry, + compile_bpmn_to_graph, +) +from govoplan_workflow_engine.backend.bpmn_graph import ( + NATIVE_BPMN_ADAPTER_ID, + export_bpmn_graph, + import_bpmn_graph, +) + + +BPMN = """ + + + + + + + + + + + + + + +""" + +NATIVE_BPMN = """ + + + + + + + + + + + + + + + + + + + + + + +""" + + +class BpmnInspectionTests(unittest.TestCase): + def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None: + fixture_directory = Path(__file__).parent / "fixtures" / "bpmn" + results = { + path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8")) + for path in sorted(fixture_directory.glob("*.bpmn")) + } + + self.assertEqual( + { + "choreography", + "collaboration", + "control-flow", + "data", + "events-transaction-compensation", + "process", + }, + set(results), + ) + self.assertEqual(1, results["choreography"].choreography_count) + self.assertEqual(1, results["collaboration"].collaboration_count) + self.assertEqual( + 1, + results["events-transaction-compensation"].element_counts[ + "transaction" + ], + ) + self.assertEqual( + 1, + results["data"].element_counts["dataStoreReference"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["exclusiveGateway"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["subProcess"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["callActivity"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts[ + "compensateEventDefinition" + ], + ) + self.assertEqual( + 2, + results["control-flow"].element_counts["signalEventDefinition"], + ) + + def test_inventory_classifies_native_and_interchange_elements(self) -> None: + result = inspect_bpmn_xml(BPMN) + + self.assertTrue(result.valid_xml) + self.assertEqual(1, result.process_count) + self.assertEqual(1, result.executable_process_count) + self.assertEqual(1, result.collaboration_count) + self.assertEqual(3, result.element_counts["sequenceFlow"]) + review = next( + item for item in result.elements if item.element_id == "Review_1" + ) + collaboration = next( + item + for item in result.elements + if item.element_id == "Collaboration_1" + ) + self.assertEqual("native_execution", review.support_level) + self.assertEqual("native_mapping", collaboration.support_level) + + def test_dangling_references_are_reported(self) -> None: + result = inspect_bpmn_xml( + BPMN.replace('targetRef="End_1"', 'targetRef="Missing_1"') + ) + + self.assertFalse(result.valid_xml) + self.assertTrue( + any( + item.code == "dangling_bpmn_reference" + for item in result.diagnostics + ) + ) + + def test_entities_are_rejected(self) -> None: + unsafe = """]> +&xxe;""" + + with self.assertRaisesRegex( + BpmnInspectionError, + "not safe and well formed", + ): + inspect_bpmn_xml(unsafe) + + def test_non_bpmn_root_is_rejected(self) -> None: + with self.assertRaisesRegex( + BpmnInspectionError, + "bpmn:definitions", + ): + inspect_bpmn_xml("") + + def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None: + profiles = { + item.id: item for item in bpmn_adapter_registry().profiles() + } + self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable) + self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable) + self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable) + + adapter, inspection, graph = compile_bpmn_to_graph( + NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ) + + self.assertEqual("1.0.0", adapter.profile.version) + self.assertTrue(inspection.valid_xml) + self.assertIsNotNone(graph) + assert graph is not None + self.assertEqual( + [ + "workflow.start.manual", + "workflow.activity", + "workflow.end.completed", + ], + [item.type for item in graph.nodes], + ) + self.assertEqual(220, graph.nodes[1].position.x) + self.assertEqual( + ["Flow_start_review", "Flow_review_end"], + [item.id for item in graph.edges], + ) + + def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None: + with self.assertRaisesRegex( + BpmnAdapterError, + "exclusiveGateway is not supported", + ): + compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ) + + def test_model_only_profile_remains_read_compatible(self) -> None: + _adapter, _inspection, graph = compile_bpmn_to_graph( + BPMN, + adapter_id=INTERCHANGE_ADAPTER_ID, + ) + + self.assertIsNone(graph) + + def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None: + graph = import_bpmn_graph(BPMN) + + self.assertEqual( + [ + "bpmn.startEvent", + "bpmn.userTask", + "bpmn.exclusiveGateway", + "bpmn.endEvent", + "bpmn.participant", + ], + [node.type for node in graph.nodes], + ) + self.assertTrue( + all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges) + ) + rendered = export_bpmn_graph(graph, name="Round trip") + imported = import_bpmn_graph(rendered) + self.assertEqual( + [(node.id, node.type) for node in graph.nodes], + [(node.id, node.type) for node in imported.nodes], + ) + self.assertEqual( + [(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges], + [ + (edge.id, edge.type, edge.source, edge.target) + for edge in imported.edges + ], + ) + + def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None: + fixture_directory = Path(__file__).parent / "fixtures" / "bpmn" + + for path in sorted(fixture_directory.glob("*.bpmn")): + with self.subTest(path=path.name): + graph = import_bpmn_graph(path.read_text(encoding="utf-8")) + imported = import_bpmn_graph( + export_bpmn_graph(graph, name=path.stem) + ) + + self.assertEqual( + Counter(node.type for node in graph.nodes), + Counter(node.type for node in imported.nodes), + ) + self.assertEqual( + Counter(edge.type for edge in graph.edges), + Counter(edge.type for edge in imported.edges), + ) + self.assertEqual(len(graph.nodes), len(imported.nodes)) + self.assertEqual(len(graph.edges), len(imported.edges)) + + def test_nested_flows_remain_in_their_bpmn_container(self) -> None: + fixture = ( + Path(__file__).parent + / "fixtures" + / "bpmn" + / "events-transaction-compensation.bpmn" + ) + rendered = export_bpmn_graph( + import_bpmn_graph(fixture.read_text(encoding="utf-8")) + ) + root = parse_bpmn_xml(rendered) + transaction = next( + item + for item in root.iter() + if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction" + ) + + self.assertTrue( + any( + child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association" + and child.attrib.get("id") == "Compensation_Association" + for child in transaction + ) + ) + + def test_default_flow_is_an_editable_edge_property(self) -> None: + source = BPMN.replace( + '', + '', + ) + graph = import_bpmn_graph(source) + default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3") + + self.assertIs(default_edge.config.get("default"), True) + rendered = export_bpmn_graph(graph) + self.assertIn('default="Flow_3"', rendered) + + graph.edges = [ + edge.model_copy(update={"config": {**edge.config, "default": False}}) + if edge.id == "Flow_3" + else edge + for edge in graph.edges + ] + rendered_without_default = export_bpmn_graph(graph) + self.assertNotIn('default="Flow_3"', rendered_without_default) + + def test_native_graph_import_is_separate_from_runtime_support(self) -> None: + _adapter, _inspection, graph = compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + ) + self.assertIsNotNone(graph) + + with self.assertRaisesRegex( + BpmnAdapterError, + "exclusive gateway", + ): + compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + activation=True, + ) + + def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None: + with self.assertRaisesRegex(BpmnAdapterError, "is not installed"): + compile_bpmn_to_graph( + NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + adapter_version="9.0.0", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contributions.py b/tests/test_contributions.py new file mode 100644 index 0000000..41c7299 --- /dev/null +++ b/tests/test_contributions.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from dataclasses import replace +import unittest + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.policy import PolicyDecision +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.workflows import WorkflowDefinitionContribution +from govoplan_core.db.base import Base +from govoplan_workflow_engine.backend.contributions import ( + reconcile_workflow_definition_contributions, + reset_workflow_override_to_standard, +) +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, + WorkflowInstance, + WorkflowInstanceEvent, + WorkflowInstanceStep, +) +from govoplan_workflow_engine.backend.instance_service import start_instance +from govoplan_workflow_engine.backend.schemas import ( + WorkflowDefinitionDeriveRequest, + WorkflowDefinitionUpdateRequest, + WorkflowEdge, + WorkflowGraph, + WorkflowInstanceStartRequest, + WorkflowNode, +) +from govoplan_workflow_engine.backend.service import ( + WorkflowConflictError, + definition_response, + derive_definition, + update_definition, +) + + +def contribution_graph(*, label: str = "Approve request") -> WorkflowGraph: + return WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.manual", + label="Start", + config={"input_schema_ref": ""}, + ), + WorkflowNode( + id="review", + type="workflow.activity", + label=label, + config={ + "title": label, + "instructions": "", + "assignee": "", + "due_after": "", + }, + ), + WorkflowNode( + id="complete", + type="workflow.end.completed", + label="Complete", + config={"output_mapping": {}}, + ), + ], + edges=[ + WorkflowEdge(id="start-review", source="start", target="review"), + WorkflowEdge(id="review-complete", source="review", target="complete"), + ], + ) + + +def contribution( + *, + version: str = "1.0.0", + label: str = "Approve request", +) -> WorkflowDefinitionContribution: + return WorkflowDefinitionContribution( + origin_module_id="permits", + origin_module_version=version, + definition_key="application-approval", + name="Application approval", + description="Module-provided approval baseline.", + graph=contribution_graph(label=label).model_dump(mode="json"), + allow_automation=False, + metadata={"domain": "permits"}, + ) + + +def registry_for(item: WorkflowDefinitionContribution) -> PlatformRegistry: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="permits", + name="Permits", + version=item.origin_module_version, + workflow_definitions=(item,), + ) + ) + registry.validate() + return registry + + +def principal() -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset( + { + "workflow:definition:read", + "workflow:definition:write", + "workflow:instance:admin", + } + ), + ), + account=object(), + user=object(), + ) + + +class _DefinitionPolicy: + def resolve_definition_action(self, *, request): + return PolicyDecision( + allowed=( + request.action in {"view", "derive", "reuse"} + and request.allow_reuse + ), + reason=None, + ) + + +class _PolicyRegistry: + def has_capability(self, name: str) -> bool: + return name == "policy.definitionGovernance" + + def capability(self, name: str): + if not self.has_capability(name): + raise KeyError(name) + return _DefinitionPolicy() + + +class WorkflowContributionTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + WorkflowDefinition.__table__, + WorkflowDefinitionRevision.__table__, + WorkflowInstance.__table__, + WorkflowInstanceStep.__table__, + WorkflowInstanceEvent.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + WorkflowInstanceEvent.__table__, + WorkflowInstanceStep.__table__, + WorkflowInstance.__table__, + WorkflowDefinitionRevision.__table__, + WorkflowDefinition.__table__, + ], + ) + self.engine.dispose() + + def test_reconciliation_is_idempotent_and_does_not_auto_activate_updates(self) -> None: + first = reconcile_workflow_definition_contributions( + self.session, + registry=registry_for(contribution()), + ) + repeated = reconcile_workflow_definition_contributions( + self.session, + registry=registry_for(contribution()), + ) + upgraded = reconcile_workflow_definition_contributions( + self.session, + registry=registry_for( + contribution(version="1.1.0", label="Approve corrected request") + ), + ) + self.session.commit() + + definition = self.session.scalar(select(WorkflowDefinition)) + self.assertIsNotNone(definition) + assert definition is not None + self.assertEqual(1, first["created"]) + self.assertEqual(1, repeated["unchanged"]) + self.assertEqual(1, upgraded["updated"]) + self.assertEqual(2, definition.current_revision) + self.assertEqual(1, definition.active_revision) + self.assertEqual("permits", definition.standard_origin_module_id) + self.assertEqual("1.1.0", definition.standard_origin_module_version) + self.assertEqual(2, len(definition.revisions)) + self.assertNotEqual( + definition.revisions[0].contribution_hash, + definition.revisions[1].contribution_hash, + ) + + def test_standard_is_immutable_and_reset_archives_only_the_override(self) -> None: + reconcile_workflow_definition_contributions( + self.session, + registry=registry_for(contribution()), + ) + baseline = self.session.scalar(select(WorkflowDefinition)) + assert baseline is not None + with self.assertRaisesRegex(WorkflowConflictError, "immutable"): + update_definition( + self.session, + tenant_id="tenant-1", + definition_id=baseline.id, + actor_id="account-1", + payload=WorkflowDefinitionUpdateRequest( + name=baseline.name, + graph=contribution_graph(), + expected_revision=baseline.current_revision, + scope_type="system", + ), + ) + override = derive_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + principal=principal(), + registry=_PolicyRegistry(), + source_definition_id=baseline.id, + payload=WorkflowDefinitionDeriveRequest( + name="Tenant approval override", + scope_type="tenant", + ), + ) + initial = definition_response( + self.session, + override, + principal=principal(), + registry=_PolicyRegistry(), + ) + self.assertIsNotNone(initial.standard) + assert initial.standard is not None + self.assertFalse(initial.standard.update_available) + + reconcile_workflow_definition_contributions( + self.session, + registry=registry_for( + contribution(version="1.1.0", label="Approve corrected request") + ), + ) + drifted = definition_response( + self.session, + override, + principal=principal(), + registry=_PolicyRegistry(), + ) + self.assertIsNotNone(drifted.standard) + assert drifted.standard is not None + self.assertTrue(drifted.standard.update_available) + + restored = reset_workflow_override_to_standard( + self.session, + tenant_id="tenant-1", + definition_id=override.id, + actor_id="account-1", + ) + self.session.commit() + + self.assertEqual(baseline.id, restored.id) + self.assertEqual("archived", override.status) + self.assertEqual("active", baseline.status) + self.assertEqual( + baseline.id, + override.metadata_["standard_reset"]["baseline_definition_id"], + ) + + def test_missing_requirements_fail_closed_without_creating_a_baseline(self) -> None: + item = replace( + contribution(), + required_capabilities=("permits.approvalAction",), + ) + result = reconcile_workflow_definition_contributions( + self.session, + registry=registry_for(item), + ) + + self.assertEqual(1, result["blocked"]) + self.assertIsNone(self.session.scalar(select(WorkflowDefinition))) + self.assertEqual( + ["capability:permits.approvalAction"], + result["items"][0]["missing"], # type: ignore[index] + ) + + def test_domain_contribution_runs_without_the_editor_module(self) -> None: + item = replace( + contribution(), + scope_type="tenant", + inherit_to_lower_scopes=False, + ) + registry = registry_for(item) + result = reconcile_workflow_definition_contributions( + self.session, + registry=registry, + tenant_ids=("tenant-1",), + ) + definition = self.session.scalar(select(WorkflowDefinition)) + assert definition is not None + + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="headless-domain-run", + input={"request_id": "request-1"}, + ), + ) + + self.assertEqual(1, result["created"]) + self.assertFalse(replayed) + self.assertEqual("waiting", instance.status) + self.assertEqual(definition.revisions[0].id, instance.definition_revision_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_governance.py b/tests/test_governance.py new file mode 100644 index 0000000..6eb4a49 --- /dev/null +++ b/tests/test_governance.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.policy import PolicyDecision +from govoplan_core.db.base import Base +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, +) +from govoplan_workflow_engine.backend.governance import normalize_definition_scope +from govoplan_workflow_engine.backend.schemas import ( + WorkflowDefinitionCreateRequest, + WorkflowDefinitionDeriveRequest, + WorkflowDefinitionUpdateRequest, +) +from govoplan_workflow_engine.backend.service import ( + WorkflowConflictError, + activate_definition, + create_definition, + definition_response, + derive_definition, + update_definition, +) +try: + from test_service import sample_graph +except ModuleNotFoundError as exc: + if exc.name != "test_service": + raise + from tests.test_service import sample_graph + + +POLICY_CAPABILITY = "policy.definitionGovernance" + + +def principal() -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset( + { + "workflow:definition:read", + "workflow:definition:write", + "workflow:instance:start", + } + ), + ), + account=object(), + user=object(), + ) + + +class DefinitionPolicy: + def resolve_definition_action(self, *, request): + local = request.definition_scope.scope_type == "tenant" + inherited = ( + request.definition_scope.scope_type == "system" + and request.inherit_to_lower_scopes + ) + allowed = local or inherited + if request.action == "edit": + allowed = local + elif request.action in {"reuse", "derive"}: + allowed = allowed and request.allow_reuse + elif request.action == "run": + allowed = ( + allowed + and request.definition_kind == "flow" + and request.status == "active" + and request.allow_run + ) + elif request.action == "automate": + allowed = ( + allowed + and request.definition_kind == "flow" + and request.allow_automation + ) + return PolicyDecision( + allowed=allowed, + reason=None if allowed else "Definition action denied.", + ) + + +class Registry: + def has_capability(self, name: str) -> bool: + return name == POLICY_CAPABILITY + + def capability(self, name: str): + if not self.has_capability(name): + raise KeyError(name) + return DefinitionPolicy() + + +class WorkflowGovernanceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + WorkflowDefinition.__table__, + WorkflowDefinitionRevision.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + self.principal = principal() + self.registry = Registry() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + WorkflowDefinitionRevision.__table__, + WorkflowDefinition.__table__, + ], + ) + self.engine.dispose() + + def test_derivation_pins_template_revision_and_provenance(self) -> None: + template = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="system-admin", + payload=WorkflowDefinitionCreateRequest( + key="permit-review", + name="Permit review template", + graph=sample_graph(), + scope_type="system", + definition_kind="template", + inherit_to_lower_scopes=True, + allow_reuse=True, + ), + ) + derived = derive_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + principal=self.principal, + registry=self.registry, + source_definition_id=template.id, + payload=WorkflowDefinitionDeriveRequest( + name="Tenant permit review", + allow_start=True, + ), + ) + self.session.commit() + + response = definition_response( + self.session, + derived, + principal=self.principal, + registry=self.registry, + ) + self.assertEqual(template.id, derived.derived_from_definition_id) + self.assertEqual(1, derived.derived_from_revision) + self.assertEqual( + template.revisions[0].content_hash, + derived.derived_from_hash, + ) + self.assertEqual( + "system", + response.governance.derivation_provenance["source_scope"][ + "scope_type" + ], + ) + self.assertFalse(response.governance.automation_runtime_available) + + def test_user_scope_normalizes_membership_to_account_id(self) -> None: + tenant_id, scope_type, scope_id, scope_key = normalize_definition_scope( + self.principal, + scope_type="user", + scope_id="membership-1", + administrative=False, + ) + + self.assertEqual("tenant-1", tenant_id) + self.assertEqual("user", scope_type) + self.assertEqual("account-1", scope_id) + self.assertEqual("user:account-1", scope_key) + + def test_template_cannot_be_activated(self) -> None: + template = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Reusable review", + graph=sample_graph(), + definition_kind="template", + allow_reuse=True, + ), + ) + + with self.assertRaises(WorkflowConflictError): + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=template.id, + actor_id="account-1", + ) + + def test_derived_limits_cannot_be_broadened_transitively(self) -> None: + template = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Restricted review", + graph=sample_graph(), + definition_kind="template", + allow_reuse=True, + allow_automation=False, + inherit_to_lower_scopes=False, + ), + ) + derived = derive_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + principal=self.principal, + registry=self.registry, + source_definition_id=template.id, + payload=WorkflowDefinitionDeriveRequest( + name="Tenant review", + allow_reuse=True, + allow_automation=True, + inherit_to_lower_scopes=True, + ), + ) + update_definition( + self.session, + tenant_id="tenant-1", + definition_id=derived.id, + actor_id="account-1", + payload=WorkflowDefinitionUpdateRequest( + name=derived.name, + graph=sample_graph(), + expected_revision=1, + allow_reuse=True, + allow_automation=True, + inherit_to_lower_scopes=True, + ), + ) + grandchild = derive_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + principal=self.principal, + registry=self.registry, + source_definition_id=derived.id, + payload=WorkflowDefinitionDeriveRequest( + name="User review", + scope_type="user", + scope_id="membership-1", + allow_automation=True, + inherit_to_lower_scopes=True, + ), + ) + + self.assertFalse(derived.allow_automation) + self.assertFalse(derived.inherit_to_lower_scopes) + self.assertFalse(grandchild.allow_automation) + self.assertFalse(grandchild.inherit_to_lower_scopes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_service.py b/tests/test_instance_service.py new file mode 100644 index 0000000..bc4f906 --- /dev/null +++ b/tests/test_instance_service.py @@ -0,0 +1,987 @@ +from __future__ import annotations + +from dataclasses import replace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import ( + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, + PrincipalRef, +) +from govoplan_core.core.automation import ( + ActionDefinition, + ActionExecutionResult, + ActionPreview, + AutomationPrincipalResolution, + EffectDefinition, + EffectPreview, + ObservedEffect, +) +from govoplan_core.core.dataflows import ( + CAPABILITY_DATAFLOW_RUN_LIFECYCLE, + DataflowRunDescriptor, +) +from govoplan_core.db.base import Base +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, + WorkflowInstance, + WorkflowInstanceEvent, + WorkflowInstanceStep, +) +from govoplan_workflow_engine.backend.instance_service import ( + SqlWorkflowRuntimeWorker, + cancel_instance, + instance_response, + reconcile_instance, + resolve_step, + start_instance, +) +from govoplan_workflow_engine.backend.schemas import ( + BpmnRevisionInput, + WorkflowDefinitionCreateRequest, + WorkflowEdge, + WorkflowGraph, + WorkflowInstanceStartRequest, + WorkflowNode, + WorkflowStepActionRequest, +) +from govoplan_workflow_engine.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID +from govoplan_workflow_engine.backend.service import ( + WorkflowConflictError, + activate_definition, + create_definition, +) +try: + from test_bpmn import NATIVE_BPMN +except ModuleNotFoundError as exc: + if exc.name != "test_bpmn": + raise + from tests.test_bpmn import NATIVE_BPMN + + +def principal() -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset( + { + "workflow:definition:read", + "workflow:instance:read", + "workflow:instance:start", + "workflow:instance:transition", + "dataflow:pipeline:run", + } + ), + ), + account=object(), + user=object(), + ) + + +def runtime_graph() -> WorkflowGraph: + return WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.manual", + label="Start", + config={"input_schema_ref": ""}, + ), + WorkflowNode( + id="flow", + type="workflow.dataflow", + label="Prepare evidence", + config={ + "pipeline_ref": "pipeline:pipeline-1", + "revision": 3, + "environment": "development", + "row_limit": 250, + "publication_target_ref": "", + "warning_policy": "review", + "input_mapping": {}, + "view_surface_ids": [ + "dataflow.module", + "dataflow.route.pipelines", + ], + }, + ), + WorkflowNode( + id="complete", + type="workflow.end.completed", + label="Complete", + config={"output_mapping": {}}, + ), + WorkflowNode( + id="cancelled", + type="workflow.end.cancelled", + label="Rejected", + config={"reason": "Rejected during review"}, + ), + ], + edges=[ + WorkflowEdge( + id="start-flow", + source="start", + target="flow", + ), + WorkflowEdge( + id="flow-complete", + source="flow", + source_port="success", + target="complete", + ), + WorkflowEdge( + id="flow-warning", + source="flow", + source_port="warning", + target="complete", + ), + WorkflowEdge( + id="flow-review", + source="flow", + source_port="review_required", + target="complete", + ), + WorkflowEdge( + id="flow-failure", + source="flow", + source_port="failure", + target="cancelled", + ), + ], + ) + + +def action_graph() -> WorkflowGraph: + return WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.manual", + config={"input_schema_ref": ""}, + ), + WorkflowNode( + id="action", + type="workflow.capability", + config={ + "capability": "test.actions", + "operation": "test.case.record", + "input_mapping": {"case_id": "$input.case_id"}, + "idempotency_key": "$input.case_id", + "failure_policy": "manual", + }, + ), + WorkflowNode( + id="complete", + type="workflow.end.completed", + config={"output_mapping": {}}, + ), + WorkflowNode( + id="failed", + type="workflow.end.cancelled", + config={"reason": "Action rejected"}, + ), + ], + edges=[ + WorkflowEdge( + id="start-action", + source="start", + target="action", + ), + WorkflowEdge( + id="action-complete", + source="action", + source_port="success", + target="complete", + ), + WorkflowEdge( + id="action-failed", + source="action", + source_port="failure", + target="failed", + ), + ], + ) + + +class FakeDataflowLifecycle: + def __init__(self) -> None: + self.runs: dict[str, DataflowRunDescriptor] = {} + self.requests = [] + self.cancelled: list[str] = [] + + def start_run(self, _session, _principal, *, request): + self.requests.append(request) + run_ref = f"run:{len(self.requests)}" + descriptor = DataflowRunDescriptor( + ref=run_ref, + pipeline_ref=request.pipeline_ref, + revision=request.revision, + status="queued", + definition_hash="definition-hash", + executor_version="test", + metadata={"progress_percent": 0, "progress_phase": "queued"}, + ) + self.runs[run_ref] = descriptor + return descriptor + + def get_run(self, _session, _principal, *, run_ref): + return self.runs.get(run_ref) + + def cancel_run(self, _session, _principal, *, run_ref): + descriptor = self.runs[run_ref] + descriptor = replace(descriptor, status="cancelled") + self.runs[run_ref] = descriptor + self.cancelled.append(run_ref) + return descriptor + + def finish( + self, + run_ref: str, + *, + diagnostics: list[dict[str, object]] | None = None, + ) -> None: + self.runs[run_ref] = replace( + self.runs[run_ref], + status="succeeded", + output_publication_ref="publication:1", + output_datasource_ref="datasource:1", + output_materialization_ref="materialization:1", + input_row_count=12, + output_row_count=10, + metadata={"diagnostics": diagnostics or []}, + ) + + def fail(self, run_ref: str) -> None: + self.runs[run_ref] = replace( + self.runs[run_ref], + status="failed", + error="Data quality gate failed.", + ) + + +class FakeAutomationProvider: + def __init__(self) -> None: + self.requests = [] + + def resolve_automation_principal(self, _session, *, request): + self.requests.append(request) + return AutomationPrincipalResolution( + allowed=True, + principal=principal(), + granted_scopes=request.grant_scopes, + provenance={"status": "rechecked"}, + ) + + +class FakeActionProvider: + action = ActionDefinition( + action_key="test.case.record", + owner_module="test", + description="Record a test case.", + input_schema_ref="schema:test.case.record@1", + expected_effect_keys=("test.case.recorded",), + ) + effect = EffectDefinition( + effect_key="test.case.recorded", + owner_module="test", + operation="created", + description="A test case was recorded.", + ) + + def __init__(self, *states: str) -> None: + self.states = list(states or ("completed",)) + self.requests = [] + + def action_definitions(self): + return (self.action,) + + def effect_definitions(self): + return (self.effect,) + + def preview_action(self, _session, _principal, *, request): + return ActionPreview( + action_key=request.action_key, + allowed=True, + summary="Record one case.", + risk_level=self.action.risk_level, + reversibility=self.action.reversibility, + effects=( + EffectPreview( + effect_key=self.effect.effect_key, + summary="Record case.", + ), + ), + preview_ref="preview:test", + ) + + def execute_action(self, _session, _principal, *, request): + self.requests.append(request) + state = self.states.pop(0) if self.states else "completed" + if state != "completed": + return ActionExecutionResult( + state=state, + error="Temporary action failure.", + ) + return ActionExecutionResult( + state="completed", + output={"case_ref": "case:1"}, + observed_effects=( + ObservedEffect( + effect_key=self.effect.effect_key, + operation="created", + resource_ref="case:1", + ), + ), + audit_event_refs=("audit:1",), + ) + + +class Registry: + def __init__( + self, + dataflow: FakeDataflowLifecycle, + automation: FakeAutomationProvider | None = None, + action: FakeActionProvider | None = None, + ) -> None: + self.dataflow = dataflow + self.automation = automation + self.action = action + + def has_capability(self, name: str) -> bool: + return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or ( + name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER + and self.automation is not None + ) or ( + name == "test.actions" + and self.action is not None + ) + + def capability(self, name: str): + if name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE: + return self.dataflow + if ( + name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER + and self.automation is not None + ): + return self.automation + if name == "test.actions" and self.action is not None: + return self.action + raise KeyError(name) + + +class WorkflowInstanceServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + WorkflowDefinition.__table__, + WorkflowDefinitionRevision.__table__, + WorkflowInstance.__table__, + WorkflowInstanceStep.__table__, + WorkflowInstanceEvent.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + self.dataflow = FakeDataflowLifecycle() + self.registry = Registry(self.dataflow) + self.definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Monthly governed processing", + graph=runtime_graph(), + execution_mode="hybrid", + view_id="view-1", + view_revision_id="view-revision-1", + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=self.definition.id, + actor_id="account-1", + revision=1, + ) + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + WorkflowInstanceEvent.__table__, + WorkflowInstanceStep.__table__, + WorkflowInstance.__table__, + WorkflowDefinitionRevision.__table__, + WorkflowDefinition.__table__, + ], + ) + self.engine.dispose() + + def _start(self, key: str = "request-1") -> WorkflowInstance: + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=self.definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key=key, + input={"case_id": "case-1"}, + correlation_id="correlation-1", + ), + ) + self.assertFalse(replayed) + return instance + + def test_start_pins_revision_and_replays_idempotently(self) -> None: + instance = self._start() + replayed, was_replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=self.definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="request-1", + input={"case_id": "case-1"}, + correlation_id="correlation-1", + ), + ) + + self.assertTrue(was_replayed) + self.assertEqual(instance.id, replayed.id) + self.assertEqual("waiting", instance.status) + self.assertEqual("user", instance.start_origin) + self.assertEqual(1, len(self.dataflow.requests)) + response = instance_response(self.session, instance) + self.assertEqual("hybrid", response.execution_mode) + self.assertEqual("user", response.start_origin) + self.assertIsNotNone(response.view_context) + self.assertEqual( + [ + "dataflow.module", + "dataflow.route.pipelines", + ], + response.view_context.visible_surface_ids, + ) + self.assertEqual([1, 2], [step.sequence for step in response.steps]) + self.assertEqual("run:1", response.steps[-1].external_ref) + self.assertGreaterEqual(len(response.events), 4) + + with self.assertRaises(WorkflowConflictError): + start_instance( + self.session, + tenant_id="tenant-1", + definition_id=self.definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="request-1", + input={"case_id": "another-case"}, + correlation_id="correlation-1", + ), + ) + + def test_guided_workflow_rejects_automated_start_origin(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Guided review", + graph=WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.api", + config={ + "input_schema_ref": "schema:input", + "authorization_policy_ref": "policy:start", + }, + ), + WorkflowNode( + id="activity", + type="workflow.activity", + config={"title": "Review"}, + ), + WorkflowNode( + id="done", + type="workflow.end.completed", + ), + ], + edges=[ + WorkflowEdge( + id="start-activity", + source="start", + target="activity", + ), + WorkflowEdge( + id="activity-done", + source="activity", + target="done", + ), + ], + ), + execution_mode="guided", + allow_automation=True, + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + + with self.assertRaisesRegex( + WorkflowConflictError, + "Guided workflows must be started by a user", + ): + start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="automated-guided", + ), + start_origin="api", + ) + + def test_module_action_records_effects_and_completes_idempotently( + self, + ) -> None: + action = FakeActionProvider() + registry = Registry(self.dataflow, action=action) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Action workflow", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="action-instance", + input={"case_id": "case-1"}, + ), + ) + response = instance_response(self.session, instance) + + self.assertFalse(replayed) + self.assertEqual("completed", response.status) + self.assertEqual(1, len(action.requests)) + self.assertEqual( + "test.actions:test.case.record:case-1", + action.requests[0].idempotency_key, + ) + self.assertEqual( + "case:1", + response.steps[1].output["execution"]["observed_effects"][0][ + "resource_ref" + ], + ) + self.assertIn( + "workflow.action.completed", + {event.kind for event in response.events}, + ) + + def test_retryable_module_action_reuses_the_idempotency_key(self) -> None: + action = FakeActionProvider("retryable", "completed") + registry = Registry(self.dataflow, action=action) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Retry action", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="retry-action-instance", + input={"case_id": "case-2"}, + ), + ) + waiting = instance_response(self.session, instance) + current = waiting.steps[-1] + self.assertEqual("retryable", current.handoff["state"]) + + resolved = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=current.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowStepActionRequest(action="retry"), + ) + + self.assertEqual("completed", resolved.status) + self.assertEqual( + action.requests[0].idempotency_key, + action.requests[1].idempotency_key, + ) + + def test_automated_dataflow_failure_policy_fails_without_handoff( + self, + ) -> None: + graph = runtime_graph() + flow = next(node for node in graph.nodes if node.id == "flow") + flow.config["warning_policy"] = "continue" + flow.config["failure_policy"] = "fail" + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Automated Dataflow", + graph=graph, + execution_mode="automated", + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="automated-dataflow", + ), + ) + self.dataflow.fail("run:1") + + changed = reconcile_instance( + self.session, + instance=instance, + principal=principal(), + registry=self.registry, + ) + + self.assertTrue(changed) + self.assertEqual("failed", instance.status) + self.assertIsNone(instance.current_step_id) + self.assertFalse( + any( + step.handoff.get("kind") == "dataflow_failure" + for step in instance.steps + ) + ) + + def test_native_bpmn_profile_runs_through_canonical_instance_state(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="BPMN governed review", + graph=runtime_graph(), + bpmn=BpmnRevisionInput( + xml=NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="bpmn-request-1", + input={"case_id": "case-bpmn"}, + ), + ) + + self.assertFalse(replayed) + self.assertEqual("waiting", instance.status) + waiting = instance_response(self.session, instance).steps[-1] + self.assertEqual("workflow.activity", waiting.node_type) + self.assertEqual("Review request", waiting.handoff["title"]) + + replay, was_replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="bpmn-request-1", + input={"case_id": "case-bpmn"}, + ), + ) + self.assertTrue(was_replayed) + self.assertEqual(instance.id, replay.id) + + resolved = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=waiting.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowStepActionRequest( + action="complete", + comment="Reviewed.", + evidence=["case:case-bpmn"], + ), + ) + response = instance_response(self.session, resolved) + self.assertEqual("completed", response.status) + self.assertEqual( + "workflow.instance.completed", + response.events[-1].kind, + ) + + def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None: + instance = self._start() + self.dataflow.finish("run:1") + + changed = reconcile_instance( + self.session, + instance=instance, + principal=principal(), + registry=self.registry, + actor_id="account-1", + ) + response = instance_response(self.session, instance) + + self.assertTrue(changed) + self.assertEqual("completed", response.status) + flow_output = response.context["steps"]["flow"] + self.assertEqual("publication:1", flow_output["output_publication_ref"]) + self.assertEqual("datasource:1", flow_output["output_datasource_ref"]) + self.assertEqual( + "materialization:1", + flow_output["output_materialization_ref"], + ) + self.assertEqual("workflow.instance.completed", response.events[-1].kind) + + def test_warning_requires_review_and_approve_resumes(self) -> None: + instance = self._start() + self.dataflow.finish( + "run:1", + diagnostics=[ + { + "severity": "warning", + "code": "review.required", + "message": "Verify unmatched records.", + } + ], + ) + reconcile_instance( + self.session, + instance=instance, + principal=principal(), + registry=self.registry, + ) + step_id = str(instance.current_step_id) + + self.assertEqual( + "review_required", + instance_response(self.session, instance).steps[-1].handoff["state"], + ) + resolved = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=step_id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowStepActionRequest( + action="approve", + comment="Evidence verified.", + evidence=["publication:1"], + ), + ) + + self.assertEqual("completed", resolved.status) + + def test_failure_can_retry_and_reject_invalid_actions(self) -> None: + instance = self._start() + self.dataflow.fail("run:1") + reconcile_instance( + self.session, + instance=instance, + principal=principal(), + registry=self.registry, + ) + step_id = str(instance.current_step_id) + + with self.assertRaises(WorkflowConflictError): + resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=step_id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowStepActionRequest(action="approve"), + ) + retried = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=step_id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowStepActionRequest(action="retry"), + ) + + self.assertEqual("waiting", retried.status) + self.assertEqual(2, len(self.dataflow.requests)) + self.assertEqual("run:2", retried.steps[-1].external_ref) + self.assertEqual("superseded", retried.steps[-2].status) + + def test_cancel_propagates_to_linked_dataflow(self) -> None: + instance = self._start() + + cancelled = cancel_instance( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + ) + + self.assertEqual("cancelled", cancelled.status) + self.assertEqual(["run:1"], self.dataflow.cancelled) + + def test_worker_rechecks_authorization_before_reconciling(self) -> None: + instance = self._start() + self.session.commit() + self.dataflow.finish("run:1") + automation = FakeAutomationProvider() + worker = SqlWorkflowRuntimeWorker( + registry=Registry(self.dataflow, automation), + ) + + summary = worker.reconcile_pending(self.session) + + self.assertEqual(1, summary["advanced"]) + self.assertEqual("completed", instance.status) + self.assertEqual(1, len(automation.requests)) + self.assertEqual( + "rechecked", + instance.authorization_["last_resolution"]["status"], + ) + + def test_worker_reconciles_pending_module_action(self) -> None: + action = FakeActionProvider("pending", "completed") + automation = FakeAutomationProvider() + registry = Registry( + self.dataflow, + automation=automation, + action=action, + ) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Asynchronous action", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="pending-action", + input={"case_id": "case-3"}, + ), + ) + self.session.commit() + worker = SqlWorkflowRuntimeWorker(registry=registry) + + summary = worker.reconcile_pending(self.session) + + self.assertEqual(1, summary["advanced"]) + self.assertEqual("completed", instance.status) + self.assertEqual(2, len(action.requests)) + self.assertEqual( + action.requests[0].idempotency_key, + action.requests[1].idempotency_key, + ) + self.assertEqual(1, len(automation.requests)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..d5bf076 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import unittest + +from govoplan_workflow_engine.backend.manifest import ( + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + INSTANCE_START_SCOPE, + get_manifest, +) +from govoplan_core.core.workflows import ( + CAPABILITY_WORKFLOW_RUNTIME_WORKER, +) + + +class WorkflowManifestTests(unittest.TestCase): + def test_manifest_exposes_definition_contracts(self) -> None: + manifest = get_manifest() + + self.assertEqual(manifest.id, "workflow_engine") + self.assertEqual(manifest.permission_namespace, "workflow") + self.assertIn( + "workflow.definition_graph", + {item.name for item in manifest.provides_interfaces}, + ) + self.assertIn( + DEFINITION_READ_SCOPE, + {item.scope for item in manifest.permissions}, + ) + self.assertIn( + DEFINITION_WRITE_SCOPE, + {item.scope for item in manifest.permissions}, + ) + self.assertIn( + INSTANCE_START_SCOPE, + {item.scope for item in manifest.permissions}, + ) + self.assertIn( + "workflow.runtime_worker", + {item.name for item in manifest.provides_interfaces}, + ) + self.assertIn( + CAPABILITY_WORKFLOW_RUNTIME_WORKER, + manifest.capability_factories, + ) + self.assertIsNone(manifest.frontend) + self.assertEqual((), manifest.nav_items) + self.assertIsNotNone(manifest.migration_spec) + self.assertEqual("workflow_engine", manifest.migration_spec.module_id) + requirement = next( + item + for item in manifest.requires_interfaces + if item.name == "dataflow.run_lifecycle" + ) + self.assertTrue(requirement.optional) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..6647103 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +import shutil + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from dataclasses import replace + +from govoplan_workflow_engine.backend.manifest import get_manifest + + +class WorkflowMigrationTests(unittest.TestCase): + def test_migration_creates_definition_tables_and_head(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-workflow-engine-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'workflow.db'}" + migrate_database( + database_url=url, + enabled_modules=("workflow_engine",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + with engine.connect() as connection: + self.assertIn( + "0b4e7c9a2d6f", + set(MigrationContext.configure(connection).get_current_heads()), + ) + self.assertEqual( + { + "workflow_definition_revisions", + "workflow_definitions", + "workflow_instance_events", + "workflow_instance_steps", + "workflow_instances", + }, + { + name + for name in inspect(connection).get_table_names() + if name.startswith("workflow_") + }, + ) + revision_columns = { + item["name"] + for item in inspect(connection).get_columns( + "workflow_definition_revisions" + ) + } + self.assertTrue( + { + "bpmn_xml", + "bpmn_hash", + "bpmn_adapter_id", + "bpmn_adapter_version", + "bpmn_runtime_kind", + "bpmn_executable", + "execution_mode", + "view_id", + "view_revision_id", + }.issubset(revision_columns) + ) + instance_columns = { + item["name"] + for item in inspect(connection).get_columns( + "workflow_instances" + ) + } + self.assertIn("start_origin", instance_columns) + definition_columns = { + item["name"] + for item in inspect(connection).get_columns( + "workflow_definitions" + ) + } + self.assertIn( + "standard_origin_module_id", + definition_columns, + ) + finally: + engine.dispose() + + def test_existing_workflow_migration_head_transfers_without_replay(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-workflow-engine-upgrade-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'workflow.db'}" + engine_manifest = get_manifest() + assert engine_manifest.migration_spec is not None + legacy_revisions = Path(directory) / "legacy-revisions" + legacy_revisions.mkdir() + current_revisions = Path( + engine_manifest.migration_spec.script_location or "" + ) + for path in current_revisions.glob("*.py"): + if path.name.startswith("0b4e7c9a2d6f_"): + continue + shutil.copy2(path, legacy_revisions / path.name) + legacy_manifest = replace( + engine_manifest, + id="workflow", + name="Workflow", + permission_namespace=None, + migration_spec=replace( + engine_manifest.migration_spec, + module_id="workflow", + script_location=str(legacy_revisions), + ), + ) + migrate_database( + database_url=url, + enabled_modules=("workflow",), + manifest_factories=(lambda: legacy_manifest,), + ) + engine = create_engine(url) + try: + first_tables = set(inspect(engine).get_table_names()) + with engine.connect() as connection: + self.assertIn( + "f1b7d3e5a9c2", + set( + MigrationContext.configure( + connection + ).get_current_heads() + ), + ) + self.assertNotIn( + "standard_origin_module_id", + { + item["name"] + for item in inspect(engine).get_columns( + "workflow_definitions" + ) + }, + ) + finally: + engine.dispose() + + result = migrate_database( + database_url=url, + enabled_modules=("workflow_engine",), + manifest_factories=(get_manifest,), + ) + + self.assertIn("0b4e7c9a2d6f", result.current_revision or "") + engine = create_engine(url) + try: + self.assertEqual(first_tables, set(inspect(engine).get_table_names())) + self.assertIn( + "standard_origin_module_id", + { + item["name"] + for item in inspect(engine).get_columns( + "workflow_definitions" + ) + }, + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node_library.py b/tests/test_node_library.py new file mode 100644 index 0000000..2e0e5b1 --- /dev/null +++ b/tests/test_node_library.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import unittest + +from govoplan_workflow_engine.backend.node_library import ( + BPMN_NODE_TYPES, + WORKFLOW_GRAPH_LIBRARY, +) +from govoplan_workflow_engine.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode +from govoplan_workflow_engine.backend.validation import validate_workflow_graph + + +class WorkflowNodeLibraryTests(unittest.TestCase): + def test_valid_workflow_graph(self) -> None: + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="start", type="workflow.start.manual"), + WorkflowNode( + id="work", + type="workflow.activity", + config={"title": "Check submission"}, + ), + WorkflowNode(id="done", type="workflow.end.completed"), + ], + edges=[ + WorkflowEdge(id="e1", source="start", target="work"), + WorkflowEdge(id="e2", source="work", target="done"), + ], + ) + + self.assertEqual(validate_workflow_graph(graph), ()) + + def test_correction_loop_is_allowed(self) -> None: + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="start", type="workflow.start.manual"), + WorkflowNode( + id="work", + type="workflow.activity", + config={"title": "Prepare"}, + ), + WorkflowNode( + id="review", + type="workflow.review", + config={"title": "Review"}, + ), + WorkflowNode(id="done", type="workflow.end.completed"), + ], + edges=[ + WorkflowEdge(id="e1", source="start", target="work"), + WorkflowEdge(id="e2", source="work", target="review"), + WorkflowEdge( + id="e3", + source="review", + source_port="changes", + target="work", + ), + WorkflowEdge( + id="e4", + source="review", + source_port="approved", + target="done", + ), + ], + ) + + self.assertNotIn( + "graph.cycle", + {item.code for item in validate_workflow_graph(graph)}, + ) + + def test_constraints_and_required_configuration_are_reported(self) -> None: + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="start-1", type="workflow.start.manual"), + WorkflowNode( + id="start-2", + type="workflow.start.event", + config={"event_type": ""}, + ), + ], + edges=[ + WorkflowEdge(id="e1", source="start-1", target="start-2"), + ], + ) + + diagnostics = validate_workflow_graph(graph) + codes = {item.code for item in diagnostics} + self.assertIn("graph.trigger_count", codes) + self.assertIn("graph.outcome_count", codes) + self.assertIn("node.config_required", codes) + self.assertIn("node.outgoing_required", codes) + + def test_library_has_domain_specific_cycle_policy(self) -> None: + self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles) + self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow") + self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version) + activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity") + self.assertIn( + "view_surface_ids", + {field.id for field in activity.config_fields}, + ) + + def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None: + node_types = {item.type for item in BPMN_NODE_TYPES} + + self.assertIn("bpmn.startEvent", node_types) + self.assertIn("bpmn.userTask", node_types) + self.assertIn("bpmn.exclusiveGateway", node_types) + self.assertIn("bpmn.participant", node_types) + self.assertIn("bpmn.textAnnotation", node_types) + self.assertTrue(all(item.startswith("bpmn.") for item in node_types)) + + def test_bpmn_rejects_multiple_default_flows(self) -> None: + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="start", type="bpmn.startEvent"), + WorkflowNode(id="choice", type="bpmn.exclusiveGateway"), + WorkflowNode(id="end-a", type="bpmn.endEvent"), + WorkflowNode(id="end-b", type="bpmn.endEvent"), + ], + edges=[ + WorkflowEdge(id="to-choice", source="start", target="choice"), + WorkflowEdge( + id="default-a", + source="choice", + target="end-a", + config={"default": True}, + ), + WorkflowEdge( + id="default-b", + source="choice", + target="end-b", + config={"default": True}, + ), + ], + ) + + self.assertIn( + "bpmn.multiple_default_flows", + {item.code for item in validate_workflow_graph(graph)}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..8a3861c --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.db.base import Base +from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, +) +from govoplan_workflow_engine.backend.schemas import ( + BpmnRevisionInput, + WorkflowDefinitionCreateRequest, + WorkflowDefinitionUpdateRequest, + WorkflowEdge, + WorkflowGraph, + WorkflowNode, + WorkflowPosition, +) +from govoplan_workflow_engine.backend.service import ( + WorkflowBpmnValidationError, + WorkflowConflictError, + WorkflowNotFoundError, + activate_definition, + create_definition, + delete_definition, + get_definition, + list_definition_revisions, + list_definitions, + update_definition, +) +from govoplan_workflow_engine.backend.bpmn_adapters import ( + INTERCHANGE_ADAPTER_ID, + NATIVE_LINEAR_ADAPTER_ID, +) +from govoplan_workflow_engine.backend.bpmn import inspect_bpmn_xml +from govoplan_workflow_engine.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID +try: + from test_bpmn import BPMN, NATIVE_BPMN +except ModuleNotFoundError as exc: + if exc.name != "test_bpmn": + raise + from tests.test_bpmn import BPMN, NATIVE_BPMN + + +def sample_graph(*, title: str = "Review request") -> WorkflowGraph: + return WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.manual", + label="Start", + position=WorkflowPosition(x=40, y=100), + config={"input_schema_ref": ""}, + ), + WorkflowNode( + id="activity", + type="workflow.activity", + label="Review", + position=WorkflowPosition(x=280, y=100), + config={ + "title": title, + "instructions": "", + "assignee": "", + "due_after": "", + }, + ), + WorkflowNode( + id="complete", + type="workflow.end.completed", + label="Completed", + position=WorkflowPosition(x=520, y=100), + config={"output_mapping": {}}, + ), + ], + edges=[ + WorkflowEdge(id="start-activity", source="start", target="activity"), + WorkflowEdge(id="activity-complete", source="activity", target="complete"), + ], + ) + + +class WorkflowServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + WorkflowDefinition.__table__, + WorkflowDefinitionRevision.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + WorkflowDefinitionRevision.__table__, + WorkflowDefinition.__table__, + ], + ) + self.engine.dispose() + + def _create(self, *, tenant_id: str = "tenant-1") -> WorkflowDefinition: + definition = create_definition( + self.session, + tenant_id=tenant_id, + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Monthly case handling", + graph=sample_graph(), + ), + ) + self.session.commit() + return definition + + def test_create_update_and_activate_pin_immutable_revisions(self) -> None: + definition = self._create() + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="Monthly case handling", + graph=sample_graph(title="Review corrected request"), + expected_revision=1, + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + revision=1, + ) + self.session.commit() + + revisions = list_definition_revisions( + self.session, + definition=updated, + ) + self.assertEqual(2, updated.current_revision) + self.assertEqual(1, updated.active_revision) + self.assertEqual("active", updated.status) + self.assertEqual([2, 1], [item.revision for item in revisions]) + self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash) + historical = next(item for item in revisions if item.revision == 1) + self.assertEqual( + "Review request", + historical.graph["nodes"][1]["config"]["title"], + ) + + def test_metadata_update_does_not_create_graph_revision(self) -> None: + definition = self._create() + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="Renamed workflow", + description="Updated metadata only", + graph=sample_graph(), + metadata={"owner": "finance"}, + expected_revision=1, + ), + ) + self.session.commit() + + self.assertEqual(1, updated.current_revision) + self.assertEqual( + 1, + len( + list( + self.session.scalars( + select(WorkflowDefinitionRevision).where( + WorkflowDefinitionRevision.definition_id + == definition.id + ) + ) + ) + ), + ) + + def test_execution_mode_and_view_pin_are_immutable_revision_content( + self, + ) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Guided review", + graph=sample_graph(), + execution_mode="guided", + view_id="view-1", + view_revision_id="view-revision-1", + ), + ) + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="Guided review", + graph=sample_graph(), + expected_revision=1, + execution_mode="hybrid", + view_id="view-1", + view_revision_id="view-revision-1", + ), + ) + + revisions = list_definition_revisions( + self.session, + definition=updated, + ) + self.assertEqual(2, updated.current_revision) + self.assertEqual("hybrid", revisions[0].execution_mode) + self.assertEqual("guided", revisions[1].execution_mode) + self.assertEqual("view-revision-1", revisions[1].view_revision_id) + self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash) + + def test_automated_mode_rejects_human_handoff_paths(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Invalid automation", + graph=sample_graph(), + execution_mode="automated", + allow_automation=True, + ), + ) + + with self.assertRaisesRegex( + WorkflowConflictError, + "human handoff paths", + ): + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-1", + ) + + def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None: + definition = self._create() + with self.assertRaises(WorkflowConflictError): + update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="Stale", + graph=sample_graph(), + expected_revision=2, + ), + ) + with self.assertRaises(WorkflowNotFoundError): + get_definition( + self.session, + tenant_id="tenant-2", + definition_id=definition.id, + ) + + def test_soft_delete_preserves_revisions_and_hides_definition(self) -> None: + definition = self._create() + delete_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + ) + self.session.commit() + + self.assertEqual([], list_definitions(self.session, tenant_id="tenant-1")) + self.assertEqual( + 1, + len( + list( + self.session.scalars( + select(WorkflowDefinitionRevision).where( + WorkflowDefinitionRevision.definition_id + == definition.id + ) + ) + ) + ), + ) + + def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="BPMN review", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + adapter_version="1.0.0", + ), + ), + ) + self.session.commit() + + first = list_definition_revisions( + self.session, + definition=definition, + )[0] + self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml) + self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id) + self.assertEqual("1.0.0", first.bpmn_adapter_version) + self.assertEqual("native_graph", first.bpmn_runtime_kind) + self.assertEqual( + "Review request", + first.graph["nodes"][1]["config"]["title"], + ) + + changed_xml = NATIVE_BPMN.replace( + "Review request", + "Review corrected request", + ) + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="BPMN review", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=changed_xml, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ), + expected_revision=1, + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + revision=2, + ) + self.session.commit() + + revisions = list_definition_revisions( + self.session, + definition=updated, + ) + self.assertEqual(2, updated.current_revision) + self.assertEqual(2, updated.active_revision) + self.assertIn("Review corrected request", revisions[0].bpmn_xml or "") + self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash) + + def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Interchange model", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=BPMN, + adapter_id=INTERCHANGE_ADAPTER_ID, + ), + ), + ) + self.session.commit() + + with self.assertRaisesRegex( + WorkflowBpmnValidationError, + "exclusive gateway", + ): + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-1", + ) + + def test_interchange_revision_preserves_extension_xml_exactly(self) -> None: + xml = ( + Path(__file__).parent + / "fixtures" + / "bpmn" + / "process.bpmn" + ).read_text(encoding="utf-8") + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Extended interchange model", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=xml, + adapter_id=INTERCHANGE_ADAPTER_ID, + adapter_version="1.0.0", + ), + ), + ) + + revision = list_definition_revisions( + self.session, + definition=definition, + )[0] + self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml) + self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "") + + +if __name__ == "__main__": + unittest.main()