feat: extract headless workflow engine
This commit is contained in:
@@ -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:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -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?
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
+348
@@ -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/
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# GovOPlaN Workflow Engine
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
+201
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Headless GovOPlaN Workflow Engine module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.14"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow backend."""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WorkflowDefinition",
|
||||||
|
"WorkflowDefinitionRevision",
|
||||||
|
"WorkflowInstance",
|
||||||
|
"WorkflowInstanceEvent",
|
||||||
|
"WorkflowInstanceStep",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow database migrations."""
|
||||||
+124
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow Alembic revisions."""
|
||||||
+201
@@ -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")
|
||||||
+209
@@ -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,
|
||||||
|
)
|
||||||
+213
@@ -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")
|
||||||
+68
@@ -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")
|
||||||
+62
@@ -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")
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow test package for both discovery and targeted module execution."""
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Choreography"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Approval" name="Approval" />
|
||||||
|
<bpmn:collaboration id="Collaboration_Choreography">
|
||||||
|
<bpmn:participant id="Participant_Applicant" name="Applicant" />
|
||||||
|
<bpmn:participant id="Participant_Authority" name="Authority" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
<bpmn:choreography id="Choreography_1" name="Permit decision">
|
||||||
|
<bpmn:startEvent id="Choreography_Start" />
|
||||||
|
<bpmn:choreographyTask
|
||||||
|
id="Choreography_Task"
|
||||||
|
initiatingParticipantRef="Participant_Authority">
|
||||||
|
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
|
||||||
|
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
|
||||||
|
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
|
||||||
|
</bpmn:choreographyTask>
|
||||||
|
<bpmn:endEvent id="Choreography_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Choreography_Flow_1"
|
||||||
|
sourceRef="Choreography_Start"
|
||||||
|
targetRef="Choreography_Task" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Choreography_Flow_2"
|
||||||
|
sourceRef="Choreography_Task"
|
||||||
|
targetRef="Choreography_End" />
|
||||||
|
</bpmn:choreography>
|
||||||
|
<bpmn:messageFlow
|
||||||
|
id="MessageFlow_Approval"
|
||||||
|
sourceRef="Participant_Authority"
|
||||||
|
targetRef="Participant_Applicant"
|
||||||
|
messageRef="Message_Approval" />
|
||||||
|
</bpmn:definitions>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Collaboration"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Request" name="Request" />
|
||||||
|
<bpmn:process id="Process_Requester">
|
||||||
|
<bpmn:startEvent id="Requester_Start" />
|
||||||
|
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:process id="Process_Reviewer">
|
||||||
|
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
|
||||||
|
<bpmn:endEvent id="Reviewer_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:collaboration id="Collaboration_1">
|
||||||
|
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
|
||||||
|
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
|
||||||
|
<bpmn:messageFlow
|
||||||
|
id="MessageFlow_1"
|
||||||
|
sourceRef="Send_Request"
|
||||||
|
targetRef="Receive_Request"
|
||||||
|
messageRef="Message_Request" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
</bpmn:definitions>
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Control_Flow"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:signal id="Signal_Escalation" name="Escalation" />
|
||||||
|
<bpmn:process id="Called_Process" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Called_Start" />
|
||||||
|
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
|
||||||
|
<bpmn:endEvent id="Called_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Called_Flow_1"
|
||||||
|
sourceRef="Called_Start"
|
||||||
|
targetRef="Called_Human_Task" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Called_Flow_2"
|
||||||
|
sourceRef="Called_Human_Task"
|
||||||
|
targetRef="Called_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:process id="Control_Process" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Control_Start" />
|
||||||
|
<bpmn:exclusiveGateway id="Control_Decision" />
|
||||||
|
<bpmn:subProcess id="Review_Subprocess" name="Review">
|
||||||
|
<bpmn:startEvent id="Subprocess_Start" />
|
||||||
|
<bpmn:userTask id="Subprocess_Review" name="Review request" />
|
||||||
|
<bpmn:endEvent id="Subprocess_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Subprocess_Flow_1"
|
||||||
|
sourceRef="Subprocess_Start"
|
||||||
|
targetRef="Subprocess_Review" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Subprocess_Flow_2"
|
||||||
|
sourceRef="Subprocess_Review"
|
||||||
|
targetRef="Subprocess_End" />
|
||||||
|
</bpmn:subProcess>
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Review_Escalation"
|
||||||
|
attachedToRef="Review_Subprocess"
|
||||||
|
cancelActivity="false">
|
||||||
|
<bpmn:signalEventDefinition
|
||||||
|
id="Review_Escalation_Definition"
|
||||||
|
signalRef="Signal_Escalation" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:parallelGateway id="Control_Join" />
|
||||||
|
<bpmn:callActivity
|
||||||
|
id="Call_Confirmation"
|
||||||
|
name="Confirm"
|
||||||
|
calledElement="Called_Process" />
|
||||||
|
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
|
||||||
|
<bpmn:signalEventDefinition
|
||||||
|
id="Escalation_Thrown_Definition"
|
||||||
|
signalRef="Signal_Escalation" />
|
||||||
|
</bpmn:intermediateThrowEvent>
|
||||||
|
<bpmn:task
|
||||||
|
id="Compensation_Handler"
|
||||||
|
name="Undo review"
|
||||||
|
isForCompensation="true" />
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Review_Compensation"
|
||||||
|
attachedToRef="Review_Subprocess">
|
||||||
|
<bpmn:compensateEventDefinition
|
||||||
|
id="Review_Compensation_Definition"
|
||||||
|
activityRef="Compensation_Handler" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:endEvent id="Control_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_1"
|
||||||
|
sourceRef="Control_Start"
|
||||||
|
targetRef="Control_Decision" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_2"
|
||||||
|
sourceRef="Control_Decision"
|
||||||
|
targetRef="Review_Subprocess" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_3"
|
||||||
|
sourceRef="Review_Subprocess"
|
||||||
|
targetRef="Control_Join" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_4"
|
||||||
|
sourceRef="Control_Join"
|
||||||
|
targetRef="Call_Confirmation" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_5"
|
||||||
|
sourceRef="Call_Confirmation"
|
||||||
|
targetRef="Escalation_Thrown" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_6"
|
||||||
|
sourceRef="Escalation_Thrown"
|
||||||
|
targetRef="Control_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Data"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
|
||||||
|
<bpmn:process id="Process_Data">
|
||||||
|
<bpmn:dataObject id="DataObject_Request" name="Request" />
|
||||||
|
<bpmn:dataObjectReference
|
||||||
|
id="DataObjectReference_Request"
|
||||||
|
dataObjectRef="DataObject_Request" />
|
||||||
|
<bpmn:dataStoreReference
|
||||||
|
id="DataStoreReference_Archive"
|
||||||
|
dataStoreRef="DataStore_Archive" />
|
||||||
|
<bpmn:scriptTask id="Transform_Data" name="Transform data">
|
||||||
|
<bpmn:script>result = input</bpmn:script>
|
||||||
|
<bpmn:dataInputAssociation id="InputAssociation_1">
|
||||||
|
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
|
||||||
|
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
|
||||||
|
</bpmn:dataInputAssociation>
|
||||||
|
<bpmn:dataOutputAssociation id="OutputAssociation_1">
|
||||||
|
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
|
||||||
|
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
|
||||||
|
</bpmn:dataOutputAssociation>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Events"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Continue" name="Continue" />
|
||||||
|
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
|
||||||
|
<bpmn:process id="Process_Transaction" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Start_Timer">
|
||||||
|
<bpmn:timerEventDefinition id="Timer_Start_Definition">
|
||||||
|
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
|
||||||
|
</bpmn:timerEventDefinition>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:transaction id="Transaction_1">
|
||||||
|
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Charge_Error"
|
||||||
|
attachedToRef="Charge_Account">
|
||||||
|
<bpmn:errorEventDefinition
|
||||||
|
id="Charge_Error_Definition"
|
||||||
|
errorRef="Error_Processing" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:task
|
||||||
|
id="Undo_Charge"
|
||||||
|
name="Undo charge"
|
||||||
|
isForCompensation="true" />
|
||||||
|
<bpmn:association
|
||||||
|
id="Compensation_Association"
|
||||||
|
sourceRef="Charge_Error"
|
||||||
|
targetRef="Undo_Charge"
|
||||||
|
associationDirection="One" />
|
||||||
|
</bpmn:transaction>
|
||||||
|
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
|
||||||
|
<bpmn:messageEventDefinition
|
||||||
|
id="Wait_Message_Definition"
|
||||||
|
messageRef="Message_Continue" />
|
||||||
|
</bpmn:intermediateCatchEvent>
|
||||||
|
<bpmn:endEvent id="End_Transaction">
|
||||||
|
<bpmn:terminateEventDefinition id="Terminate_Definition" />
|
||||||
|
</bpmn:endEvent>
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
|
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||||
|
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||||
|
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
|
||||||
|
id="Definitions_Process"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:process id="Process_Linear" isExecutable="true">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<govoplan:fixture revision="1">
|
||||||
|
<govoplan:note>Preserve this extension exactly.</govoplan:note>
|
||||||
|
</govoplan:fixture>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:startEvent id="Start_1">
|
||||||
|
<bpmn:outgoing>Flow_1</bpmn:outgoing>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:userTask id="Task_1" name="Review">
|
||||||
|
<bpmn:incoming>Flow_1</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_2</bpmn:outgoing>
|
||||||
|
</bpmn:userTask>
|
||||||
|
<bpmn:endEvent id="End_1">
|
||||||
|
<bpmn:incoming>Flow_2</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmndi:BPMNDiagram id="Diagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
|
||||||
|
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
|
||||||
|
<dc:Bounds x="80" y="112" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
|
||||||
|
<dc:Bounds x="220" y="90" width="100" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
|
||||||
|
<dc:Bounds x="430" y="112" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
|
||||||
|
<di:waypoint x="116" y="130" />
|
||||||
|
<di:waypoint x="220" y="130" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
|
||||||
|
<di:waypoint x="320" y="130" />
|
||||||
|
<di:waypoint x="430" y="130" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
||||||
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_1"
|
||||||
|
targetNamespace="https://govoplan.example.test/workflow">
|
||||||
|
<bpmn:process id="Process_1" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Start_1" />
|
||||||
|
<bpmn:userTask id="Review_1" name="Review request" />
|
||||||
|
<bpmn:exclusiveGateway id="Decision_1" />
|
||||||
|
<bpmn:endEvent id="End_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Review_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_2" sourceRef="Review_1" targetRef="Decision_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_3" sourceRef="Decision_1" targetRef="End_1" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:collaboration id="Collaboration_1">
|
||||||
|
<bpmn:participant id="Participant_1" processRef="Process_1" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
</bpmn:definitions>
|
||||||
|
"""
|
||||||
|
|
||||||
|
NATIVE_BPMN = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
|
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||||
|
id="Definitions_native"
|
||||||
|
targetNamespace="https://govoplan.example.test/workflow/native">
|
||||||
|
<bpmn:process id="Process_native" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Start_native" />
|
||||||
|
<bpmn:userTask id="Review_native" name="Review request" />
|
||||||
|
<bpmn:endEvent id="End_native" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_start_review" sourceRef="Start_native" targetRef="Review_native" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_review_end" sourceRef="Review_native" targetRef="End_native" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmndi:BPMNDiagram id="Diagram_native">
|
||||||
|
<bpmndi:BPMNPlane id="Plane_native" bpmnElement="Process_native">
|
||||||
|
<bpmndi:BPMNShape id="Shape_start" bpmnElement="Start_native">
|
||||||
|
<dc:Bounds x="40" y="120" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_review" bpmnElement="Review_native">
|
||||||
|
<dc:Bounds x="220" y="90" width="100" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_end" bpmnElement="End_native">
|
||||||
|
<dc:Bounds x="460" y="120" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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 = """<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||||
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_1" targetNamespace="x">&xxe;</bpmn:definitions>"""
|
||||||
|
|
||||||
|
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("<definitions />")
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<bpmn:exclusiveGateway id="Decision_1" />',
|
||||||
|
'<bpmn:exclusiveGateway id="Decision_1" default="Flow_3" />',
|
||||||
|
)
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user