Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2582a278e | ||
|
|
6e03338b13 | ||
|
|
bf77ac2a8d | ||
|
|
4aa19840bc | ||
|
|
fa0bdde839 | ||
|
|
8e5415dfe7 | ||
|
|
d5214c641c | ||
|
|
0fd744a2db | ||
|
|
f49ad8eff4 | ||
|
|
244c0ee4c1 | ||
|
|
a071692e75 | ||
|
|
55bbd15ecc | ||
|
|
8305bd5c5b | ||
|
|
15956173f6 | ||
|
|
46c849e2fa | ||
|
|
e344af0b0a | ||
|
|
5f00898bed | ||
|
|
db8970adea | ||
|
|
629f76440b | ||
|
|
06f93f4f7a | ||
|
|
00fb6b56a9 | ||
|
|
7936aadd93 | ||
|
|
6e27e72a54 | ||
|
|
ca32a6ee5c | ||
|
|
9d987c7339 | ||
|
|
8fd8753012 | ||
|
|
484ac43352 | ||
|
|
f4974b4949 | ||
|
|
c505e81006 | ||
|
|
a6e0e89829 | ||
|
|
a1654e70cf | ||
|
|
c45e2b808c | ||
|
|
b015569b5e | ||
|
|
63e5ce949d | ||
|
|
1ec8336c02 | ||
|
|
6737b60c11 | ||
|
|
85eef00913 | ||
|
|
0d099b05b7 |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -1,15 +1,53 @@
|
|||||||
# govoplan-workflow
|
# GovOPlaN Workflow
|
||||||
|
|
||||||
`govoplan-workflow` will own process orchestration for GovOPlaN.
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
The module should execute configurable state machines and command handoffs
|
Optional visual authoring and inspection workspace for GovOPlaN Workflow
|
||||||
between modules without importing their implementations. It coordinates cases,
|
Engine.
|
||||||
tasks, forms, files, templates, mail, appointments, payments, and records
|
|
||||||
through capabilities, events, commands, and DTOs.
|
|
||||||
|
|
||||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
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.
|
||||||
|
|
||||||
There is a BPMN component playing a major role here. Maybe this needs to
|
For one compatibility release, Python imports below
|
||||||
become a separate module. It is quite viable to think about workflow
|
`govoplan_workflow.backend` re-export their corresponding Workflow Engine
|
||||||
modelling (and consequently import and export) in terms of BPMN, permitting
|
implementations. New module code must use Core's `workflow.*` capabilities or,
|
||||||
a standardized configuration of the system.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/workflow-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/workflow-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -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.
|
||||||
+40
-23
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`govoplan-workflow` is the process orchestration module. It turns a configured
|
`govoplan-workflow-engine` is the headless process orchestration module.
|
||||||
administrative procedure into state transitions, guards, commands, timers, and
|
`govoplan-workflow` is its optional authoring and inspection surface. See
|
||||||
operator-visible progress.
|
`ENGINE_EDITOR_SPLIT.md`.
|
||||||
|
|
||||||
Workflow does not own business records. A case, task, file, appointment,
|
Workflow does not own business records. A case, task, file, appointment,
|
||||||
template, payment, or postbox message remains owned by its domain module.
|
template, payment, or postbox message remains owned by its domain module.
|
||||||
@@ -19,7 +19,7 @@ action/effect contracts.
|
|||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
The module owns:
|
Workflow Engine owns:
|
||||||
|
|
||||||
- workflow definitions and versions
|
- workflow definitions and versions
|
||||||
- workflow instances and current state
|
- workflow instances and current state
|
||||||
@@ -29,7 +29,10 @@ The module owns:
|
|||||||
- retry/manual-intervention state for failed command handoffs
|
- retry/manual-intervention state for failed command handoffs
|
||||||
- action/effect execution records for workflow-triggered automation
|
- action/effect execution records for workflow-triggered automation
|
||||||
- workflow audit/event emission
|
- workflow audit/event emission
|
||||||
- workflow diagram metadata and WebUI route contributions
|
- 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:
|
The module does not own:
|
||||||
|
|
||||||
@@ -95,18 +98,32 @@ Permit-to-payment MVP:
|
|||||||
|
|
||||||
## MVP Slice
|
## MVP Slice
|
||||||
|
|
||||||
The first implementation should provide:
|
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
|
- static workflow definition registration from configuration packages
|
||||||
- create/read/list workflow instances
|
- event, API, schedule, and parent-workflow start dispatchers
|
||||||
- transition execution with permission checks
|
|
||||||
- guard hooks implemented through capability calls
|
- guard hooks implemented through capability calls
|
||||||
- command execution records with retry/manual-resolution state
|
- registry-driven generic module-action execution records
|
||||||
- action/effect previews for transitions that call other modules
|
- action/effect previews for transitions that call other modules
|
||||||
- idempotency keys for command execution
|
|
||||||
- explicit blocked, retryable, quarantined, manual-required, and
|
- explicit blocked, retryable, quarantined, manual-required, and
|
||||||
compensation-required states
|
compensation-required states
|
||||||
- basic WebUI instance detail and definition viewer
|
|
||||||
- dashboard summary provider
|
- dashboard summary provider
|
||||||
- event emission and audit integration
|
- event emission and audit integration
|
||||||
|
|
||||||
@@ -139,12 +156,16 @@ details.
|
|||||||
|
|
||||||
## Data Model Sketch
|
## Data Model Sketch
|
||||||
|
|
||||||
Candidate tables:
|
Current tables:
|
||||||
|
|
||||||
- `workflow_definitions`
|
- `workflow_definitions`
|
||||||
- `workflow_definition_versions`
|
- `workflow_definition_revisions`
|
||||||
- `workflow_instances`
|
- `workflow_instances`
|
||||||
- `workflow_transition_history`
|
- `workflow_instance_steps`
|
||||||
|
- `workflow_instance_events`
|
||||||
|
|
||||||
|
Future generic action execution and timers may add:
|
||||||
|
|
||||||
- `workflow_command_records`
|
- `workflow_command_records`
|
||||||
- `workflow_timers`
|
- `workflow_timers`
|
||||||
|
|
||||||
@@ -153,16 +174,14 @@ reference the exact version used at start.
|
|||||||
|
|
||||||
## WebUI
|
## WebUI
|
||||||
|
|
||||||
Initial route contributions:
|
Current route contribution:
|
||||||
|
|
||||||
- `/workflow`
|
- `/workflow`
|
||||||
- `/workflow/instances/:instanceId`
|
|
||||||
- `/workflow/definitions/:definitionId`
|
|
||||||
|
|
||||||
The UI should show current state, available transitions, pending commands,
|
The route combines the definition editor and a fixed run dialog showing current
|
||||||
failed handoffs, audit trace, and linked subject records. It should not import
|
state, available transitions, failed handoffs, comments/evidence, immutable
|
||||||
case/task/template components directly; panels are contributed through core UI
|
event history, and linked Dataflow results. It does not import Dataflow or other
|
||||||
extension points.
|
domain UI components.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -177,8 +196,6 @@ Minimum tests:
|
|||||||
|
|
||||||
## Open Decisions
|
## Open Decisions
|
||||||
|
|
||||||
- Whether to implement BPMN import later or keep a GovOPlaN-native JSON model.
|
|
||||||
- How much visual workflow editing belongs in the first WebUI.
|
|
||||||
- Whether long-running timers use Celery beat, a module scheduler, or an ops
|
- Whether long-running timers use Celery beat, a module scheduler, or an ops
|
||||||
scheduler abstraction.
|
scheduler abstraction.
|
||||||
- How workflow variables are redacted and retained.
|
- How workflow variables are redacted and retained.
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Workflow Engine And Workflow Editor Split
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Split the current module into two installable modules:
|
||||||
|
|
||||||
|
- `govoplan-workflow-engine` (runtime module ID `workflow_engine`) is the
|
||||||
|
headless definition and execution platform available to all modules.
|
||||||
|
- `govoplan-workflow` remains the optional authoring, inspection, catalogue,
|
||||||
|
diff, override, and reset WebUI.
|
||||||
|
|
||||||
|
Other modules depend on Workflow Engine capabilities, never on the Workflow
|
||||||
|
editor package. Workflow depends on Workflow Engine.
|
||||||
|
|
||||||
|
This is a packaging and ownership split, not a second workflow model. BPMN 2.0
|
||||||
|
and the existing native graph remain the canonical language and use the same
|
||||||
|
versioned contracts.
|
||||||
|
|
||||||
|
## Existing Versioning
|
||||||
|
|
||||||
|
Workflow definitions are already versioned:
|
||||||
|
|
||||||
|
- `workflow_definitions.current_revision` identifies the latest graph revision.
|
||||||
|
- `workflow_definitions.active_revision` selects the revision used for new
|
||||||
|
instances.
|
||||||
|
- `workflow_definition_revisions` stores immutable graph content, hashes, node
|
||||||
|
library versions, execution mode, pinned View revision, BPMN XML/hash, and
|
||||||
|
execution-adapter profile/version.
|
||||||
|
- Graph changes create a new revision and return an active definition to draft;
|
||||||
|
metadata-only changes do not create graph revisions.
|
||||||
|
- Updates use `expected_revision` optimistic concurrency.
|
||||||
|
- Instances pin `definition_revision_id`; later edits or module upgrades cannot
|
||||||
|
mutate running or historical instances.
|
||||||
|
- Derived definitions record source definition, source revision/hash, actor,
|
||||||
|
Policy decision, scope, and effective ancestor limits.
|
||||||
|
|
||||||
|
The missing model is a durable module-owned baseline and local override/reset
|
||||||
|
relationship.
|
||||||
|
|
||||||
|
## Workflow Engine Ownership
|
||||||
|
|
||||||
|
Workflow Engine owns:
|
||||||
|
|
||||||
|
- definition, immutable revision, instance, step, event, command, timer, and
|
||||||
|
execution-record persistence and migrations
|
||||||
|
- definition CRUD/activation/derivation APIs and headless read APIs
|
||||||
|
- Workflow graph/BPMN schemas, validation, import/export, and conformance
|
||||||
|
- node-library and execution-adapter registries
|
||||||
|
- instance start/transition/cancel/retry/reconciliation services
|
||||||
|
- runtime worker, event/API/schedule/parent dispatch, idempotency, and recovery
|
||||||
|
- definition governance capability integration and audit/event emission
|
||||||
|
- configuration-package workflow fragments
|
||||||
|
- module-contributed standard definition discovery and reconciliation
|
||||||
|
|
||||||
|
It contributes no primary navigation or full editor. A module can install and
|
||||||
|
run its workflows when the editor is absent.
|
||||||
|
|
||||||
|
## Workflow Editor Ownership
|
||||||
|
|
||||||
|
Workflow owns:
|
||||||
|
|
||||||
|
- the Workflow workspace and visual BPMN/graph editor
|
||||||
|
- definition/revision catalogue, preview, diff, validation, and activation UI
|
||||||
|
- instance inspection and operator controls built on Engine APIs
|
||||||
|
- derivation and governed override UX
|
||||||
|
- module-standard update comparison and reset-to-standard UX
|
||||||
|
- reusable embedded editor/inspector components for other module surfaces
|
||||||
|
|
||||||
|
The editor never owns workflow tables or executes transitions directly.
|
||||||
|
|
||||||
|
## Module-Contributed Definitions
|
||||||
|
|
||||||
|
Modules announce standard workflows through a versioned Engine contribution
|
||||||
|
contract or a module-owned configuration-package fragment. A contribution has:
|
||||||
|
|
||||||
|
- origin module ID/version, stable definition key, contribution schema version,
|
||||||
|
and content hash
|
||||||
|
- graph plus BPMN representation, node-library/profile requirements, and
|
||||||
|
execution mode
|
||||||
|
- default scope, start/reuse/automation ceilings, required capabilities, and
|
||||||
|
Policy metadata
|
||||||
|
- upgrade compatibility and optional migration diagnostics
|
||||||
|
|
||||||
|
Engine reconciles contributions idempotently after module discovery. The
|
||||||
|
module-provided baseline is immutable. A module update may add a new baseline
|
||||||
|
revision, but it never rewrites a running instance or silently replaces a local
|
||||||
|
override.
|
||||||
|
|
||||||
|
## Override And Reset
|
||||||
|
|
||||||
|
Editing a system/module standard creates a local override derived from a pinned
|
||||||
|
baseline revision. The UI may present this as editing the effective definition,
|
||||||
|
but the canonical baseline remains available.
|
||||||
|
|
||||||
|
- View is always possible when the caller can read the definition.
|
||||||
|
- Edit/derive is controlled by Policy and scope ceilings.
|
||||||
|
- An upstream baseline update is shown as an available update with a three-way
|
||||||
|
diff; it is not merged silently.
|
||||||
|
- Reset archives the local override and selects the latest permitted baseline.
|
||||||
|
- Historical overrides, baselines, and instances remain addressable for audit.
|
||||||
|
- A tenant/group/user override cannot loosen inherited restrictions.
|
||||||
|
|
||||||
|
## Compatibility And Extraction Order
|
||||||
|
|
||||||
|
1. Define Engine-owned capability and contribution DTOs in Core-neutral
|
||||||
|
contracts while preserving existing `workflow.*` interface names.
|
||||||
|
2. Create `govoplan-workflow-engine` and move backend code, tests, migrations,
|
||||||
|
and runtime workers without changing table names or API paths.
|
||||||
|
3. Transfer migration ownership without replaying the existing chain. Test both
|
||||||
|
upgrades and fresh installs with Engine alone.
|
||||||
|
4. Keep a compatibility facade in `govoplan-workflow` for one release line;
|
||||||
|
make it depend on `workflow_engine` and retain only WebUI/editor code.
|
||||||
|
5. Update Core workers and consuming modules to resolve Engine capabilities.
|
||||||
|
6. Add module contributions, immutable baselines, override/update/reset, and
|
||||||
|
configuration-package support.
|
||||||
|
7. Remove compatibility imports only under the platform compatibility policy.
|
||||||
|
|
||||||
|
The extraction must preserve existing definition IDs, revision IDs, active
|
||||||
|
revision selection, instance foreign keys, idempotency keys, API routes, and
|
||||||
|
audit references.
|
||||||
|
|
||||||
|
## Implemented Boundary
|
||||||
|
|
||||||
|
The split is implemented in the `govoplan-workflow-engine` repository. Engine
|
||||||
|
owns the unchanged migration chain and `/api/v1/workflow` API, retains the
|
||||||
|
existing `workflow:*` permission namespace through an explicit manifest
|
||||||
|
compatibility field, and exposes headless runtime and contribution
|
||||||
|
capabilities. `govoplan-workflow` now has a hard dependency on runtime module
|
||||||
|
ID `workflow_engine`, contributes only its WebUI/navigation/editor contract,
|
||||||
|
and keeps one release line of `govoplan_workflow.backend` import facades.
|
||||||
|
|
||||||
|
Module manifests can announce versioned workflow baselines. Reconciliation is
|
||||||
|
idempotent, records module/schema/hash provenance, keeps a newly supplied
|
||||||
|
baseline revision inactive when an older revision is active, and fails closed
|
||||||
|
when required capabilities or interfaces are absent. Baselines are immutable;
|
||||||
|
editing derives a pinned local override, and reset archives that override
|
||||||
|
without removing revision or instance history.
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/workflow-webui",
|
||||||
|
"version": "0.1.23",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"@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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-workflow"
|
||||||
|
version = "0.1.23"
|
||||||
|
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.45",
|
||||||
|
"govoplan-workflow-engine>=0.1.19",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.23"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Compatibility facades for the extracted Workflow Engine backend."""
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
|
ViewSurface,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
semantic_documentation_subject_capability,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
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.23"
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_subjects(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
return WorkflowSemanticDocumentationSubjectProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name=MODULE_NAME,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("workflow_engine",),
|
||||||
|
optional_dependencies=("docs",),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=SEMANTIC_SUBJECT_CAPABILITY,
|
||||||
|
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Workflow semantic-documentation subjects",
|
||||||
|
summary=(
|
||||||
|
"Lists currently authorized workflow definitions and stable "
|
||||||
|
"step lineages with documentation-safe review fingerprints."
|
||||||
|
),
|
||||||
|
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="work",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.work",
|
||||||
|
icon="list-checks",
|
||||||
|
description="i18n:govoplan-core.product_area.work_description",
|
||||||
|
surface_ids=("workflow.nav.workflow", "workflow.route.workflow"),
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.workspace-layout",
|
||||||
|
title="Workflow workspace actions",
|
||||||
|
summary="Find collection-wide commands in their consistent workspace position.",
|
||||||
|
body="Reload and New workflow use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Graph editing, validation, saving, publication, and execution remain separate pane-local actions. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Workflows: Aktionen im Arbeitsbereich",
|
||||||
|
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
|
||||||
|
"body": "Neu laden und Neuer Workflow stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Graphbearbeitung, Validierung, Speichern, Veröffentlichung und Ausführung bleiben getrennte Aktionen im jeweiligen Bereich. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.external-handoff-inspection",
|
||||||
|
title="Inspect and return to external work hand-offs",
|
||||||
|
summary=(
|
||||||
|
"Follow the exact linked module work while Workflow retains "
|
||||||
|
"its durable position and immutable reference."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The runs dialog shows the current external hand-off state, exact "
|
||||||
|
"revision reference, safe linked-work action, timeout, and the "
|
||||||
|
"immutable Workflow evidence trail. Leaving the dialog or linked "
|
||||||
|
"Campaign page does not complete the step. Declared Campaign events "
|
||||||
|
"drive acceptance, reassignment, completion, rejection, and "
|
||||||
|
"cancellation without browser polling. A configured focused View "
|
||||||
|
"narrows the available surfaces while the step is active. If Views "
|
||||||
|
"is absent, the linked Campaign action remains available and the "
|
||||||
|
"dialog explains the reduced presentation. Missing optional Tasks or "
|
||||||
|
"Notifications is also shown without changing the hand-off outcome."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "workflow_designer", "campaign_manager"),
|
||||||
|
related_modules=(
|
||||||
|
"workflow_engine",
|
||||||
|
"campaigns",
|
||||||
|
"views",
|
||||||
|
"tasks",
|
||||||
|
"notifications",
|
||||||
|
),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Externe Arbeitsübergaben prüfen und zu ihnen zurückkehren",
|
||||||
|
"summary": "Der genau verknüpften Arbeit im Zielmodul folgen, während Workflow seine dauerhafte Position und unveränderliche Referenz beibehält.",
|
||||||
|
"body": (
|
||||||
|
"Der Läufe-Dialog zeigt den aktuellen Zustand der externen Übergabe, die genaue Revisionsreferenz, die sichere Aktion zur verknüpften Arbeit, den Zeitablauf und die unveränderliche Workflow-Nachweisspur. "
|
||||||
|
"Das Verlassen des Dialogs oder der verknüpften Campaign-Seite schließt den Schritt nicht ab. Deklarierte Campaign-Ereignisse steuern Annahme, Neuzuweisung, Abschluss, Ablehnung und Abbruch ohne Browser-Abfragen. "
|
||||||
|
"Eine konfigurierte fokussierte View grenzt die verfügbaren Oberflächen ein, solange der Schritt aktiv ist. Fehlt Views, bleibt die verknüpfte Campaign-Aktion verfügbar und der Dialog erläutert die reduzierte Darstellung. "
|
||||||
|
"Auch fehlende optionale Module Tasks oder Notifications werden angezeigt, ohne das Ergebnis der Übergabe zu verändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"workflow.external-handoff",
|
||||||
|
"workflow.instances",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=74,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.semantic-documentation",
|
||||||
|
title="Document configured workflow and step meaning",
|
||||||
|
summary=(
|
||||||
|
"Attach tenant-owned semantic guidance to an authorized workflow "
|
||||||
|
"definition or stable step without changing execution."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"When Docs is installed, Workflow supplies documentation-safe "
|
||||||
|
"subjects for each definition the current actor may view and for "
|
||||||
|
"each step in its current immutable revision. Step identity survives "
|
||||||
|
"label and canvas-position changes. Removing and later recreating a "
|
||||||
|
"step with the same graph ID creates a new lineage, leaving prior "
|
||||||
|
"documentation explicitly orphaned. Relevant changes to step type, "
|
||||||
|
"label, hierarchy, safe configuration, or connected flow semantics "
|
||||||
|
"change the review fingerprint; layout-only movement does not. "
|
||||||
|
"Semantic prose can explain purpose, non-purpose, entry and exit "
|
||||||
|
"conditions, handoffs, exceptions, ownership, stewardship, and "
|
||||||
|
"examples, but it cannot alter validation, activation, policy, or "
|
||||||
|
"Workflow Engine execution. Workflow rechecks tenant scope and the "
|
||||||
|
"current per-definition governance decision for discovery and direct "
|
||||||
|
"resolution. If Docs is absent, editing and static help continue."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=(
|
||||||
|
"user",
|
||||||
|
"workflow_designer",
|
||||||
|
"process_owner",
|
||||||
|
"module_admin",
|
||||||
|
),
|
||||||
|
related_modules=("docs", "workflow_engine", "policy"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Semantic documentation authoring",
|
||||||
|
href="/docs/semantic",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Workflow editor and engine boundary",
|
||||||
|
href="govoplan-workflow/docs/ENGINE_EDITOR_SPLIT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Bedeutung konfigurierter Workflows und Schritte dokumentieren",
|
||||||
|
"summary": "Mandanteneigene semantische Hinweise einer berechtigten Workflow-Definition oder einem stabilen Schritt zuordnen, ohne die Ausführung zu verändern.",
|
||||||
|
"body": (
|
||||||
|
"Wenn Docs installiert ist, stellt Workflow dokumentationssichere Subjekte für jede Definition bereit, die der aktuelle Akteur sehen darf, sowie für jeden Schritt ihrer aktuellen unveränderlichen Revision. "
|
||||||
|
"Die Schrittidentität bleibt bei Änderungen von Bezeichnung und Position auf der Zeichenfläche erhalten. Wird ein Schritt entfernt und später mit derselben Graph-ID neu angelegt, entsteht eine neue Abstammung; frühere Dokumentation bleibt ausdrücklich verwaist. "
|
||||||
|
"Relevante Änderungen an Schritttyp, Bezeichnung, Hierarchie, sicherer Konfiguration oder der Semantik verbundener Flüsse ändern den Prüffingerabdruck; reine Layoutverschiebungen tun dies nicht. "
|
||||||
|
"Semantische Texte können Zweck, Nicht-Zweck, Ein- und Austrittsbedingungen, Übergaben, Ausnahmen, Verantwortung, Betreuung und Beispiele erläutern, aber weder Validierung, Aktivierung, Richtlinien noch die Ausführung durch Workflow Engine verändern. "
|
||||||
|
"Workflow prüft bei Ermittlung und direkter Auflösung erneut Mandantengrenze und aktuelle Governance-Entscheidung je Definition. Fehlt Docs, bleiben Bearbeitung und statische Hilfe verfügbar."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.semantic-documentation"],
|
||||||
|
},
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
|
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. Deleting a flow or node changes only "
|
||||||
|
"the editable draft until that definition is saved; deleting a workflow "
|
||||||
|
"removes its definition revisions and requires explicit confirmation. "
|
||||||
|
"Retained execution and audit evidence remains governed by Workflow Engine. "
|
||||||
|
"Opening or reloading the editor does not execute a workflow. If the editor "
|
||||||
|
"cannot be loaded after a development update, preserve any unsaved work "
|
||||||
|
"before reloading the browser. Administrators should distinguish failed "
|
||||||
|
"frontend assets from definition API or access errors. Development and "
|
||||||
|
"browser-conformance servers use separate dependency caches; an older "
|
||||||
|
"server may need restarting after upgrading this configuration."
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Arbeitsbereich für Workflow-Bearbeitung und -Prüfung",
|
||||||
|
"summary": "Optionale Erstellung, Validierung, Revisionsprüfung und Betriebssteuerung für Workflow Engine.",
|
||||||
|
"body": (
|
||||||
|
"Workflow ergänzt den visuellen BPMN- und nativen Graph-Editor, den Definitionskatalog, den Vergleich unveränderlicher Revisionen, Aktivierungssteuerung, Instanzprüfung und gesteuerte Eingriffe zum Überschreiben oder Zurücksetzen. "
|
||||||
|
"Definitionen und Instanzen bleiben Eigentum des ohne eigene Bedienoberfläche betriebenen Moduls Workflow Engine und werden dort ausgeführt. Das Löschen eines Flusses oder Knotens verändert nur den bearbeitbaren Entwurf, bis die Definition gespeichert wird. Das Löschen eines Workflows entfernt dessen Definitionsrevisionen und verlangt eine ausdrückliche Bestätigung. Aufbewahrte Ausführungs- und Auditnachweise bleiben durch Workflow Engine geregelt. "
|
||||||
|
"Das Öffnen oder Neuladen des Editors führt keinen Workflow aus. Kann der Editor nach einer Entwicklungsaktualisierung nicht geladen werden, sichern Sie ungespeicherte Arbeit vor dem Neuladen des Browsers. Administratoren sollten fehlgeschlagene Oberflächendateien von Fehlern der Definitions-API oder Zugriffsfehlern unterscheiden. Entwicklungs- und Browser-Konformitätsserver verwenden getrennte Abhängigkeitscaches; ein älterer Server muss nach dieser Konfigurationsaktualisierung gegebenenfalls neu gestartet werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.action.delete-definition-parts"],
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
"Semantic documentation requires Docs for tenant-authored content; static Workflow help remains available without it.",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"workflow editing surface",
|
||||||
|
"workflow revision inspection",
|
||||||
|
"workflow activation controls",
|
||||||
|
"workflow semantic documentation subjects",
|
||||||
|
),
|
||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Compatibility facade for the extracted Workflow Engine backend."""
|
||||||
|
|
||||||
|
from govoplan_workflow_engine.backend.router import * # noqa: F401,F403
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Compatibility facade for the extracted Workflow Engine backend."""
|
||||||
|
|
||||||
|
from govoplan_workflow_engine.backend.runtime import * # noqa: F401,F403
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Compatibility facade for the extracted Workflow Engine backend."""
|
||||||
|
|
||||||
|
from govoplan_workflow_engine.backend.schemas import * # noqa: F401,F403
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
SemanticDocumentationBreadcrumb,
|
||||||
|
SemanticDocumentationSubjectAnchor,
|
||||||
|
SemanticDocumentationSubjectDescriptor,
|
||||||
|
SemanticDocumentationSubjectPage,
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
SemanticDocumentationSubjectReference,
|
||||||
|
SemanticDocumentationSubjectResolution,
|
||||||
|
semantic_documentation_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.redaction import redact_secret_values
|
||||||
|
from govoplan_workflow.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.governance import definition_decision
|
||||||
|
from govoplan_workflow.backend.service import (
|
||||||
|
get_definition_revision,
|
||||||
|
list_definition_revisions,
|
||||||
|
list_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECT_KIND = "workflow_definition"
|
||||||
|
ADMIN_SCOPE = "workflow:instance:admin"
|
||||||
|
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||||
|
_MAX_SUBJECTS = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LineageState:
|
||||||
|
node_ids: Mapping[str, str]
|
||||||
|
historical_node_ids: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
provider_id = "workflow.semantic_subjects"
|
||||||
|
module_id = "workflow"
|
||||||
|
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def list_subjects(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: SemanticDocumentationSubjectQuery,
|
||||||
|
) -> SemanticDocumentationSubjectPage:
|
||||||
|
actor = _principal(principal, tenant_id=request.tenant_id)
|
||||||
|
if actor is None:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
db = _session(session)
|
||||||
|
subjects: list[SemanticDocumentationSubjectDescriptor] = []
|
||||||
|
for definition in list_definitions(db, tenant_id=request.tenant_id):
|
||||||
|
if not self._can_view(definition, actor):
|
||||||
|
continue
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
subjects.extend(
|
||||||
|
_descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(subjects) > _MAX_SUBJECTS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow semantic subject limit exceeded; narrow the query."
|
||||||
|
)
|
||||||
|
query = request.query.casefold().strip()
|
||||||
|
if query:
|
||||||
|
subjects = [
|
||||||
|
item
|
||||||
|
for item in subjects
|
||||||
|
if query in _search_text(item).casefold()
|
||||||
|
]
|
||||||
|
offset = _cursor_offset(request.cursor)
|
||||||
|
selected = tuple(subjects[offset : offset + request.limit])
|
||||||
|
next_offset = offset + len(selected)
|
||||||
|
has_more = next_offset < len(subjects)
|
||||||
|
return SemanticDocumentationSubjectPage(
|
||||||
|
subjects=selected,
|
||||||
|
next_cursor=str(next_offset) if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: SemanticDocumentationSubjectReference,
|
||||||
|
) -> SemanticDocumentationSubjectResolution | None:
|
||||||
|
actor = _principal(principal, tenant_id=reference.tenant_id)
|
||||||
|
if (
|
||||||
|
reference.module_id != self.module_id
|
||||||
|
or reference.subject_kind != SUBJECT_KIND
|
||||||
|
or actor is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
db = _session(session)
|
||||||
|
definition = db.scalar(
|
||||||
|
select(WorkflowDefinition).where(
|
||||||
|
WorkflowDefinition.id == reference.subject_id,
|
||||||
|
or_(
|
||||||
|
WorkflowDefinition.tenant_id == reference.tenant_id,
|
||||||
|
WorkflowDefinition.tenant_id.is_(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if definition is None or not self._can_view(definition, actor):
|
||||||
|
return None
|
||||||
|
if definition.deleted_at is not None:
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code="definition_deleted",
|
||||||
|
)
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
descriptor = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in _descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=reference.tenant_id,
|
||||||
|
)
|
||||||
|
if item.reference.stable_key == reference.stable_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
anchor = reference.anchor
|
||||||
|
reason = "subject_missing"
|
||||||
|
if anchor is not None and anchor.kind == "step":
|
||||||
|
reason = (
|
||||||
|
"step_deleted"
|
||||||
|
if anchor.id in lineage.historical_node_ids
|
||||||
|
else "step_missing"
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code=reason,
|
||||||
|
)
|
||||||
|
changed = any(
|
||||||
|
expected is not None and expected != actual
|
||||||
|
for expected, actual in (
|
||||||
|
(reference.observed_revision, descriptor.reference.observed_revision),
|
||||||
|
(
|
||||||
|
reference.observed_fingerprint,
|
||||||
|
descriptor.reference.observed_fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="changed" if changed else "available",
|
||||||
|
subject=descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _can_view(
|
||||||
|
self,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> bool:
|
||||||
|
return definition_decision(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=self._registry,
|
||||||
|
action="view",
|
||||||
|
).allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptors(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
revision: WorkflowDefinitionRevision,
|
||||||
|
lineage: _LineageState,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[SemanticDocumentationSubjectDescriptor, ...]:
|
||||||
|
route = f"/workflow?definition={quote(definition.id, safe='')}"
|
||||||
|
definition_fingerprint = semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"revision": definition.current_revision,
|
||||||
|
"content_hash": revision.content_hash,
|
||||||
|
"name": definition.name,
|
||||||
|
"description": definition.description,
|
||||||
|
"status": definition.status,
|
||||||
|
"active_revision": definition.active_revision,
|
||||||
|
"scope_type": definition.scope_type,
|
||||||
|
"definition_kind": definition.definition_kind,
|
||||||
|
"allow_start": definition.allow_start,
|
||||||
|
"allow_reuse": definition.allow_reuse,
|
||||||
|
"allow_automation": definition.allow_automation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = [
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
revision=str(definition.current_revision),
|
||||||
|
fingerprint=definition_fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": definition.name},
|
||||||
|
descriptions=(
|
||||||
|
{"en": definition.description} if definition.description else {}
|
||||||
|
),
|
||||||
|
route=route,
|
||||||
|
# The provider already applies the engine's READ-or-ADMIN and
|
||||||
|
# per-definition governance decision. The descriptor contract
|
||||||
|
# represents an AND-only scope list, so it cannot restate that
|
||||||
|
# disjunction without incorrectly excluding administrators.
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
nodes = tuple(
|
||||||
|
item for item in graph.get("nodes", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
edges = tuple(
|
||||||
|
item for item in graph.get("edges", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
node_by_id = {str(item.get("id")): item for item in nodes if item.get("id")}
|
||||||
|
for node_id, node in node_by_id.items():
|
||||||
|
identity = lineage.node_ids[node_id]
|
||||||
|
fingerprint = _node_fingerprint(node, edges)
|
||||||
|
label = str(node.get("label") or node.get("type") or node_id)[:300]
|
||||||
|
breadcrumbs = [
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=definition.name,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
parent_id = str(node.get("parent_id") or "")
|
||||||
|
parent = node_by_id.get(parent_id)
|
||||||
|
if parent is not None and parent_id in lineage.node_ids:
|
||||||
|
breadcrumbs.append(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=str(parent.get("label") or parent_id)[:300],
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step",
|
||||||
|
id=lineage.node_ids[parent_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step", id=identity
|
||||||
|
),
|
||||||
|
revision=fingerprint,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": label},
|
||||||
|
descriptions={
|
||||||
|
"en": f"Configured {str(node.get('type') or 'workflow step')[:200]} step."
|
||||||
|
},
|
||||||
|
breadcrumbs=tuple(breadcrumbs),
|
||||||
|
route=route,
|
||||||
|
route_anchor=_route_anchor(node_id),
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
revision: str,
|
||||||
|
fingerprint: str,
|
||||||
|
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||||
|
) -> SemanticDocumentationSubjectReference:
|
||||||
|
return SemanticDocumentationSubjectReference(
|
||||||
|
module_id="workflow",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=anchor,
|
||||||
|
observed_revision=revision,
|
||||||
|
observed_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_state(
|
||||||
|
session: Session,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
) -> _LineageState:
|
||||||
|
revisions = sorted(
|
||||||
|
list_definition_revisions(session, definition=definition),
|
||||||
|
key=lambda item: item.revision,
|
||||||
|
)
|
||||||
|
active: dict[str, str] = {}
|
||||||
|
historical: set[str] = set()
|
||||||
|
for revision in revisions:
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
node_ids = {
|
||||||
|
str(item.get("id"))
|
||||||
|
for item in graph.get("nodes", ())
|
||||||
|
if isinstance(item, Mapping) and item.get("id")
|
||||||
|
}
|
||||||
|
active = {key: value for key, value in active.items() if key in node_ids}
|
||||||
|
for node_id in sorted(node_ids):
|
||||||
|
active.setdefault(node_id, _lineage_id(revision.id, node_id))
|
||||||
|
historical.add(active[node_id])
|
||||||
|
return _LineageState(
|
||||||
|
node_ids=active,
|
||||||
|
historical_node_ids=frozenset(historical),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_fingerprint(
|
||||||
|
node: Mapping[str, object],
|
||||||
|
edges: tuple[Mapping[str, object], ...],
|
||||||
|
) -> str:
|
||||||
|
node_id = str(node.get("id") or "")
|
||||||
|
connected = [
|
||||||
|
{
|
||||||
|
"id": edge.get("id"),
|
||||||
|
"type": edge.get("type"),
|
||||||
|
"label": edge.get("label"),
|
||||||
|
"source": edge.get("source"),
|
||||||
|
"target": edge.get("target"),
|
||||||
|
"source_port": edge.get("source_port"),
|
||||||
|
"target_port": edge.get("target_port"),
|
||||||
|
"config": redact_secret_values(edge.get("config") or {}),
|
||||||
|
}
|
||||||
|
for edge in edges
|
||||||
|
if node_id in {str(edge.get("source") or ""), str(edge.get("target") or "")}
|
||||||
|
]
|
||||||
|
connected.sort(key=lambda item: str(item["id"] or ""))
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"type": node.get("type"),
|
||||||
|
"label": node.get("label"),
|
||||||
|
"parent_id": node.get("parent_id"),
|
||||||
|
"process_id": node.get("process_id"),
|
||||||
|
"config": redact_secret_values(node.get("config") or {}),
|
||||||
|
"connections": connected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_id(revision_id: str, node_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{revision_id}\x1f{node_id}".encode()).hexdigest()
|
||||||
|
return f"step-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _route_anchor(node_id: str) -> str:
|
||||||
|
route_anchor = f"workflow-node-{node_id}"
|
||||||
|
if len(route_anchor) <= 255:
|
||||||
|
return route_anchor
|
||||||
|
digest = hashlib.sha256(node_id.encode()).hexdigest()
|
||||||
|
return f"workflow-node-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
|
||||||
|
return " ".join(
|
||||||
|
(
|
||||||
|
item.reference.subject_id,
|
||||||
|
*item.labels.values(),
|
||||||
|
*item.descriptions.values(),
|
||||||
|
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> ApiPrincipal | None:
|
||||||
|
if not isinstance(principal, ApiPrincipal) or principal.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
if not (
|
||||||
|
principal.has(DEFINITION_READ_SCOPE)
|
||||||
|
or principal.has(ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return principal
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(value: str | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if not value.isdigit() or int(value) < 0:
|
||||||
|
raise ValueError("Workflow semantic subject cursor is invalid.")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Workflow semantic subjects require a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SUBJECT_KIND",
|
||||||
|
"WorkflowSemanticDocumentationSubjectProvider",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow test package for both discovery and targeted module execution."""
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Choreography"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Approval" name="Approval" />
|
||||||
|
<bpmn:collaboration id="Collaboration_Choreography">
|
||||||
|
<bpmn:participant id="Participant_Applicant" name="Applicant" />
|
||||||
|
<bpmn:participant id="Participant_Authority" name="Authority" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
<bpmn:choreography id="Choreography_1" name="Permit decision">
|
||||||
|
<bpmn:startEvent id="Choreography_Start" />
|
||||||
|
<bpmn:choreographyTask
|
||||||
|
id="Choreography_Task"
|
||||||
|
initiatingParticipantRef="Participant_Authority">
|
||||||
|
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
|
||||||
|
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
|
||||||
|
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
|
||||||
|
</bpmn:choreographyTask>
|
||||||
|
<bpmn:endEvent id="Choreography_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Choreography_Flow_1"
|
||||||
|
sourceRef="Choreography_Start"
|
||||||
|
targetRef="Choreography_Task" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Choreography_Flow_2"
|
||||||
|
sourceRef="Choreography_Task"
|
||||||
|
targetRef="Choreography_End" />
|
||||||
|
</bpmn:choreography>
|
||||||
|
<bpmn:messageFlow
|
||||||
|
id="MessageFlow_Approval"
|
||||||
|
sourceRef="Participant_Authority"
|
||||||
|
targetRef="Participant_Applicant"
|
||||||
|
messageRef="Message_Approval" />
|
||||||
|
</bpmn:definitions>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Collaboration"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Request" name="Request" />
|
||||||
|
<bpmn:process id="Process_Requester">
|
||||||
|
<bpmn:startEvent id="Requester_Start" />
|
||||||
|
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:process id="Process_Reviewer">
|
||||||
|
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
|
||||||
|
<bpmn:endEvent id="Reviewer_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:collaboration id="Collaboration_1">
|
||||||
|
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
|
||||||
|
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
|
||||||
|
<bpmn:messageFlow
|
||||||
|
id="MessageFlow_1"
|
||||||
|
sourceRef="Send_Request"
|
||||||
|
targetRef="Receive_Request"
|
||||||
|
messageRef="Message_Request" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
</bpmn:definitions>
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Control_Flow"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:signal id="Signal_Escalation" name="Escalation" />
|
||||||
|
<bpmn:process id="Called_Process" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Called_Start" />
|
||||||
|
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
|
||||||
|
<bpmn:endEvent id="Called_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Called_Flow_1"
|
||||||
|
sourceRef="Called_Start"
|
||||||
|
targetRef="Called_Human_Task" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Called_Flow_2"
|
||||||
|
sourceRef="Called_Human_Task"
|
||||||
|
targetRef="Called_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:process id="Control_Process" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Control_Start" />
|
||||||
|
<bpmn:exclusiveGateway id="Control_Decision" />
|
||||||
|
<bpmn:subProcess id="Review_Subprocess" name="Review">
|
||||||
|
<bpmn:startEvent id="Subprocess_Start" />
|
||||||
|
<bpmn:userTask id="Subprocess_Review" name="Review request" />
|
||||||
|
<bpmn:endEvent id="Subprocess_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Subprocess_Flow_1"
|
||||||
|
sourceRef="Subprocess_Start"
|
||||||
|
targetRef="Subprocess_Review" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Subprocess_Flow_2"
|
||||||
|
sourceRef="Subprocess_Review"
|
||||||
|
targetRef="Subprocess_End" />
|
||||||
|
</bpmn:subProcess>
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Review_Escalation"
|
||||||
|
attachedToRef="Review_Subprocess"
|
||||||
|
cancelActivity="false">
|
||||||
|
<bpmn:signalEventDefinition
|
||||||
|
id="Review_Escalation_Definition"
|
||||||
|
signalRef="Signal_Escalation" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:parallelGateway id="Control_Join" />
|
||||||
|
<bpmn:callActivity
|
||||||
|
id="Call_Confirmation"
|
||||||
|
name="Confirm"
|
||||||
|
calledElement="Called_Process" />
|
||||||
|
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
|
||||||
|
<bpmn:signalEventDefinition
|
||||||
|
id="Escalation_Thrown_Definition"
|
||||||
|
signalRef="Signal_Escalation" />
|
||||||
|
</bpmn:intermediateThrowEvent>
|
||||||
|
<bpmn:task
|
||||||
|
id="Compensation_Handler"
|
||||||
|
name="Undo review"
|
||||||
|
isForCompensation="true" />
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Review_Compensation"
|
||||||
|
attachedToRef="Review_Subprocess">
|
||||||
|
<bpmn:compensateEventDefinition
|
||||||
|
id="Review_Compensation_Definition"
|
||||||
|
activityRef="Compensation_Handler" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:endEvent id="Control_End" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_1"
|
||||||
|
sourceRef="Control_Start"
|
||||||
|
targetRef="Control_Decision" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_2"
|
||||||
|
sourceRef="Control_Decision"
|
||||||
|
targetRef="Review_Subprocess" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_3"
|
||||||
|
sourceRef="Review_Subprocess"
|
||||||
|
targetRef="Control_Join" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_4"
|
||||||
|
sourceRef="Control_Join"
|
||||||
|
targetRef="Call_Confirmation" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_5"
|
||||||
|
sourceRef="Call_Confirmation"
|
||||||
|
targetRef="Escalation_Thrown" />
|
||||||
|
<bpmn:sequenceFlow
|
||||||
|
id="Control_Flow_6"
|
||||||
|
sourceRef="Escalation_Thrown"
|
||||||
|
targetRef="Control_End" />
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Data"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
|
||||||
|
<bpmn:process id="Process_Data">
|
||||||
|
<bpmn:dataObject id="DataObject_Request" name="Request" />
|
||||||
|
<bpmn:dataObjectReference
|
||||||
|
id="DataObjectReference_Request"
|
||||||
|
dataObjectRef="DataObject_Request" />
|
||||||
|
<bpmn:dataStoreReference
|
||||||
|
id="DataStoreReference_Archive"
|
||||||
|
dataStoreRef="DataStore_Archive" />
|
||||||
|
<bpmn:scriptTask id="Transform_Data" name="Transform data">
|
||||||
|
<bpmn:script>result = input</bpmn:script>
|
||||||
|
<bpmn:dataInputAssociation id="InputAssociation_1">
|
||||||
|
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
|
||||||
|
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
|
||||||
|
</bpmn:dataInputAssociation>
|
||||||
|
<bpmn:dataOutputAssociation id="OutputAssociation_1">
|
||||||
|
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
|
||||||
|
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
|
||||||
|
</bpmn:dataOutputAssociation>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
id="Definitions_Events"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:message id="Message_Continue" name="Continue" />
|
||||||
|
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
|
||||||
|
<bpmn:process id="Process_Transaction" isExecutable="true">
|
||||||
|
<bpmn:startEvent id="Start_Timer">
|
||||||
|
<bpmn:timerEventDefinition id="Timer_Start_Definition">
|
||||||
|
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
|
||||||
|
</bpmn:timerEventDefinition>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:transaction id="Transaction_1">
|
||||||
|
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
|
||||||
|
<bpmn:boundaryEvent
|
||||||
|
id="Charge_Error"
|
||||||
|
attachedToRef="Charge_Account">
|
||||||
|
<bpmn:errorEventDefinition
|
||||||
|
id="Charge_Error_Definition"
|
||||||
|
errorRef="Error_Processing" />
|
||||||
|
</bpmn:boundaryEvent>
|
||||||
|
<bpmn:task
|
||||||
|
id="Undo_Charge"
|
||||||
|
name="Undo charge"
|
||||||
|
isForCompensation="true" />
|
||||||
|
<bpmn:association
|
||||||
|
id="Compensation_Association"
|
||||||
|
sourceRef="Charge_Error"
|
||||||
|
targetRef="Undo_Charge"
|
||||||
|
associationDirection="One" />
|
||||||
|
</bpmn:transaction>
|
||||||
|
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
|
||||||
|
<bpmn:messageEventDefinition
|
||||||
|
id="Wait_Message_Definition"
|
||||||
|
messageRef="Message_Continue" />
|
||||||
|
</bpmn:intermediateCatchEvent>
|
||||||
|
<bpmn:endEvent id="End_Transaction">
|
||||||
|
<bpmn:terminateEventDefinition id="Terminate_Definition" />
|
||||||
|
</bpmn:endEvent>
|
||||||
|
</bpmn:process>
|
||||||
|
</bpmn:definitions>
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions
|
||||||
|
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
|
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||||
|
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||||
|
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
|
||||||
|
id="Definitions_Process"
|
||||||
|
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||||
|
<bpmn:process id="Process_Linear" isExecutable="true">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<govoplan:fixture revision="1">
|
||||||
|
<govoplan:note>Preserve this extension exactly.</govoplan:note>
|
||||||
|
</govoplan:fixture>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:startEvent id="Start_1">
|
||||||
|
<bpmn:outgoing>Flow_1</bpmn:outgoing>
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:userTask id="Task_1" name="Review">
|
||||||
|
<bpmn:incoming>Flow_1</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_2</bpmn:outgoing>
|
||||||
|
</bpmn:userTask>
|
||||||
|
<bpmn:endEvent id="End_1">
|
||||||
|
<bpmn:incoming>Flow_2</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmndi:BPMNDiagram id="Diagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
|
||||||
|
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
|
||||||
|
<dc:Bounds x="80" y="112" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
|
||||||
|
<dc:Bounds x="220" y="90" width="100" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
|
||||||
|
<dc:Bounds x="430" y="112" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
|
||||||
|
<di:waypoint x="116" y="130" />
|
||||||
|
<di:waypoint x="220" y="130" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
|
||||||
|
<di:waypoint x="320" y="130" />
|
||||||
|
<di:waypoint x="430" y="130" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_workflow.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()
|
||||||
@@ -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
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
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)
|
||||||
|
project = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text())
|
||||||
|
self.assertEqual(project["project"]["version"], manifest.version)
|
||||||
|
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.assertIn("documentation.semantic_subjects.workflow", manifest.capability_factories)
|
||||||
|
self.assertIn("docs", manifest.optional_dependencies)
|
||||||
|
self.assertIn(
|
||||||
|
"documentation.semantic_subjects.workflow",
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"workflow.semantic-documentation",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"@govoplan/workflow-webui",
|
||||||
|
manifest.frontend.package_name if manifest.frontend else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(german.get(field) for field in ("title", "summary", "body")),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_editor_exposes_semantic_help_without_replacing_static_help(self) -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
page = (root / "webui/src/features/workflow/WorkflowPage.tsx").read_text()
|
||||||
|
inspector = (
|
||||||
|
root / "webui/src/features/workflow/WorkflowInspector.tsx"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
self.assertIn("workflow.semantic-documentation", {
|
||||||
|
topic.id for topic in get_manifest().documentation
|
||||||
|
})
|
||||||
|
self.assertIn('"semantic"', page)
|
||||||
|
self.assertIn('"workflow_definition"', page)
|
||||||
|
self.assertIn('target="_blank"', page)
|
||||||
|
self.assertIn('target="_blank"', inspector)
|
||||||
|
self.assertIn("workflow-node-${nodeId}", inspector)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.schemas import (
|
||||||
|
WorkflowDefinitionCreateRequest,
|
||||||
|
WorkflowDefinitionUpdateRequest,
|
||||||
|
WorkflowEdge,
|
||||||
|
WorkflowGraph,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowPosition,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.service import (
|
||||||
|
create_definition,
|
||||||
|
delete_definition,
|
||||||
|
update_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
scopes: frozenset[str] = frozenset({"workflow:definition:read"}),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="author-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=scopes,
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graph(
|
||||||
|
*,
|
||||||
|
activity_label: str = "Review",
|
||||||
|
activity_x: float = 280,
|
||||||
|
include_activity: bool = True,
|
||||||
|
) -> WorkflowGraph:
|
||||||
|
nodes = [
|
||||||
|
WorkflowNode(
|
||||||
|
id="start",
|
||||||
|
type="workflow.start.manual",
|
||||||
|
label="Start",
|
||||||
|
position=WorkflowPosition(x=40, y=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if include_activity:
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="activity",
|
||||||
|
type="workflow.activity",
|
||||||
|
label=activity_label,
|
||||||
|
position=WorkflowPosition(x=activity_x, y=100),
|
||||||
|
config={
|
||||||
|
"instructions": "Check the submitted evidence.",
|
||||||
|
"api_secret": "must-not-leak",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="complete",
|
||||||
|
type="workflow.end.completed",
|
||||||
|
label="Completed",
|
||||||
|
position=WorkflowPosition(x=520, y=100),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edges = []
|
||||||
|
if include_activity:
|
||||||
|
edges.extend(
|
||||||
|
(
|
||||||
|
WorkflowEdge(id="start-activity", source="start", target="activity"),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="activity-complete",
|
||||||
|
source="activity",
|
||||||
|
target="complete",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
edges.append(
|
||||||
|
WorkflowEdge(id="start-complete", source="start", target="complete")
|
||||||
|
)
|
||||||
|
return WorkflowGraph(nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticSubjectTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=(
|
||||||
|
WorkflowDefinition.__table__,
|
||||||
|
WorkflowDefinitionRevision.__table__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = principal()
|
||||||
|
self.provider = WorkflowSemanticDocumentationSubjectProvider(None)
|
||||||
|
self.definition = create_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
|
name="Resident permit review",
|
||||||
|
description="Review an application before issuing the permit.",
|
||||||
|
graph=graph(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def subjects(self):
|
||||||
|
return self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=200,
|
||||||
|
),
|
||||||
|
).subjects
|
||||||
|
|
||||||
|
def activity(self):
|
||||||
|
return next(
|
||||||
|
item
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.route_anchor == "workflow-node-activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def revise(self, updated_graph: WorkflowGraph) -> None:
|
||||||
|
update_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionUpdateRequest(
|
||||||
|
name=self.definition.name,
|
||||||
|
description=self.definition.description,
|
||||||
|
graph=updated_graph,
|
||||||
|
expected_revision=self.definition.current_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def test_exposes_safe_definition_and_step_descriptors(self) -> None:
|
||||||
|
subjects = self.subjects()
|
||||||
|
self.assertEqual(4, len(subjects))
|
||||||
|
definition = next(item for item in subjects if item.reference.anchor is None)
|
||||||
|
activity = self.activity()
|
||||||
|
|
||||||
|
self.assertEqual("Resident permit review", definition.labels["en"])
|
||||||
|
self.assertEqual("step", activity.reference.anchor.kind)
|
||||||
|
self.assertTrue(activity.reference.anchor.id.startswith("step-"))
|
||||||
|
self.assertEqual("/workflow?definition=" + self.definition.id, activity.route)
|
||||||
|
payload = activity.to_dict()
|
||||||
|
self.assertNotIn("config", payload)
|
||||||
|
self.assertNotIn("must-not-leak", str(payload))
|
||||||
|
|
||||||
|
def test_lineage_survives_layout_and_label_changes_with_review_fingerprint(self) -> None:
|
||||||
|
before = self.activity().reference
|
||||||
|
self.revise(graph(activity_x=900))
|
||||||
|
after_layout = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_layout.stable_key)
|
||||||
|
self.assertEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_layout.observed_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.revise(graph(activity_x=900, activity_label="Assess evidence"))
|
||||||
|
after_label = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_label.stable_key)
|
||||||
|
self.assertNotEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_label.observed_fingerprint,
|
||||||
|
)
|
||||||
|
resolution = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=before,
|
||||||
|
)
|
||||||
|
self.assertEqual("changed", resolution.availability)
|
||||||
|
|
||||||
|
def test_deleted_and_recreated_step_id_gets_new_lineage(self) -> None:
|
||||||
|
old = self.activity().reference
|
||||||
|
self.revise(graph(include_activity=False))
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("step_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
self.revise(graph(activity_label="Recreated review"))
|
||||||
|
recreated = self.activity().reference
|
||||||
|
self.assertNotEqual(old.stable_key, recreated.stable_key)
|
||||||
|
still_deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("step_deleted", still_deleted.reason_code)
|
||||||
|
|
||||||
|
def test_resolution_rechecks_scope_tenant_governance_and_deletion(self) -> None:
|
||||||
|
reference = self.activity().reference
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(scopes=frozenset()),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(tenant_id="tenant-2"),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admin = principal(scopes=frozenset({"workflow:instance:admin"}))
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
admin,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("definition_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
def test_query_filters_and_pages_without_docs_runtime(self) -> None:
|
||||||
|
page = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(page.subjects))
|
||||||
|
self.assertTrue(page.has_more)
|
||||||
|
second = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
cursor=page.next_cursor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(second.subjects))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_workflow.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()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/workflow-webui",
|
||||||
|
"version": "0.1.23",
|
||||||
|
"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.45",
|
||||||
|
"@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
@@ -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,386 @@
|
|||||||
|
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 { StatePanel } from "@govoplan/core-webui";
|
||||||
|
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="definition-graph-canvas 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 ? (
|
||||||
|
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a start node here" />
|
||||||
|
) : 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,382 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { BookOpen, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ActionToolbar,
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
ReferenceMultiSelect,
|
||||||
|
StatePanel,
|
||||||
|
useViewSurfaces,
|
||||||
|
type ReferenceOptionProvider
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import type {
|
||||||
|
WorkflowGraphEdge,
|
||||||
|
WorkflowGraphNode,
|
||||||
|
WorkflowNodeType
|
||||||
|
} from "../../api/workflow";
|
||||||
|
|
||||||
|
export default function WorkflowInspector({
|
||||||
|
node,
|
||||||
|
edge,
|
||||||
|
nodeLibrary,
|
||||||
|
readOnly,
|
||||||
|
semanticDefinitionId,
|
||||||
|
semanticStepAvailable,
|
||||||
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
onEdgeChange,
|
||||||
|
onEdgeDelete
|
||||||
|
}: {
|
||||||
|
node: WorkflowGraphNode | null;
|
||||||
|
edge: WorkflowGraphEdge | null;
|
||||||
|
nodeLibrary: WorkflowNodeType[];
|
||||||
|
readOnly: boolean;
|
||||||
|
semanticDefinitionId: string | null;
|
||||||
|
semanticStepAvailable: 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">
|
||||||
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
|
<span>
|
||||||
|
<strong>Flow</strong>
|
||||||
|
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
helpContextId="workflow.action.delete-definition-parts"
|
||||||
|
helpModuleId="workflow"
|
||||||
|
className="workflow-inspector-delete"
|
||||||
|
onClick={() => onEdgeDelete(edge.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
aria-label="Delete flow"
|
||||||
|
title="Delete flow"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
<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">
|
||||||
|
<ActionToolbar surface="section-header" className="workflow-panel-heading"><strong>Inspector</strong></ActionToolbar>
|
||||||
|
<StatePanel size="compact" description="No node selected" />
|
||||||
|
</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">
|
||||||
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
|
<span>
|
||||||
|
<strong>Inspector</strong>
|
||||||
|
<small>{definition?.label ?? node.type}</small>
|
||||||
|
</span>
|
||||||
|
<span className="workflow-inspector-actions">
|
||||||
|
{semanticDefinitionId && semanticStepAvailable ? <>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
contextId: semanticHelpContext(semanticDefinitionId, node.id),
|
||||||
|
documentationType: "user"
|
||||||
|
}}
|
||||||
|
label="Open step meaning"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={15} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
</> : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
helpContextId="workflow.action.delete-definition-parts"
|
||||||
|
helpModuleId="workflow"
|
||||||
|
className="workflow-inspector-delete"
|
||||||
|
onClick={() => onDelete(node.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
aria-label="Delete node"
|
||||||
|
title="Delete node"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</ActionToolbar>
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(definitionId: string, nodeId: string): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"workflow",
|
||||||
|
"workflow_definition",
|
||||||
|
definitionId,
|
||||||
|
`workflow-node-${nodeId}`
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(definitionId: string, nodeId: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "workflow",
|
||||||
|
subjectKind: "workflow_definition",
|
||||||
|
subjectId: definitionId,
|
||||||
|
routeAnchor: `workflow-node-${nodeId}`
|
||||||
|
});
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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 { DefinitionNodeIcon } from "@govoplan/core-webui";
|
||||||
|
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({
|
||||||
|
id,
|
||||||
|
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
|
||||||
|
id={`workflow-node-${id}`}
|
||||||
|
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="definition-node-handle"
|
||||||
|
style={{ top: portPosition(index, inputPorts.length) }}
|
||||||
|
isConnectable={isConnectable}
|
||||||
|
title={port.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DefinitionNodeIcon className="workflow-node-icon"><Icon size={17} /></DefinitionNodeIcon>
|
||||||
|
<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="definition-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,621 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Circle,
|
||||||
|
Clock3,
|
||||||
|
ExternalLink,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
XCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ActionToolbar,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
ContentGrid,
|
||||||
|
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 unavailableOptionalCapabilities = Array.isArray(
|
||||||
|
currentStep?.handoff.unavailable_optional_capabilities
|
||||||
|
)
|
||||||
|
? currentStep.handoff.unavailable_optional_capabilities.filter(
|
||||||
|
(value): value is string => typeof value === "string" && Boolean(value)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
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>}
|
||||||
|
>
|
||||||
|
<ActionToolbar 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>
|
||||||
|
</ActionToolbar>
|
||||||
|
{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}
|
||||||
|
{selected.view_context && !viewsRuntime ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
resetKey={`view-unavailable:${selected.id}:${selected.current_step_id ?? "none"}`}
|
||||||
|
>
|
||||||
|
The focused View is unavailable. Workflow position is preserved;
|
||||||
|
use the linked module action or ask an administrator to enable Views.
|
||||||
|
</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}
|
||||||
|
{unavailableOptionalCapabilities.length ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="info"
|
||||||
|
resetKey={`optional:${currentStep.id}:${unavailableOptionalCapabilities.join(",")}`}
|
||||||
|
>
|
||||||
|
Optional integration unavailable: {unavailableOptionalCapabilities.join(", ")}.
|
||||||
|
The hand-off remains valid and resumable.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
{actionUrl ? (
|
||||||
|
<a href={actionUrl}>
|
||||||
|
Open linked work <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{allowedActions.some((action) => action !== "cancel") ? (
|
||||||
|
<ContentGrid columns={2} gap="compact" collapseAt="narrow" 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>
|
||||||
|
</ContentGrid>
|
||||||
|
) : 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.";
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { workflowModule as default, workflowModule } from "./module";
|
||||||
|
export * from "./api/workflow";
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
.workflow-definition-dialog {
|
||||||
|
width: min(620px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-comparison-dialog {
|
||||||
|
width: min(1180px, calc(100vw - 32px));
|
||||||
|
height: min(760px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-comparison {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-comparison-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-comparison-summary .status-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-list {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item.state-conflict {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item.state-upstream_only {
|
||||||
|
border-left-color: var(--info-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item header > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item small,
|
||||||
|
.workflow-standard-diff-item p,
|
||||||
|
.workflow-standard-diff-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workflow-standard-comparison-summary {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-comparison-summary .status-badge {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-definition-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-definition-fields input,
|
||||||
|
.workflow-definition-fields select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-provenance span,
|
||||||
|
.workflow-provenance small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-provenance code {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-workspace,
|
||||||
|
.workflow-editor,
|
||||||
|
.workflow-canvas,
|
||||||
|
.workflow-definition-list-frame,
|
||||||
|
.workflow-inspector-column {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-toolbar-actions,
|
||||||
|
.workflow-command-bar,
|
||||||
|
.workflow-identity-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-definition-list-frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.workflow-workspace-toolbar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-identity-fields {
|
||||||
|
min-width: 240px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-identity-fields input,
|
||||||
|
.workflow-revision-select {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-name-input {
|
||||||
|
max-width: 250px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-description-input {
|
||||||
|
min-width: 140px;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-revision-select {
|
||||||
|
width: 142px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-command-bar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-command-bar .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 34px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-alerts {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: 66px;
|
||||||
|
right: 12px;
|
||||||
|
display: grid;
|
||||||
|
width: min(500px, calc(100% - 24px));
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-alerts .alert {
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 210px minmax(0, 1fr) minmax(260px, 310px);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-bpmn-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector,
|
||||||
|
.workflow-inspector-column {
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-panel-heading > span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-panel-heading strong,
|
||||||
|
.workflow-panel-heading small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-panel-heading strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-panel-heading small {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--line-dark);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-bpmn_activity {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-bpmn_collaboration {
|
||||||
|
border-width: 2px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-participant,
|
||||||
|
.workflow-node-shape-lane {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-lane {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-group {
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 2px dashed var(--line-dark);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-text-annotation {
|
||||||
|
border: 0;
|
||||||
|
border-left: 2px solid var(--line-dark);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node[class*="workflow-node-shape-event"],
|
||||||
|
.workflow-node-shape-gateway {
|
||||||
|
justify-content: flex-start;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node[class*="workflow-node-shape-event"] .workflow-node-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-basis: 42px;
|
||||||
|
border: 2px solid var(--text-strong);
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-event-intermediate-catch .workflow-node-icon,
|
||||||
|
.workflow-node-shape-event-intermediate-throw .workflow-node-icon {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--panel), inset 0 0 0 4px var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-event-boundary .workflow-node-icon {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-event-end .workflow-node-icon {
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-gateway .workflow-node-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-basis: 42px;
|
||||||
|
border: 2px solid var(--text-strong);
|
||||||
|
border-radius: var(--radius-hairline);
|
||||||
|
background: var(--panel);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-gateway .workflow-node-icon svg {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-shape-data-object,
|
||||||
|
.workflow-node-shape-data-store,
|
||||||
|
.workflow-node-shape-conversation,
|
||||||
|
.workflow-node-shape-choreography {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent),
|
||||||
|
var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node[class*="workflow-node-shape-event"].is-selected,
|
||||||
|
.workflow-node-shape-gateway.is-selected {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node[class*="workflow-node-shape-event"].is-selected .workflow-node-icon,
|
||||||
|
.workflow-node-shape-gateway.is-selected .workflow-node-icon {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node.has-error {
|
||||||
|
border-color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node[class*="workflow-node-shape-event"].has-error .workflow-node-icon,
|
||||||
|
.workflow-node-shape-gateway.has-error .workflow-node-icon {
|
||||||
|
border-color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-copy strong,
|
||||||
|
.workflow-node-copy small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-copy strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-node-copy small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
|
||||||
|
.workflow-edge-bpmn-association .react-flow__edge-path,
|
||||||
|
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-checkbox input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
height: calc(100% - 44px);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-fields input,
|
||||||
|
.workflow-inspector-fields select,
|
||||||
|
.workflow-inspector-fields textarea {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-fields textarea {
|
||||||
|
min-height: 82px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-json-editor {
|
||||||
|
min-height: 150px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-delete {
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics {
|
||||||
|
display: flex;
|
||||||
|
max-height: 38%;
|
||||||
|
min-height: 120px;
|
||||||
|
flex: 0 1 38%;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: var(--border-line-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics > div:last-child {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics button:hover {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics button strong,
|
||||||
|
.workflow-diagnostics button small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics button strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-diagnostics button small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-dialog {
|
||||||
|
width: min(1120px, calc(100vw - 32px));
|
||||||
|
height: min(760px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 56px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-toolbar > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-toolbar > span:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-toolbar small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-frame,
|
||||||
|
.workflow-runs-layout,
|
||||||
|
.workflow-run-list,
|
||||||
|
.workflow-run-detail {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list {
|
||||||
|
overflow: auto;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list > button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list > button:hover,
|
||||||
|
.workflow-run-list > button:focus-visible {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list > button.is-selected {
|
||||||
|
background: var(--primary-soft-strong);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list strong,
|
||||||
|
.workflow-run-list small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header,
|
||||||
|
.workflow-run-handoff > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
min-height: 56px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header > span:first-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header strong,
|
||||||
|
.workflow-run-detail > header small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-detail > header small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-handoff,
|
||||||
|
.workflow-run-history,
|
||||||
|
.workflow-run-events {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-handoff p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-handoff a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: fit-content;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-action-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-actions {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history h3,
|
||||||
|
.workflow-run-events h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history > div,
|
||||||
|
.workflow-run-events > div {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-events > div > span {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 38px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history .stage-rail {
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history small,
|
||||||
|
.workflow-run-events small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 120px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.workflow-workspace-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-command-bar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-editor {
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) 270px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workflow-editor {
|
||||||
|
grid-template-columns: 140px minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-column {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.workflow-page {
|
||||||
|
height: calc(100vh - 94px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-identity-fields,
|
||||||
|
.workflow-command-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-name-input,
|
||||||
|
.workflow-description-input {
|
||||||
|
max-width: none;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-editor {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-runs-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-list {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history > div > span,
|
||||||
|
.workflow-run-events > div > span {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-run-history > div > span small,
|
||||||
|
.workflow-run-events > div > span small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.workflow-page *,
|
||||||
|
.workflow-page *::before,
|
||||||
|
.workflow-page *::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+16
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user