29 Commits
Author SHA1 Message Date
zemion 8305bd5c5b Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:51 +02:00
zemion 15956173f6 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:12 +02:00
zemion 46c849e2fa Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:26 +02:00
zemion e344af0b0a Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:21 +02:00
zemion 5f00898bed Make package publication retries hash-safe 2026-08-04 14:32:21 +02:00
zemion db8970adea Harden module package publication 2026-08-04 14:02:42 +02:00
zemion 629f76440b Adopt the shared interface pattern language 2026-08-04 08:21:50 +02:00
zemion 06f93f4f7a Add protected package release workflow 2026-08-04 04:14:08 +02:00
zemion 00fb6b56a9 Restore workflow facade recovery tests 2026-08-03 07:40:53 +02:00
zemion 7936aadd93 Expose workflow effect reconciliation controls 2026-08-03 06:37:45 +02:00
zemion 6e27e72a54 Cover extracted workflow trigger tables 2026-08-01 20:57:26 +02:00
zemion ca32a6ee5c docs: declare institutional architecture boundary 2026-08-01 17:48:41 +02:00
zemion 9d987c7339 Add workflow standard comparison controls 2026-07-31 19:40:08 +02:00
zemion 8fd8753012 refactor: retain workflow as optional editor 2026-07-31 16:59:02 +02:00
zemion 484ac43352 docs: define workflow engine and editor split 2026-07-31 15:07:53 +02:00
zemion f4974b4949 Implement native BPMN workflows and guided modes 2026-07-31 02:48:57 +02:00
zemion c505e81006 feat: add BPMN inspection and workflow progress visuals 2026-07-30 17:42:10 +02:00
zemion a6e0e89829 feat(workflow): orchestrate resumable dataflow handoffs 2026-07-30 03:11:56 +02:00
zemion a1654e70cf perf(workflow): page scope references 2026-07-30 01:30:38 +02:00
zemion c45e2b808c feat: select governed workflow scope targets 2026-07-29 14:32:54 +02:00
zemion b015569b5e Declare Views integration contract 2026-07-28 21:04:55 +02:00
zemion 63e5ce949d Keep workflow nodes initialized 2026-07-28 15:51:38 +02:00
zemion 1ec8336c02 Add governed reusable workflow definitions 2026-07-28 15:04:32 +02:00
zemion 6737b60c11 feat: persist and edit versioned workflow definitions 2026-07-28 13:48:06 +02:00
zemion 85eef00913 feat: add reusable workflow definition graphs 2026-07-28 12:43:26 +02:00
zemion 0d099b05b7 Release v0.1.8 2026-07-11 16:49:05 +02:00
zemion c2dc8c0ded Release v0.1.7 2026-07-11 02:34:59 +02:00
zemion 9311913292 Add shared GovOPlaN gitignore 2026-07-10 21:57:29 +02:00
zemion c3143a8fd2 chore: sync GovOPlaN module split state 2026-07-10 12:51:23 +02:00
56 changed files with 9545 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+348
View File
@@ -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/
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Workflow Codex Guide
## Scope
This repository owns the workflow editing and inspection experience, including BPMN-compatible native graph authoring and module-provided definition views.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Workflow internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- `govoplan-workflow-engine` owns headless definitions, versions, execution, and resumable runtime state.
- The editor must consume engine contracts without duplicating execution semantics.
+35
View File
@@ -0,0 +1,35 @@
# GovOPlaN Workflow
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
Optional visual authoring and inspection workspace for GovOPlaN Workflow
Engine.
This module owns the Workflow catalogue, native BPMN/graph editor, validation
and activation UI, immutable revision inspection, instance controls, and the
governed module-standard compare/override/reset experience. The headless
`govoplan-workflow-engine` package owns persistence, migrations, API routes,
runtime services, and module integration contracts.
For one compatibility release, Python imports below
`govoplan_workflow.backend` re-export their corresponding Workflow Engine
implementations. New module code must use Core's `workflow.*` capabilities or,
for engine implementation code, `govoplan_workflow_engine` directly.
See [the engine/editor split](docs/ENGINE_EDITOR_SPLIT.md) for the durable
ownership boundary.
See [the module concept](docs/CONCEPT.md) and
[BPMN interoperability contract](docs/BPMN_INTEROPERABILITY.md) for the shared
model retained by Workflow Engine.
The editor route, graph, decision, state, and accessibility mapping is recorded
in [the interface pattern audit](docs/INTERFACE_PATTERN_MIGRATION.md).
## Checks
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run typecheck
cd webui && npm run test:interface-pattern
```
+84
View File
@@ -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
View File
@@ -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.
+137
View File
@@ -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.
+30
View File
@@ -0,0 +1,30 @@
# Workflow Interface Pattern Migration
Workflow is the optional visual authoring and inspection surface for the
headless Workflow Engine. It composes native BPMN graph editing, immutable
revision inspection, governed activation, and instance evidence without owning
runtime persistence or importing module-private action implementations.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| `/workflow` definition catalogue | List-detail workspace | Search, loading, empty, selected, current/historical revision, baseline, override, and update-available states retain definition context. |
| Native BPMN graph | Specialized create/edit workspace | Palette nodes can be dragged or added with keyboard activation. Nodes and edges can be selected, edited, reconnected, or removed through explicit controls; drag is not the only path. |
| Definition settings and derivation | Governed create/edit dialogs | Scope, kind, execution mode, inherited visibility, reuse, automation, and immutable View revision expose field help and policy provenance. |
| Validate/save/activate/archive/delete/reset | Review and consequential actions | Validation diagnostics identify graph elements. Save creates a revision; activation changes the runnable revision; archive/delete/reset use explicit state gates and Core confirmation dialogs. |
| Runs dialog and open-work widget | Monitoring, progress, and human decision | Instance/step status, handoff instructions, evidence references, transitions, retries, cancellation, reconciliation, failures, and partial outcomes remain durable Workflow Engine evidence. |
Core owns buttons, icon buttons, dialog/focus behavior, alerts, status, form
help, toggles, selectors, unsaved-navigation protection, and documentation help.
The graph itself is the authorized domain-specific editor. Module standards and
optional Views/Policy integrations are consumed through public capabilities.
Responsive layouts move catalogue, palette, graph, and inspector into task
order; reduced-motion preferences disable editor animation as a source of
meaning.
Verification:
- `npm run typecheck`
- `npm run test:interface-pattern`
- Workflow editor and Workflow Engine backend suites
- manifest shape, optional-module permutations, structural localization, theme,
and full-product bundle-budget checks
+27
View File
@@ -0,0 +1,27 @@
# Workflow Visual Model
The Campaign review flow is the reference for runtime workflow progress:
- a compact stage rail communicates order, current state, completion, warning,
failure, partial progress, and locks;
- the active handoff owns the detailed controls;
- unknown module-action outcomes expose evidence fields plus **Effect
confirmed** and **Effect absent** actions; Retry stays hidden until absence is
verified;
- evidence remains visible without turning every stage into a permanent card;
- unavailable stages stay visibly unavailable while non-blocking optional
stages do not interrupt the connector state.
Workflow now applies that language to instance progress without importing
Campaign code. Once the state vocabulary has stabilized, the rail should move
to Core as a generic process-stage component and Campaign should consume it.
Navigation has three distinct layers:
1. the platform siderail selects a module or focused View;
2. the module workspace selects an object or definition;
3. the workflow stage rail describes progress inside that object.
A focused View or active Workflow may suppress unrelated platform and module
navigation, but must always provide a visible escape back to the normal View.
Modules should not add another persistent navigation tier for workflow stages.
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow"
version = "0.1.18"
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-workflow-engine>=0.1.18",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_workflow = ["py.typed"]
[project.entry-points."govoplan.modules"]
workflow = "govoplan_workflow.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""Optional GovOPlaN Workflow editor and compatibility package."""
__version__ = "0.1.18"
@@ -0,0 +1 @@
"""Compatibility facades for the extracted Workflow Engine backend."""
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn_adapters import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn_graph import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.db import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.db.models import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.governance import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.instance_service import * # noqa: F401,F403
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from govoplan_core.core.modules import (
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
ViewSurface,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_workflow_engine.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
INSTANCE_READ_SCOPE,
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
)
MODULE_ID = "workflow"
MODULE_NAME = "Workflow"
MODULE_VERSION = "0.1.18"
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("workflow_engine",),
provides_interfaces=(
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="workflow.definition_graph",
version_min="0.1.0",
version_max_exclusive="1.0.0",
),
ModuleInterfaceRequirement(
name="workflow.definition_catalogue",
version_min="0.1.0",
version_max_exclusive="1.0.0",
),
ModuleInterfaceRequirement(
name="workflow.bpmn_interchange",
version_min="1.0.0",
version_max_exclusive="2.0.0",
),
),
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/workflow-webui",
routes=(
FrontendRoute(
path="/workflow",
component="WorkflowPage",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
view_surfaces=(
ViewSurface(
id="workflow.widget.open-work",
module_id=MODULE_ID,
kind="section",
label="Open workflow work widget",
order=76,
),
),
),
documentation=(
DocumentationTopic(
id="workflow.editor",
title="Workflow editor and inspection workspace",
summary=(
"Optional authoring, validation, revision inspection, and "
"operator controls for Workflow Engine."
),
body=(
"Workflow adds the visual BPMN/native graph editor, definition "
"catalogue, immutable revision comparison, activation controls, "
"instance inspection, and governed override/reset experience. "
"Definitions and instances remain owned and executed by the "
"headless Workflow Engine module."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "power_user", "product_owner"),
related_modules=("workflow_engine", "views", "policy", "audit"),
links=(
DocumentationLink(
label="Workflow interface pattern audit",
href="govoplan-workflow/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
),
order=76,
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="editor",
maturity="vertical_slice",
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
test_ref="tests/test_manifest.py",
known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",),
owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"),
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
security_docs=("docs/CONCEPT.md",),
),
)
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,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.node_library import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.router import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.runtime import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.schemas import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.service import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.validation import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
"""Workflow test package for both discovery and targeted module execution."""
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+27
View File
@@ -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>
+50
View File
@@ -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>
+355
View File
@@ -0,0 +1,355 @@
from __future__ import annotations
from collections import Counter
import unittest
from pathlib import Path
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnInspectionError,
inspect_bpmn_xml,
parse_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.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()
+275
View File
@@ -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.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.governance import normalize_definition_scope
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionUpdateRequest,
)
from govoplan_workflow.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()
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.manifest import get_manifest
class WorkflowManifestTests(unittest.TestCase):
def test_manifest_is_an_editor_only_engine_client(self) -> None:
manifest = get_manifest()
self.assertEqual("workflow", manifest.id)
self.assertEqual(("workflow_engine",), manifest.dependencies)
self.assertEqual(
{"workflow.definition_graph", "workflow.definition_catalogue", "workflow.bpmn_interchange"},
{item.name for item in manifest.requires_interfaces},
)
self.assertIn(
"workflow.editor",
{item.name for item in manifest.provides_interfaces},
)
self.assertEqual((), manifest.permissions)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertEqual({}, manifest.capability_factories)
self.assertEqual(
"@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None,
)
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.manifest import get_manifest
class WorkflowEditorMigrationTests(unittest.TestCase):
def test_editor_does_not_own_database_migrations(self) -> None:
self.assertIsNone(get_manifest().migration_spec)
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
from govoplan_workflow.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()
+427
View File
@@ -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.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
create_definition,
delete_definition,
get_definition,
list_definition_revisions,
list_definitions,
update_definition,
)
from govoplan_workflow.backend.bpmn_adapters import (
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
)
from govoplan_workflow.backend.bpmn import inspect_bpmn_xml
from govoplan_workflow.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()
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/workflow-webui",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/workflow/WorkflowPage.tsx", "utf8");
const runs = fs.readFileSync("src/features/workflow/WorkflowRunsDialog.tsx", "utf8");
const inspector = fs.readFileSync("src/features/workflow/WorkflowInspector.tsx", "utf8");
const styles = fs.readFileSync("src/styles/workflow.css", "utf8");
assert.ok(page.includes("DocumentationHelpLink"), "Workflow exposes configured-system help");
assert.ok(page.includes("useUnsavedDraftGuard"), "Workflow protects dirty graph revisions during navigation");
assert.ok(page.includes("<ConfirmDialog"), "Destructive and corrective definition actions use shared confirmation");
assert.ok(page.includes("<Dialog"), "Definition settings and derivation use shared focus-contained dialogs");
assert.ok(page.includes("onClick={() => addNodeFromPalette(nodeType.type)}"), "Palette nodes have a keyboard/pointer alternative to drag and drop");
assert.ok(inspector.includes("onEdgeChange"), "Edges can be edited without reconnect dragging");
assert.ok(runs.includes("StatusBadge"), "Run and handoff states are not conveyed by color alone");
assert.ok(runs.includes("DismissibleAlert"), "Run failures use the shared alert contract");
assert.ok(!page.includes("window.alert("), "Workflow must not use browser alerts");
assert.ok(styles.includes("@media (max-width: 680px)"), "Workflow retains a narrow-viewport layout");
assert.ok(styles.includes("@media (prefers-reduced-motion: reduce)"), "Workflow honors reduced motion");
console.log("Workflow interface pattern contract passed.");
+668
View File
@@ -0,0 +1,668 @@
import {
apiFetch,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphEdge,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowExecutionMode = "guided" | "automated" | "hybrid";
export type WorkflowStartOrigin =
| "user"
| "api"
| "schedule"
| "event"
| "parent_workflow"
| "dependency"
| "retry"
| "replay"
| "backfill";
export type WorkflowGraphNode = DefinitionGraphNode & {
size?: { width: number; height: number } | null;
parent_id?: string | null;
process_id?: string | null;
};
export type WorkflowGraphEdge = DefinitionGraphEdge & {
type:
| "bpmn.sequenceFlow"
| "bpmn.messageFlow"
| "bpmn.association"
| "bpmn.dataInputAssociation"
| "bpmn.dataOutputAssociation"
| "bpmn.conversationLink";
label: string;
config: Record<string, unknown>;
waypoints: Array<{ x: number; y: number }>;
};
export type WorkflowGraph = Omit<DefinitionGraph, "nodes" | "edges"> & {
schema_version: 1;
nodes: WorkflowGraphNode[];
edges: WorkflowGraphEdge[];
metadata: Record<string, unknown>;
};
export type WorkflowDiagnostic = {
severity: "error" | "warning";
code: string;
message: string;
node_id?: string | null;
field?: string | null;
};
export type WorkflowNodeType = DefinitionGraphNodeType;
export type BpmnRuntimeKind = "model_only" | "native_graph" | "external";
export type BpmnDiagnostic = {
severity: "error" | "warning" | "info";
code: string;
message: string;
element_id?: string | null;
};
export type BpmnAdapterProfile = {
id: string;
version: string;
label: string;
description: string;
conformance: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
supported_elements: string[];
supported_event_definitions: string[];
requirements: string[];
};
export type BpmnInspection = {
valid_xml: boolean;
definitions_id?: string | null;
target_namespace?: string | null;
process_count: number;
executable_process_count: number;
collaboration_count: number;
choreography_count: number;
element_counts: Record<string, number>;
support_counts: Record<string, number>;
elements: Array<{
element_type: string;
element_id?: string | null;
name?: string | null;
parent_type?: string | null;
parent_id?: string | null;
support_level: "interchange_only" | "native_mapping" | "native_execution";
}>;
diagnostics: BpmnDiagnostic[];
adapter_id?: string | null;
adapter_version?: string | null;
runtime_kind?: BpmnRuntimeKind | null;
executable: boolean;
activatable: boolean;
};
export type BpmnRevisionSummary = {
format: "bpmn-2.0";
content_hash: string;
adapter_id: string;
adapter_version: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
adapter_available: boolean;
};
export type BpmnRevisionDocument = BpmnRevisionSummary & {
definition_id: string;
revision: number;
xml: string;
inspection: BpmnInspection;
};
export type WorkflowRevision = {
id: string;
revision: number;
schema_version: number;
graph: WorkflowGraph;
content_hash: string;
library_id: string;
library_version: string;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
bpmn?: BpmnRevisionSummary | null;
contribution_origin_module_version?: string | null;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
contribution_metadata: Record<string, unknown>;
created_by?: string | null;
created_at: string;
};
export type WorkflowStandardProvenance = {
kind: "baseline" | "override";
origin_module_id: string;
origin_module_version?: string | null;
definition_key: string;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
baseline_definition_id: string;
latest_baseline_revision: number;
active_baseline_revision?: number | null;
pinned_baseline_revision?: number | null;
pinned_baseline_hash?: string | null;
update_available: boolean;
reset_available: boolean;
};
export type WorkflowStandardDiffItem = {
resource_type: "graph" | "node" | "edge";
resource_id: string;
state: "unchanged" | "local_only" | "upstream_only" | "same_change" | "conflict";
recommended_action: "none" | "keep_local" | "adopt_upstream" | "either" | "manual_resolution";
changed_fields: string[];
baseline?: Record<string, unknown> | null;
local?: Record<string, unknown> | null;
latest?: Record<string, unknown> | null;
};
export type WorkflowStandardDiff = {
override_definition_id: string;
baseline_definition_id: string;
pinned_baseline_revision: number;
local_revision: number;
latest_baseline_revision: number;
counts: Record<string, number>;
conflict_count: number;
auto_mergeable: boolean;
items: WorkflowStandardDiffItem[];
};
export type WorkflowDefinition = {
id: string;
tenant_id: string | null;
key: string;
name: string;
description?: string | null;
status: WorkflowStatus;
current_revision: number;
active_revision?: number | null;
metadata: Record<string, unknown>;
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
revision: WorkflowRevision;
governance: WorkflowGovernance;
standard?: WorkflowStandardProvenance | null;
};
export type WorkflowActionDecision = {
allowed: boolean;
reason?: string | null;
source_path: Array<Record<string, unknown>>;
requirements: string[];
details: Record<string, unknown>;
};
export type WorkflowGovernance = {
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
derived_from_definition_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
derivation_provenance: Record<string, unknown>;
actions: Record<string, WorkflowActionDecision>;
automation_runtime_available: boolean;
automation_runtime_reason?: string | null;
};
export type WorkflowInstanceStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled";
export type WorkflowStepStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled"
| "superseded";
export type WorkflowInstanceStep = {
id: string;
sequence: number;
node_id: string;
node_type: string;
status: WorkflowStepStatus;
attempt: number;
input: Record<string, unknown>;
output: Record<string, unknown>;
handoff: Record<string, unknown>;
external_ref?: string | null;
started_at?: string | null;
finished_at?: string | null;
error?: string | null;
completed_by?: string | null;
created_at: string;
updated_at: string;
};
export type WorkflowInstanceEvent = {
id: string;
sequence: number;
step_id?: string | null;
kind: string;
actor_id?: string | null;
payload: Record<string, unknown>;
created_at: string;
};
export type WorkflowInstance = {
id: string;
definition_id: string;
definition_name: string;
definition_revision: number;
definition_hash: string;
execution_mode: WorkflowExecutionMode;
start_origin: WorkflowStartOrigin;
view_context?: {
view_id: string;
revision_id?: string | null;
visible_surface_ids: string[];
step_id?: string | null;
node_id?: string | null;
} | null;
status: WorkflowInstanceStatus;
idempotency_key: string;
correlation_id?: string | null;
current_step_id?: string | null;
input: Record<string, unknown>;
context: Record<string, unknown>;
output: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
cancellation_requested_at?: string | null;
error?: string | null;
created_by?: string | null;
created_at: string;
updated_at: string;
steps: WorkflowInstanceStep[];
events: WorkflowInstanceEvent[];
replayed: boolean;
};
export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
bpmn?: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
} | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
};
export async function getBpmnSupportProfile(
settings: ApiSettings
): Promise<{
specification: string;
model_namespace: string;
interchange: string;
native_runtime: string;
native_execution_elements: string[];
native_mapping_elements: string[];
adapters: BpmnAdapterProfile[];
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/profile");
}
export function inspectWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
activation?: boolean;
}
): Promise<BpmnInspection> {
return apiFetch(settings, "/api/v1/workflow/bpmn/inspect", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function compileWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
}
): Promise<{
adapter: BpmnAdapterProfile;
graph: WorkflowGraph;
inspection: BpmnInspection;
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/compile", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function renderWorkflowBpmn(
settings: ApiSettings,
payload: { graph: WorkflowGraph; name?: string }
): Promise<{ xml: string; inspection: BpmnInspection }> {
return apiFetch(settings, "/api/v1/workflow/bpmn/render", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
return apiFetch(settings, "/api/v1/workflow/node-types");
}
export async function listWorkflowDefinitions(
settings: ApiSettings
): Promise<WorkflowDefinition[]> {
const response = await apiFetch<{ definitions: WorkflowDefinition[] }>(
settings,
"/api/v1/workflow/definitions"
);
return response.definitions;
}
export function createWorkflowDefinition(
settings: ApiSettings,
payload: WorkflowDefinitionPayload
): Promise<WorkflowDefinition> {
return apiFetch(settings, "/api/v1/workflow/definitions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: WorkflowDefinitionPayload & { expected_revision: number }
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{
method: "PUT",
body: JSON.stringify(payload)
}
);
}
export function deriveWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: {
key?: string | null;
name: string;
description?: string | null;
source_revision?: number | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode?: WorkflowExecutionMode | null;
view_id?: string | null;
view_revision_id?: string | null;
}
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function reconcileWorkflowStandards(
settings: ApiSettings
): Promise<{
discovered: number;
created: number;
updated: number;
unchanged: number;
blocked: number;
pending_tenant_scope: number;
items: Array<Record<string, unknown>>;
}> {
return apiFetch(settings, "/api/v1/workflow/standards/reconcile", {
method: "POST"
});
}
export function resetWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/reset-standard`,
{ method: "POST" }
);
}
export function compareWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowStandardDiff> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/standard-diff`
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowRevision[]> {
const response = await apiFetch<{ revisions: WorkflowRevision[] }>(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions`
);
return response.revisions;
}
export function getWorkflowRevision(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<WorkflowRevision> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}`
);
}
export function getWorkflowRevisionBpmn(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<BpmnRevisionDocument> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}/bpmn`
);
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
revision?: number
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/activate`,
{
method: "POST",
body: JSON.stringify({ revision })
}
);
}
export function archiveWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/archive`,
{ method: "POST" }
);
}
export function deleteWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<{ deleted: boolean; definition_id: string }> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{ method: "DELETE" }
);
}
export function validateWorkflowDefinition(
settings: ApiSettings,
graph: WorkflowGraph
): Promise<{ valid: boolean; diagnostics: WorkflowDiagnostic[] }> {
return apiFetch(settings, "/api/v1/workflow/definitions/validate", {
method: "POST",
body: JSON.stringify({ graph })
});
}
export function workflowScopeReferenceProvider(
settings: ApiSettings,
scopeType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
"/api/v1/workflow/scope-targets",
{ scope_type: scopeType }
);
}
export async function listWorkflowInstances(
settings: ApiSettings,
definitionId?: string | null
): Promise<WorkflowInstance[]> {
const params = new URLSearchParams();
if (definitionId) params.set("definition_id", definitionId);
const query = params.size ? `?${params.toString()}` : "";
const response = await apiFetch<{ instances: WorkflowInstance[] }>(
settings,
`/api/v1/workflow/instances${query}`
);
return response.instances;
}
export function startWorkflowInstance(
settings: ApiSettings,
definitionId: string,
payload: {
idempotency_key: string;
input?: Record<string, unknown>;
correlation_id?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/instances`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function getWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}`
);
}
export function reconcileWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/reconcile`,
{ method: "POST" }
);
}
export function resolveWorkflowStep(
settings: ApiSettings,
instanceId: string,
stepId: string,
payload: {
action: "complete" | "approve" | "changes" | "reject" | "resume" | "retry" | "confirm_effect" | "confirm_absent" | "cancel";
output?: Record<string, unknown>;
evidence?: string[];
comment?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/steps/${encodeURIComponent(stepId)}/actions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function cancelWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/cancel`,
{ method: "POST" }
);
}
@@ -0,0 +1,385 @@
import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
MarkerType,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDiagnostic,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
import { newWorkflowNode } from "./model";
import WorkflowNode, { type WorkflowFlowNode } from "./WorkflowNode";
const nodeTypes = { workflow: WorkflowNode };
export default function WorkflowCanvas({
graph,
diagnostics,
nodeLibrary,
selectedNodeId,
selectedEdgeId,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode,
onSelectEdge
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
selectedEdgeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
onSelectEdge: (edgeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | null
>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const definitions = useMemo(
() => new Map(nodeLibrary.map((item) => [item.type, item])),
[nodeLibrary]
);
const errorNodeIds = useMemo(
() => new Set(
diagnostics
.filter((item) => item.severity === "error" && item.node_id)
.map((item) => item.node_id as string)
),
[diagnostics]
);
const nodes = useMemo<WorkflowFlowNode[]>(
() => graph.nodes.flatMap((node) => {
const definition = definitions.get(node.type);
if (!definition) return [];
return [{
id: node.id,
type: "workflow" as const,
position: node.position,
initialWidth: canvasNodeSize(node, definition).width,
initialHeight: canvasNodeSize(node, definition).height,
selected: node.id === selectedNodeId,
data: {
label: node.label,
workflowType: node.type,
definition,
hasError: errorNodeIds.has(node.id)
}
}];
}),
[definitions, errorNodeIds, graph.nodes, selectedNodeId]
);
const edges = useMemo<Edge[]>(
() => graph.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
label: edge.label || undefined,
className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`,
selected: edge.id === selectedEdgeId,
animated: edge.type === "bpmn.messageFlow",
markerEnd: edgeMarkerEnd(edge),
style: edge.type === "bpmn.association"
? { strokeDasharray: "4 4" }
: edge.type === "bpmn.messageFlow"
? { strokeDasharray: "8 5" }
: undefined
})),
[graph.edges, selectedEdgeId]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
const movedIds = new Set(
nextNodes
.filter((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
return current
&& (
current.position.x !== flowNode.position.x
|| current.position.y !== flowNode.position.y
);
})
.map((node) => node.id)
);
onGraphChange({
...graph,
nodes: nextNodes.map((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
if (!current) throw new Error(`Unknown Workflow node: ${flowNode.id}`);
return { ...current, position: flowNode.position };
}),
edges: graph.edges.filter(
(edge) => ids.has(edge.source) && ids.has(edge.target)
).map((edge) =>
movedIds.has(edge.source) || movedIds.has(edge.target)
? { ...edge, waypoints: [] }
: edge
)
});
};
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => {
const current = graph.edges.find((item) => item.id === edge.id);
const endpointsChanged = Boolean(
current
&& (
current.source !== edge.source
|| current.target !== edge.target
)
);
return {
id: edge.id,
type: current?.type ?? "bpmn.sequenceFlow",
label: current?.label ?? "",
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "outgoing",
target_port: edge.targetHandle ?? "incoming",
config: structuredClone(current?.config ?? {}),
waypoints: endpointsChanged
? []
: structuredClone(current?.waypoints ?? [])
} satisfies WorkflowGraphEdge;
})
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
reconnectingEdgeId.current
? {
...graph,
edges: graph.edges.filter(
(edge) => edge.id !== reconnectingEdgeId.current
)
}
: graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
},
{ allowCycles: allowsCycles }
) === null;
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
const type = event.dataTransfer.getData(
"application/x-govoplan-workflow-node"
);
if (!type) return;
const node = newWorkflowNode(
type,
instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
nodeLibrary
);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectNode(node.id);
};
return (
<div
className="workflow-canvas"
onDragOver={(event) => {
if (
event.dataTransfer.types.includes(
"application/x-govoplan-workflow-node"
)
) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={onDrop}
>
<ReactFlow<WorkflowFlowNode, Edge>
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
if (readOnly) return;
const graphChanges = changes.filter(
(change) => change.type !== "dimensions"
);
if (graphChanges.length) {
updateNodes(applyNodeChanges(graphChanges, nodes));
}
}}
onEdgesChange={(changes) => {
if (readOnly) return;
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
onSelectEdge(selectedChange.id);
}
const graphChanges = changes.filter(
(change) => change.type !== "select"
);
if (graphChanges.length) {
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
updateEdges(addEdge({
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
}}
onReconnect={(oldEdge, connection) => {
if (readOnly || !isValidConnection(connection)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
}}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
onSelectEdge(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => {
onSelectEdge(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
onSelectNode(null);
onSelectEdge(edge.id);
}}
onPaneClick={() => {
onSelectNode(null);
onSelectEdge(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
connectionRadius={32}
fitView
fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }}
minZoom={0.25}
maxZoom={1.8}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
<MiniMap
pannable
zoomable
nodeStrokeWidth={2}
nodeColor={(node) =>
node.data.hasError ? "var(--danger)" : "var(--accent)"
}
/>
<Controls showInteractive={false} />
</ReactFlow>
{!graph.nodes.length ? (
<div className="workflow-canvas-empty">Drop a start node here</div>
) : null}
</div>
);
}
function edgeMarkerEnd(edge: WorkflowGraphEdge) {
if (edge.type === "bpmn.sequenceFlow") {
return {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
if (
edge.type === "bpmn.messageFlow"
|| edge.type === "bpmn.dataInputAssociation"
|| edge.type === "bpmn.dataOutputAssociation"
) {
return {
type: MarkerType.Arrow,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
return undefined;
}
function canvasNodeSize(
node: WorkflowGraphNode,
definition: WorkflowNodeType
): { width: number; height: number } {
const shape = String(definition.metadata?.shape ?? "activity");
if (shape.startsWith("event")) return { width: 124, height: 70 };
if (shape === "gateway") return { width: 124, height: 82 };
if (shape === "participant" || shape === "lane") {
return {
width: Math.max(240, Math.min(node.size?.width ?? 360, 720)),
height: Math.max(100, Math.min(node.size?.height ?? 160, 360))
};
}
if (shape === "group") {
return {
width: Math.max(220, node.size?.width ?? 300),
height: Math.max(120, node.size?.height ?? 180)
};
}
return { width: 190, height: 64 };
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
): WorkflowGraph {
return {
...graph,
nodes: graph.nodes.map((node) =>
node.id === updatedNode.id ? updatedNode : node
)
};
}
@@ -0,0 +1,332 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DismissibleAlert,
FormField,
ReferenceMultiSelect,
useViewSurfaces,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
edge,
nodeLibrary,
readOnly,
onChange,
onDelete,
onEdgeChange,
onEdgeDelete
}: {
node: WorkflowGraphNode | null;
edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void;
onEdgeDelete: (edgeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
const viewSurfaces = useViewSurfaces();
const viewSurfaceProvider = useMemo<ReferenceOptionProvider>(() => {
const options = viewSurfaces.map((surface) => ({
value: surface.id,
label: surface.label,
description: `${surface.moduleId} · ${surface.kind}`,
searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}`
}));
const byId = new Map(options.map((option) => [option.value, option]));
return {
search: async (query, context) => {
const normalized = query.trim().toLowerCase();
return options
.filter((option) => (
!normalized
|| option.searchText.toLowerCase().includes(normalized)
))
.slice(0, context.limit);
},
resolve: async (values) => values
.map((value) => byId.get(value))
.filter((option): option is (typeof options)[number] => Boolean(option))
};
}, [viewSurfaces]);
useEffect(() => {
if (!node) {
setJsonDrafts({});
setError("");
return;
}
const definition = nodeLibrary.find((item) => item.type === node.type);
setJsonDrafts(Object.fromEntries(
(definition?.config_fields ?? [])
.filter((field) => field.kind === "mapping")
.map((field) => [
field.id,
JSON.stringify(node.config[field.id] ?? {}, null, 2)
])
));
setError("");
}, [node?.id, nodeLibrary]);
if (edge) {
const updateEdgeConfig = (field: string, value: unknown) => {
onEdgeChange({
...edge,
config: { ...edge.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Flow inspector">
<div className="workflow-panel-heading">
<span>
<strong>Flow</strong>
<small>{edge.type.replace(/^bpmn\./, "")}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onEdgeDelete(edge.id)}
disabled={readOnly}
aria-label="Delete flow"
title="Delete flow"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
<FormField label="Flow type">
<select
value={edge.type}
onChange={(event) => onEdgeChange({
...edge,
type: event.target.value as WorkflowGraphEdge["type"]
})}
disabled={readOnly}
>
<option value="bpmn.sequenceFlow">Sequence flow</option>
<option value="bpmn.messageFlow">Message flow</option>
<option value="bpmn.association">Association</option>
<option value="bpmn.dataInputAssociation">
Data input association
</option>
<option value="bpmn.dataOutputAssociation">
Data output association
</option>
<option value="bpmn.conversationLink">
Conversation link
</option>
</select>
</FormField>
<FormField label="Name">
<input
value={edge.label}
onChange={(event) => onEdgeChange({
...edge,
label: event.target.value
})}
disabled={readOnly}
/>
</FormField>
{edge.type === "bpmn.sequenceFlow" ? (
<>
<FormField
label="Condition"
help="A constrained expression evaluated when this flow is reached."
>
<textarea
value={textValue(edge.config.condition)}
onChange={(event) =>
updateEdgeConfig("condition", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<FormField
label="Runtime outcome"
help="Optional GovOPlaN task outcome mapped to this sequence flow."
>
<input
value={textValue(edge.config.outcome)}
onChange={(event) =>
updateEdgeConfig("outcome", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<label className="workflow-inspector-checkbox">
<input
type="checkbox"
checked={edge.config.default === true}
onChange={(event) =>
updateEdgeConfig("default", event.target.checked)
}
disabled={readOnly}
/>
Default flow
</label>
</>
) : null}
</div>
</aside>
);
}
if (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
<div className="workflow-inspector-empty">No node selected</div>
</aside>
);
}
const definition = nodeLibrary.find((item) => item.type === node.type);
const updateConfig = (field: string, value: unknown) => {
onChange({
...node,
config: { ...node.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading">
<span>
<strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<FormField label="Name">
<input
value={node.label}
onChange={(event) =>
onChange({ ...node, label: event.target.value })
}
disabled={readOnly}
/>
</FormField>
{(definition?.config_fields ?? []).map((field) => (
<FormField
key={field.id}
label={`${field.label}${field.required ? " *" : ""}`}
help={field.description ?? undefined}
>
{field.kind === "select" ? (
<select
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
>
<option value="">Choose</option>
{field.options.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : field.kind === "textarea" || field.kind === "expression" ? (
<textarea
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
) : field.kind === "mapping" ? (
<textarea
className="workflow-json-editor"
value={jsonDrafts[field.id] ?? "{}"}
onChange={(event) => setJsonDrafts((current) => ({
...current,
[field.id]: event.target.value
}))}
onBlur={() => {
try {
const parsed: unknown = JSON.parse(
jsonDrafts[field.id] ?? "{}"
);
if (!isRecord(parsed)) {
throw new Error(`${field.label} must be a JSON object.`);
}
setError("");
updateConfig(field.id, parsed);
} catch (parseError) {
setError(
parseError instanceof Error
? parseError.message
: `${field.label} is invalid.`
);
}
}}
spellCheck={false}
disabled={readOnly}
/>
) : field.kind === "string_list" ? (
<input
value={stringList(node.config[field.id]).join(", ")}
onChange={(event) => updateConfig(
field.id,
event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
)}
disabled={readOnly}
/>
) : field.kind === "view_surfaces" ? (
<ReferenceMultiSelect
values={stringList(node.config[field.id])}
onChange={(values) => updateConfig(field.id, values)}
provider={viewSurfaceProvider}
aria-label={field.label}
placeholder="Add a visible surface"
searchPlaceholder="Search modules and interface surfaces"
disabled={readOnly}
/>
) : (
<input
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
)}
</FormField>
))}
</div>
</aside>
);
}
function textValue(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,159 @@
import {
Asterisk,
BadgeDollarSign,
BoxSelect,
Braces,
CircleDashed,
CircleDot,
CircleDotDashed,
CirclePlus,
CircleStop,
Cog,
Database,
Diamond,
ExternalLink,
File,
FileCode2,
GitBranch,
Hand,
Inbox,
MessagesSquare,
Plus,
RadioTower,
RectangleHorizontal,
Rows3,
Scale,
Send,
Shuffle,
Square,
TextQuote,
UserRoundCheck,
CalendarClock,
CheckCircle2,
CirclePlay,
CircleX,
ClipboardCheck,
GitFork,
PlugZap,
Radio,
Split,
SquareCheckBig,
Timer,
Waypoints,
type LucideIcon
} from "lucide-react";
import {
Handle,
Position,
type Node,
type NodeProps
} from "@xyflow/react";
import type { WorkflowNodeType } from "../../api/workflow";
export type WorkflowFlowNodeData = {
label: string;
workflowType: string;
definition: WorkflowNodeType;
hasError: boolean;
};
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
const iconByName: Record<string, LucideIcon> = {
"calendar-clock": CalendarClock,
"circle-check-big": CheckCircle2,
"circle-play": CirclePlay,
"circle-x": CircleX,
"clipboard-check": ClipboardCheck,
"plug-zap": PlugZap,
radio: Radio,
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints,
asterisk: Asterisk,
"badge-dollar-sign": BadgeDollarSign,
"box-select": BoxSelect,
braces: Braces,
"circle-dashed": CircleDashed,
"circle-dot": CircleDot,
"circle-dot-dashed": CircleDotDashed,
"circle-plus": CirclePlus,
"circle-stop": CircleStop,
cog: Cog,
database: Database,
diamond: Diamond,
"external-link": ExternalLink,
file: File,
"file-code-2": FileCode2,
"git-branch": GitBranch,
hand: Hand,
inbox: Inbox,
"messages-square": MessagesSquare,
plus: Plus,
"radio-tower": RadioTower,
"rectangle-horizontal": RectangleHorizontal,
"rows-3": Rows3,
scale: Scale,
send: Send,
shuffle: Shuffle,
square: Square,
"text-quote": TextQuote,
"user-round-check": UserRoundCheck
};
export default function WorkflowNode({
data,
selected,
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const shape = String(data.definition.metadata?.shape ?? "activity");
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
<div
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
`workflow-node-shape-${shape}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
className="workflow-node-handle"
style={{ top: portPosition(index, inputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
<span className="workflow-node-icon"><Icon size={17} /></span>
<span className="workflow-node-copy">
<strong>{data.label}</strong>
<small>{data.definition.label}</small>
</span>
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
className="workflow-node-handle"
style={{ top: portPosition(index, outputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
</div>
);
}
function portPosition(index: number, count: number): string {
return `${((index + 1) / (count + 1)) * 100}%`;
}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { ListChecks } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listWorkflowInstances,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
export default function WorkflowOpenWorkWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 6, 1, 20);
const includeRunning = configuration.includeRunning !== false;
const load = useCallback(async () => {
const instances = await listWorkflowInstances(settings);
return instances
.filter((instance) =>
instance.status === "waiting"
|| (includeRunning && instance.status === "running")
)
.sort(compareOpenWork)
.slice(0, maxItems);
}, [includeRunning, maxItems, settings]);
const { data: instances, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading open workflow work">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No workflow work is currently open."
items={(instances ?? []).map((instance) => {
const step = currentStep(instance);
return {
id: instance.id,
title: instance.definition_name,
detail: handoffTitle(step),
meta: updatedLabel(instance.updated_at),
leading: <ListChecks size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={instance.status}
label={instance.status === "waiting" ? "Waiting" : "Running"}
/>
),
to: workflowRunUrl(instance)
};
})}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/workflow">
Open Workflow
</Link>
</div>
</LoadingFrame>
);
}
function currentStep(
instance: WorkflowInstance
): WorkflowInstanceStep | null {
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffTitle(step: WorkflowInstanceStep | null): string {
const title = step?.handoff.title;
if (typeof title === "string" && title.trim()) return title;
if (!step) return "Preparing next step";
return step.node_type
.replace(/^workflow\./, "")
.split(".")
.join(" ");
}
function workflowRunUrl(instance: WorkflowInstance): string {
const query = new URLSearchParams({
definition: instance.definition_id,
run: instance.id
});
return `/workflow?${query.toString()}`;
}
function compareOpenWork(
left: WorkflowInstance,
right: WorkflowInstance
): number {
if (left.status !== right.status) {
return left.status === "waiting" ? -1 : 1;
}
return (
new Date(right.updated_at).getTime()
- new Date(left.updated_at).getTime()
);
}
function updatedLabel(value: string): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,595 @@
import {
useCallback,
useEffect,
useMemo,
useState
} from "react";
import {
AlertTriangle,
Check,
Circle,
Clock3,
ExternalLink,
Play,
RefreshCw,
RotateCcw,
XCircle
} from "lucide-react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingFrame,
StageRail,
StatusBadge,
dispatchWorkflowViewChanged,
usePlatformUiCapability,
type StageRailTone,
type ApiSettings,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
listWorkflowInstances,
reconcileWorkflowInstance,
resolveWorkflowStep,
startWorkflowInstance,
type WorkflowDefinition,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
type WorkflowAction =
| "complete"
| "approve"
| "changes"
| "reject"
| "resume"
| "retry"
| "confirm_effect"
| "confirm_absent"
| "cancel";
export default function WorkflowRunsDialog({
open,
settings,
definition,
initialInstanceId,
canStart,
canTransition,
onClose
}: {
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
initialInstanceId?: string | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
}) {
const [instances, setInstances] = useState<WorkflowInstance[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
"views.runtime"
);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
[instances, selectedId]
);
const currentStep = useMemo(
() => currentInstanceStep(selected),
[selected]
);
const allowedActions = useMemo(
() => handoffActions(currentStep),
[currentStep]
);
const mergeInstance = useCallback((instance: WorkflowInstance) => {
setInstances((current) => [
instance,
...current.filter((item) => item.id !== instance.id)
]);
setSelectedId(instance.id);
}, []);
const load = useCallback(async () => {
if (!open || !definition?.id) return;
setLoading(true);
setError("");
try {
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
initialInstanceId
&& items.some((item) => item.id === initialInstanceId)
? initialInstanceId
: items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [definition?.id, initialInstanceId, open, settings]);
useEffect(() => {
if (!open) return;
setComment("");
setEvidence("");
void load();
}, [load, open]);
useEffect(() => {
if (!open || !selected) return;
const context = selected.view_context;
if (!context || !viewsRuntime) {
dispatchWorkflowViewChanged(null);
return;
}
let cancelled = false;
void viewsRuntime.resolveWorkflowView(settings, {
viewId: context.view_id,
revisionId: context.revision_id,
visibleSurfaceIds: context.visible_surface_ids
}).then((projection) => {
if (!cancelled) {
dispatchWorkflowViewChanged(projection, selected.id);
}
}).catch((viewError) => {
if (!cancelled) {
dispatchWorkflowViewChanged(null);
setError(errorMessage(viewError));
}
});
return () => {
cancelled = true;
};
}, [
open,
selected?.id,
selected?.updated_at,
settings,
viewsRuntime
]);
useEffect(() => {
if (
!open
|| !canTransition
|| !selected
|| !currentStep
|| currentStep.node_type !== "workflow.dataflow"
|| !["queued", "retrying", "running"].includes(
String(currentStep.handoff.state ?? "")
)
) {
return;
}
let stopped = false;
const poll = window.setInterval(() => {
void reconcileWorkflowInstance(settings, selected.id)
.then((instance) => {
if (!stopped) mergeInstance(instance);
})
.catch((pollError) => {
if (!stopped) setError(errorMessage(pollError));
});
}, 2500);
return () => {
stopped = true;
window.clearInterval(poll);
};
}, [
canTransition,
currentStep,
mergeInstance,
open,
selected,
settings
]);
const start = async () => {
if (!definition?.id) return;
setWorking(true);
setError("");
try {
const instance = await startWorkflowInstance(settings, definition.id, {
idempotency_key: crypto.randomUUID(),
input: {}
});
mergeInstance(instance);
} catch (startError) {
setError(errorMessage(startError));
} finally {
setWorking(false);
}
};
const refreshSelected = async () => {
if (!selected) {
await load();
return;
}
setWorking(true);
setError("");
try {
const instance = canTransition
? await reconcileWorkflowInstance(settings, selected.id)
: (await listWorkflowInstances(settings, definition?.id))
.find((item) => item.id === selected.id);
if (instance) mergeInstance(instance);
else await load();
} catch (refreshError) {
setError(errorMessage(refreshError));
} finally {
setWorking(false);
}
};
const performAction = async (action: WorkflowAction) => {
if (!selected || !currentStep) return;
setWorking(true);
setError("");
try {
const instance = await resolveWorkflowStep(
settings,
selected.id,
currentStep.id,
{
action,
comment: comment.trim() || null,
evidence: evidence
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
);
mergeInstance(instance);
setComment("");
setEvidence("");
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setWorking(false);
}
};
const cancel = async () => {
if (!selected) return;
setWorking(true);
setError("");
try {
mergeInstance(await cancelWorkflowInstance(settings, selected.id));
setCancelOpen(false);
} catch (cancelError) {
setError(errorMessage(cancelError));
} finally {
setWorking(false);
}
};
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
const close = () => {
dispatchWorkflowViewChanged(null);
onClose();
};
return (
<>
<Dialog
open={open}
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={close}
footer={<Button onClick={close}>Close</Button>}
>
<div className="workflow-runs-toolbar">
<span>
<strong>Workflow instances</strong>
<small>Revision-pinned runs and human handoffs</small>
</span>
<span>
<IconButton
label="Refresh runs"
icon={<RefreshCw size={16} />}
variant="ghost"
onClick={() => void refreshSelected()}
disabled={loading || working}
/>
<Button
variant="primary"
onClick={() => void start()}
disabled={!canStart || working || definition?.status !== "active"}
disabledReason={
definition?.status !== "active"
? "Activate a definition revision before starting it."
: undefined
}
>
<Play size={16} /> Start
</Button>
</span>
</div>
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<LoadingFrame loading={loading} className="workflow-runs-frame">
<div className="workflow-runs-layout">
<div className="workflow-run-list">
{instances.map((instance) => (
<button
key={instance.id}
type="button"
className={instance.id === selected?.id ? "is-selected" : ""}
onClick={() => {
setSelectedId(instance.id);
setComment("");
setEvidence("");
}}
>
<span>
<strong>{formatDateTime(instance.started_at)}</strong>
<small>
Revision {instance.definition_revision} · {instance.steps.length} steps
</small>
</span>
<StatusBadge
status={instance.status}
label={instance.status}
/>
</button>
))}
{!instances.length ? (
<div className="workflow-run-empty">No runs yet</div>
) : null}
</div>
<div className="workflow-run-detail">
{selected ? (
<>
<header>
<span>
<strong>{selected.definition_name}</strong>
<small>
Revision {selected.definition_revision} · {selected.definition_hash.slice(0, 12)}
</small>
</span>
<span>
<StatusBadge status={selected.status} label={selected.status} />
{["running", "waiting"].includes(selected.status) ? (
<IconButton
label="Cancel workflow run"
icon={<XCircle size={16} />}
variant="danger"
onClick={() => setCancelOpen(true)}
disabled={!canTransition || working}
/>
) : null}
</span>
</header>
{selected.error ? (
<DismissibleAlert tone="danger" resetKey={selected.error}>
{selected.error}
</DismissibleAlert>
) : null}
{currentStep ? (
<section className="workflow-run-handoff">
<div>
<span>
<strong>
{String(
currentStep.handoff.title
?? currentStep.handoff.kind
?? currentStep.node_type
)}
</strong>
<small>
Step {currentStep.sequence} · attempt {currentStep.attempt}
</small>
</span>
<StatusBadge
status={String(currentStep.handoff.state ?? currentStep.status)}
label={String(currentStep.handoff.state ?? currentStep.status)}
/>
</div>
{typeof currentStep.handoff.message === "string" ? (
<p>{currentStep.handoff.message}</p>
) : null}
{typeof currentStep.handoff.instructions === "string"
&& currentStep.handoff.instructions ? (
<p>{currentStep.handoff.instructions}</p>
) : null}
{actionUrl ? (
<a href={actionUrl}>
Open linked Dataflow result <ExternalLink size={14} />
</a>
) : null}
{allowedActions.some((action) => action !== "cancel") ? (
<div className="workflow-run-action-form">
<FormField label="Comment">
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<FormField
label="Evidence references"
help="Enter one durable evidence reference per line."
>
<textarea
value={evidence}
onChange={(event) => setEvidence(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<div className="workflow-run-actions">
{allowedActions
.filter((action) => action !== "cancel")
.map((action) => (
<Button
key={action}
variant={
action === "reject"
? "danger"
: action === "approve"
|| action === "complete"
|| action === "resume"
|| action === "confirm_effect"
? "primary"
: undefined
}
onClick={() => void performAction(action)}
disabled={!canTransition || working}
>
{action === "retry" ? <RotateCcw size={15} /> : null}
{actionLabel(action)}
</Button>
))}
</div>
</div>
) : null}
</section>
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<StageRail
ariaLabel="Workflow instance progress"
items={selected.steps.map((step) => ({
id: step.id,
label: step.node_id,
detail: `${step.node_type} · attempt ${step.attempt}`,
statusLabel: step.status,
current: step.id === selected.current_step_id,
tone: stepTone(step.status),
icon: step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)
}))}
/>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
<div>
{[...selected.events].reverse().map((event) => (
<span key={event.id}>
<strong>{event.kind}</strong>
<small>
{formatDateTime(event.created_at)}
{event.actor_id ? ` · ${event.actor_id}` : ""}
</small>
</span>
))}
</div>
</section>
</>
) : (
<div className="workflow-run-empty">
Start a run to track its progress here.
</div>
)}
</div>
</div>
</LoadingFrame>
</Dialog>
<ConfirmDialog
open={cancelOpen}
title="Cancel workflow run"
message="Cancel this workflow instance and its active Dataflow run?"
confirmLabel="Cancel run"
tone="danger"
busy={working}
onCancel={() => setCancelOpen(false)}
onConfirm={() => void cancel()}
/>
</>
);
}
function currentInstanceStep(
instance: WorkflowInstance | null
): WorkflowInstanceStep | null {
if (!instance?.current_step_id) return null;
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffActions(step: WorkflowInstanceStep | null): WorkflowAction[] {
const actions = step?.handoff.allowed_actions;
if (!Array.isArray(actions)) return [];
return actions.filter((action): action is WorkflowAction => (
typeof action === "string"
&& [
"complete",
"approve",
"changes",
"reject",
"resume",
"retry",
"confirm_effect",
"confirm_absent",
"cancel"
].includes(action)
));
}
function actionLabel(action: WorkflowAction): string {
return {
complete: "Complete",
approve: "Approve",
changes: "Request changes",
reject: "Reject",
resume: "Resume",
retry: "Retry",
confirm_effect: "Effect confirmed",
confirm_absent: "Effect absent",
cancel: "Cancel"
}[action];
}
function stepTone(
status: WorkflowInstanceStep["status"]
): StageRailTone {
if (status === "completed") return "success";
if (status === "running" || status === "waiting") return "active";
if (status === "failed" || status === "cancelled") return "danger";
return "neutral";
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "The Workflow request failed.";
}
+327
View File
@@ -0,0 +1,327 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
DefinitionKind,
DefinitionScopeType,
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowExecutionMode,
WorkflowNodeType,
WorkflowStandardProvenance,
WorkflowStatus
} from "../../api/workflow";
export type WorkflowDraft = {
id?: string;
name: string;
description: string;
status: WorkflowStatus;
currentRevision?: number;
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
scopeType: DefinitionScopeType;
scopeId: string;
definitionKind: DefinitionKind;
inheritToLowerScopes: boolean;
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
executionMode: WorkflowExecutionMode;
viewId: string;
viewRevisionId: string;
governance: WorkflowGovernance | null;
standard: WorkflowStandardProvenance | null;
};
const inputPort = [{
id: "incoming",
label: "Incoming",
required: true,
multiple: true,
minimum_connections: 1
}];
const outputPort = [{
id: "outgoing",
label: "Outgoing",
required: false,
multiple: true,
minimum_connections: 0
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "bpmn.startEvent",
category: "bpmn_event",
category_label: "Events",
label: "Start event",
description: "Start a BPMN process.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-start" }
},
{
type: "bpmn.userTask",
category: "bpmn_activity",
category_label: "Activities",
label: "User task",
description: "A governed user task.",
icon: "user-round-check",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
{
id: "title",
label: "Title",
kind: "text",
required: true,
options: []
}
],
default_config: {
title: "",
instructions: "",
assignee: "",
due_after: "",
task_mode: "activity",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "activity" }
},
{
type: "bpmn.endEvent",
category: "bpmn_event",
category_label: "Events",
label: "End event",
description: "End the BPMN process.",
icon: "circle-stop",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-end" }
}
];
export function sampleWorkflowDraft(): WorkflowDraft {
return {
name: "New workflow",
description: "",
status: "draft",
metadata: {},
scopeType: "tenant",
scopeId: "",
definitionKind: "flow",
inheritToLowerScopes: false,
allowStart: true,
allowReuse: false,
allowAutomation: false,
executionMode: "hybrid",
viewId: "",
viewRevisionId: "",
governance: null,
standard: null,
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "bpmn.startEvent",
label: "Start",
position: { x: 60, y: 150 },
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
}
},
{
id: "activity",
type: "bpmn.userTask",
label: "Activity",
position: { x: 320, y: 150 },
size: { width: 120, height: 80 },
process_id: "Process_1",
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: "",
task_mode: "activity",
documentation: ""
}
},
{
id: "complete",
type: "bpmn.endEvent",
label: "Completed",
position: { x: 580, y: 150 },
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
}
}
],
edges: [
{
id: "start-activity",
type: "bpmn.sequenceFlow",
label: "",
source: "start",
target: "activity",
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
},
{
id: "activity-complete",
type: "bpmn.sequenceFlow",
label: "",
source: "activity",
target: "complete",
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
}
],
metadata: {
notation: "bpmn-2.0",
bpmn: {
definitions_id: "Definitions_1",
target_namespace: "urn:govoplan:workflow",
processes: [{
id: "Process_1",
name: "",
is_executable: true,
attributes: {}
}],
collaborations: [],
choreographies: [],
root_elements_xml: []
}
}
}
};
}
export function draftFromDefinition(
definition: WorkflowDefinition
): WorkflowDraft {
return {
id: definition.id,
name: definition.name,
description: definition.description ?? "",
status: definition.status,
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph),
scopeType: definition.governance.scope_type,
scopeId: definition.governance.scope_id ?? "",
definitionKind: definition.governance.definition_kind,
inheritToLowerScopes: definition.governance.inherit_to_lower_scopes,
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
executionMode: definition.revision.execution_mode,
viewId: definition.revision.view_id ?? "",
viewRevisionId: definition.revision.view_revision_id ?? "",
governance: definition.governance,
standard: definition.standard ?? null
};
}
export function workflowPayload(
draft: WorkflowDraft
): WorkflowDefinitionPayload {
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata,
scope_type: draft.scopeType,
scope_id: draft.scopeId.trim() || null,
definition_kind: draft.definitionKind,
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation,
execution_mode: draft.executionMode,
view_id: draft.viewId || null,
view_revision_id: draft.viewRevisionId || null
};
}
export function workflowFingerprint(
draft: WorkflowDraft | null
): string {
if (!draft) return "";
return JSON.stringify({
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata,
scopeType: draft.scopeType,
scopeId: draft.scopeId,
definitionKind: draft.definitionKind,
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation,
executionMode: draft.executionMode,
viewId: draft.viewId,
viewRevisionId: draft.viewRevisionId
});
}
export function newWorkflowNode(
type: string,
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
const node = createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
const shape = library.find((item) => item.type === type)?.metadata?.shape;
return {
...node,
process_id: "Process_1",
size: defaultNodeSize(String(shape ?? "activity"))
};
}
function defaultNodeSize(shape: string): { width: number; height: number } {
if (shape.startsWith("event")) return { width: 36, height: 36 };
if (shape === "gateway") return { width: 50, height: 50 };
if (shape === "participant") return { width: 600, height: 180 };
if (shape === "lane") return { width: 560, height: 140 };
if (shape === "data-object") return { width: 36, height: 50 };
if (shape === "data-store") return { width: 50, height: 50 };
return { width: 120, height: 80 };
}
+2
View File
@@ -0,0 +1,2 @@
export { workflowModule as default, workflowModule } from "./module";
export * from "./api/workflow";
+111
View File
@@ -0,0 +1,111 @@
import { createElement, lazy } from "react";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "@xyflow/react/dist/style.css";
import "./styles/workflow.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const WorkflowOpenWorkWidget = lazy(
() => import("./features/workflow/WorkflowOpenWorkWidget")
);
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
const instanceReadScopes = [
"workflow:instance:read",
"workflow:instance:admin"
];
const workflowDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "workflow.open-work",
surfaceId: "workflow.widget.open-work",
title: "Open workflow work",
description: "Running workflows and steps that require attention.",
moduleId: "workflow",
category: "Work",
order: 42,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: instanceReadScopes,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 6,
includeRunning: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum items",
kind: "number",
min: 1,
max: 20,
step: 1,
required: true
},
{
id: "includeRunning",
label: "Include running steps",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(WorkflowOpenWorkWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const workflowModule: PlatformWebModule = {
id: "workflow",
label: "Workflow",
version: "0.1.14",
optionalDependencies: [
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks"
],
viewSurfaces: [
{
id: "workflow.widget.open-work",
moduleId: "workflow",
kind: "section",
label: "Open workflow work widget",
order: 76
}
],
navItems: [
{
to: "/workflow",
label: "Workflow",
iconName: "workflow",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/workflow",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
],
uiCapabilities: {
"dashboard.widgets": workflowDashboardWidgets
}
};
export default workflowModule;
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModules: PlatformWebModule[];
export { installedWebModules };
export default installedWebModules;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/production/index.d.ts"]
}
},
"include": ["src"]
}