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