Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,19 +1,35 @@
|
||||
# govoplan-workflow
|
||||
# GovOPlaN Workflow
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-workflow` will own process orchestration for GovOPlaN.
|
||||
Optional visual authoring and inspection workspace for GovOPlaN Workflow
|
||||
Engine.
|
||||
|
||||
The module should execute configurable state machines and command handoffs
|
||||
between modules without importing their implementations. It coordinates cases,
|
||||
tasks, forms, files, templates, mail, appointments, payments, and records
|
||||
through capabilities, events, commands, and DTOs.
|
||||
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.
|
||||
|
||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
||||
For one compatibility release, Python imports below
|
||||
`govoplan_workflow.backend` re-export their corresponding Workflow Engine
|
||||
implementations. New module code must use Core's `workflow.*` capabilities or,
|
||||
for engine implementation code, `govoplan_workflow_engine` directly.
|
||||
|
||||
There is a BPMN component playing a major role here. Maybe this needs to
|
||||
become a separate module. It is quite viable to think about workflow
|
||||
modelling (and consequently import and export) in terms of BPMN, permitting
|
||||
a standardized configuration of the system.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
`govoplan-workflow` is the process orchestration module. It turns a configured
|
||||
administrative procedure into state transitions, guards, commands, timers, and
|
||||
operator-visible progress.
|
||||
`govoplan-workflow-engine` is the headless process orchestration module.
|
||||
`govoplan-workflow` is its optional authoring and inspection surface. See
|
||||
`ENGINE_EDITOR_SPLIT.md`.
|
||||
|
||||
Workflow does not own business records. A case, task, file, appointment,
|
||||
template, payment, or postbox message remains owned by its domain module.
|
||||
@@ -19,7 +19,7 @@ action/effect contracts.
|
||||
|
||||
## Ownership
|
||||
|
||||
The module owns:
|
||||
Workflow Engine owns:
|
||||
|
||||
- workflow definitions and versions
|
||||
- workflow instances and current state
|
||||
@@ -29,7 +29,10 @@ The module owns:
|
||||
- retry/manual-intervention state for failed command handoffs
|
||||
- action/effect execution records for workflow-triggered automation
|
||||
- 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:
|
||||
|
||||
@@ -95,18 +98,32 @@ Permit-to-payment MVP:
|
||||
|
||||
## 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
|
||||
- create/read/list workflow instances
|
||||
- transition execution with permission checks
|
||||
- event, API, schedule, and parent-workflow start dispatchers
|
||||
- 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
|
||||
- idempotency keys for command execution
|
||||
- explicit blocked, retryable, quarantined, manual-required, and
|
||||
compensation-required states
|
||||
- basic WebUI instance detail and definition viewer
|
||||
- dashboard summary provider
|
||||
- event emission and audit integration
|
||||
|
||||
@@ -139,12 +156,16 @@ details.
|
||||
|
||||
## Data Model Sketch
|
||||
|
||||
Candidate tables:
|
||||
Current tables:
|
||||
|
||||
- `workflow_definitions`
|
||||
- `workflow_definition_versions`
|
||||
- `workflow_definition_revisions`
|
||||
- `workflow_instances`
|
||||
- `workflow_transition_history`
|
||||
- `workflow_instance_steps`
|
||||
- `workflow_instance_events`
|
||||
|
||||
Future generic action execution and timers may add:
|
||||
|
||||
- `workflow_command_records`
|
||||
- `workflow_timers`
|
||||
|
||||
@@ -153,16 +174,14 @@ reference the exact version used at start.
|
||||
|
||||
## WebUI
|
||||
|
||||
Initial route contributions:
|
||||
Current route contribution:
|
||||
|
||||
- `/workflow`
|
||||
- `/workflow/instances/:instanceId`
|
||||
- `/workflow/definitions/:definitionId`
|
||||
|
||||
The UI should show current state, available transitions, pending commands,
|
||||
failed handoffs, audit trace, and linked subject records. It should not import
|
||||
case/task/template components directly; panels are contributed through core UI
|
||||
extension points.
|
||||
The route combines the definition editor and a fixed run dialog showing current
|
||||
state, available transitions, failed handoffs, comments/evidence, immutable
|
||||
event history, and linked Dataflow results. It does not import Dataflow or other
|
||||
domain UI components.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -177,8 +196,6 @@ Minimum tests:
|
||||
|
||||
## 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
|
||||
scheduler abstraction.
|
||||
- How workflow variables are redacted and retained.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Workflow Engine And Workflow Editor Split
|
||||
|
||||
## Decision
|
||||
|
||||
Split the current module into two installable modules:
|
||||
|
||||
- `govoplan-workflow-engine` (runtime module ID `workflow_engine`) is the
|
||||
headless definition and execution platform available to all modules.
|
||||
- `govoplan-workflow` remains the optional authoring, inspection, catalogue,
|
||||
diff, override, and reset WebUI.
|
||||
|
||||
Other modules depend on Workflow Engine capabilities, never on the Workflow
|
||||
editor package. Workflow depends on Workflow Engine.
|
||||
|
||||
This is a packaging and ownership split, not a second workflow model. BPMN 2.0
|
||||
and the existing native graph remain the canonical language and use the same
|
||||
versioned contracts.
|
||||
|
||||
## Existing Versioning
|
||||
|
||||
Workflow definitions are already versioned:
|
||||
|
||||
- `workflow_definitions.current_revision` identifies the latest graph revision.
|
||||
- `workflow_definitions.active_revision` selects the revision used for new
|
||||
instances.
|
||||
- `workflow_definition_revisions` stores immutable graph content, hashes, node
|
||||
library versions, execution mode, pinned View revision, BPMN XML/hash, and
|
||||
execution-adapter profile/version.
|
||||
- Graph changes create a new revision and return an active definition to draft;
|
||||
metadata-only changes do not create graph revisions.
|
||||
- Updates use `expected_revision` optimistic concurrency.
|
||||
- Instances pin `definition_revision_id`; later edits or module upgrades cannot
|
||||
mutate running or historical instances.
|
||||
- Derived definitions record source definition, source revision/hash, actor,
|
||||
Policy decision, scope, and effective ancestor limits.
|
||||
|
||||
The missing model is a durable module-owned baseline and local override/reset
|
||||
relationship.
|
||||
|
||||
## Workflow Engine Ownership
|
||||
|
||||
Workflow Engine owns:
|
||||
|
||||
- definition, immutable revision, instance, step, event, command, timer, and
|
||||
execution-record persistence and migrations
|
||||
- definition CRUD/activation/derivation APIs and headless read APIs
|
||||
- Workflow graph/BPMN schemas, validation, import/export, and conformance
|
||||
- node-library and execution-adapter registries
|
||||
- instance start/transition/cancel/retry/reconciliation services
|
||||
- runtime worker, event/API/schedule/parent dispatch, idempotency, and recovery
|
||||
- definition governance capability integration and audit/event emission
|
||||
- configuration-package workflow fragments
|
||||
- module-contributed standard definition discovery and reconciliation
|
||||
|
||||
It contributes no primary navigation or full editor. A module can install and
|
||||
run its workflows when the editor is absent.
|
||||
|
||||
## Workflow Editor Ownership
|
||||
|
||||
Workflow owns:
|
||||
|
||||
- the Workflow workspace and visual BPMN/graph editor
|
||||
- definition/revision catalogue, preview, diff, validation, and activation UI
|
||||
- instance inspection and operator controls built on Engine APIs
|
||||
- derivation and governed override UX
|
||||
- module-standard update comparison and reset-to-standard UX
|
||||
- reusable embedded editor/inspector components for other module surfaces
|
||||
|
||||
The editor never owns workflow tables or executes transitions directly.
|
||||
|
||||
## Module-Contributed Definitions
|
||||
|
||||
Modules announce standard workflows through a versioned Engine contribution
|
||||
contract or a module-owned configuration-package fragment. A contribution has:
|
||||
|
||||
- origin module ID/version, stable definition key, contribution schema version,
|
||||
and content hash
|
||||
- graph plus BPMN representation, node-library/profile requirements, and
|
||||
execution mode
|
||||
- default scope, start/reuse/automation ceilings, required capabilities, and
|
||||
Policy metadata
|
||||
- upgrade compatibility and optional migration diagnostics
|
||||
|
||||
Engine reconciles contributions idempotently after module discovery. The
|
||||
module-provided baseline is immutable. A module update may add a new baseline
|
||||
revision, but it never rewrites a running instance or silently replaces a local
|
||||
override.
|
||||
|
||||
## Override And Reset
|
||||
|
||||
Editing a system/module standard creates a local override derived from a pinned
|
||||
baseline revision. The UI may present this as editing the effective definition,
|
||||
but the canonical baseline remains available.
|
||||
|
||||
- View is always possible when the caller can read the definition.
|
||||
- Edit/derive is controlled by Policy and scope ceilings.
|
||||
- An upstream baseline update is shown as an available update with a three-way
|
||||
diff; it is not merged silently.
|
||||
- Reset archives the local override and selects the latest permitted baseline.
|
||||
- Historical overrides, baselines, and instances remain addressable for audit.
|
||||
- A tenant/group/user override cannot loosen inherited restrictions.
|
||||
|
||||
## Compatibility And Extraction Order
|
||||
|
||||
1. Define Engine-owned capability and contribution DTOs in Core-neutral
|
||||
contracts while preserving existing `workflow.*` interface names.
|
||||
2. Create `govoplan-workflow-engine` and move backend code, tests, migrations,
|
||||
and runtime workers without changing table names or API paths.
|
||||
3. Transfer migration ownership without replaying the existing chain. Test both
|
||||
upgrades and fresh installs with Engine alone.
|
||||
4. Keep a compatibility facade in `govoplan-workflow` for one release line;
|
||||
make it depend on `workflow_engine` and retain only WebUI/editor code.
|
||||
5. Update Core workers and consuming modules to resolve Engine capabilities.
|
||||
6. Add module contributions, immutable baselines, override/update/reset, and
|
||||
configuration-package support.
|
||||
7. Remove compatibility imports only under the platform compatibility policy.
|
||||
|
||||
The extraction must preserve existing definition IDs, revision IDs, active
|
||||
revision selection, instance foreign keys, idempotency keys, API routes, and
|
||||
audit references.
|
||||
|
||||
## Implemented Boundary
|
||||
|
||||
The split is implemented in the `govoplan-workflow-engine` repository. Engine
|
||||
owns the unchanged migration chain and `/api/v1/workflow` API, retains the
|
||||
existing `workflow:*` permission namespace through an explicit manifest
|
||||
compatibility field, and exposes headless runtime and contribution
|
||||
capabilities. `govoplan-workflow` now has a hard dependency on runtime module
|
||||
ID `workflow_engine`, contributes only its WebUI/navigation/editor contract,
|
||||
and keeps one release line of `govoplan_workflow.backend` import facades.
|
||||
|
||||
Module manifests can announce versioned workflow baselines. Reconciliation is
|
||||
idempotent, records module/schema/hash provenance, keeps a newly supplied
|
||||
baseline revision inactive when an older revision is active, and fails closed
|
||||
when required capabilities or interfaces are absent. Baselines are immutable;
|
||||
editing derives a pinned local override, and reset archives that override
|
||||
without removing revision or instance history.
|
||||
@@ -0,0 +1,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,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-workflow"
|
||||
version = "0.1.18"
|
||||
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-workflow-engine>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_workflow = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
workflow = "govoplan_workflow.backend.manifest:get_manifest"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
||||
|
||||
__version__ = "0.1.18"
|
||||
@@ -0,0 +1 @@
|
||||
"""Compatibility facades for the extracted Workflow Engine backend."""
|
||||
@@ -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,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
ViewSurface,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_workflow_engine.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
INSTANCE_READ_SCOPE,
|
||||
INSTANCE_START_SCOPE,
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "workflow"
|
||||
MODULE_NAME = "Workflow"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("workflow_engine",),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name="workflow.definition_graph",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="workflow.definition_catalogue",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="workflow.bpmn_interchange",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/workflow",
|
||||
label="Workflow",
|
||||
icon="workflow",
|
||||
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/workflow-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/workflow",
|
||||
component="WorkflowPage",
|
||||
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/workflow",
|
||||
label="Workflow",
|
||||
icon="workflow",
|
||||
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="workflow.widget.open-work",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Open workflow work widget",
|
||||
order=76,
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="workflow.editor",
|
||||
title="Workflow editor and inspection workspace",
|
||||
summary=(
|
||||
"Optional authoring, validation, revision inspection, and "
|
||||
"operator controls for Workflow Engine."
|
||||
),
|
||||
body=(
|
||||
"Workflow adds the visual BPMN/native graph editor, definition "
|
||||
"catalogue, immutable revision comparison, activation controls, "
|
||||
"instance inspection, and governed override/reset experience. "
|
||||
"Definitions and instances remain owned and executed by the "
|
||||
"headless Workflow Engine module."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||
related_modules=("workflow_engine", "views", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Workflow interface pattern audit",
|
||||
href="govoplan-workflow/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
order=76,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="human_work_procedure",
|
||||
kind="editor",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
|
||||
test_ref="tests/test_manifest.py",
|
||||
known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",),
|
||||
owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"),
|
||||
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
|
||||
security_docs=("docs/CONCEPT.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_SCOPE",
|
||||
"DEFINITION_READ_SCOPE",
|
||||
"DEFINITION_WRITE_SCOPE",
|
||||
"INSTANCE_READ_SCOPE",
|
||||
"INSTANCE_START_SCOPE",
|
||||
"INSTANCE_TRANSITION_SCOPE",
|
||||
"MODULE_ID",
|
||||
"MODULE_VERSION",
|
||||
"get_manifest",
|
||||
"manifest",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Compatibility facade for the extracted Workflow Engine backend."""
|
||||
|
||||
from govoplan_workflow_engine.backend.node_library import * # noqa: F401,F403
|
||||
@@ -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,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,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_workflow.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class WorkflowManifestTests(unittest.TestCase):
|
||||
def test_manifest_is_an_editor_only_engine_client(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual("workflow", manifest.id)
|
||||
self.assertEqual(("workflow_engine",), manifest.dependencies)
|
||||
self.assertEqual(
|
||||
{"workflow.definition_graph", "workflow.definition_catalogue", "workflow.bpmn_interchange"},
|
||||
{item.name for item in manifest.requires_interfaces},
|
||||
)
|
||||
self.assertIn(
|
||||
"workflow.editor",
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertEqual((), manifest.permissions)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertEqual({}, manifest.capability_factories)
|
||||
self.assertEqual(
|
||||
"@govoplan/workflow-webui",
|
||||
manifest.frontend.package_name if manifest.frontend else None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,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.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,385 @@
|
||||
import { useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
ConnectionLineType,
|
||||
Controls,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
reconnectEdge,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type ReactFlowInstance
|
||||
} from "@xyflow/react";
|
||||
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
|
||||
import type {
|
||||
WorkflowDiagnostic,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowGraphNode,
|
||||
WorkflowNodeType
|
||||
} from "../../api/workflow";
|
||||
import { newWorkflowNode } from "./model";
|
||||
import WorkflowNode, { type WorkflowFlowNode } from "./WorkflowNode";
|
||||
|
||||
const nodeTypes = { workflow: WorkflowNode };
|
||||
|
||||
export default function WorkflowCanvas({
|
||||
graph,
|
||||
diagnostics,
|
||||
nodeLibrary,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
readOnly,
|
||||
allowsCycles,
|
||||
onGraphChange,
|
||||
onSelectNode,
|
||||
onSelectEdge
|
||||
}: {
|
||||
graph: WorkflowGraph;
|
||||
diagnostics: WorkflowDiagnostic[];
|
||||
nodeLibrary: WorkflowNodeType[];
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
readOnly: boolean;
|
||||
allowsCycles: boolean;
|
||||
onGraphChange: (graph: WorkflowGraph) => void;
|
||||
onSelectNode: (nodeId: string | null) => void;
|
||||
onSelectEdge: (edgeId: string | null) => void;
|
||||
}) {
|
||||
const [instance, setInstance] = useState<
|
||||
ReactFlowInstance<WorkflowFlowNode, Edge> | null
|
||||
>(null);
|
||||
const reconnectSuccessful = useRef(true);
|
||||
const reconnectingEdgeId = useRef<string | null>(null);
|
||||
const definitions = useMemo(
|
||||
() => new Map(nodeLibrary.map((item) => [item.type, item])),
|
||||
[nodeLibrary]
|
||||
);
|
||||
const errorNodeIds = useMemo(
|
||||
() => new Set(
|
||||
diagnostics
|
||||
.filter((item) => item.severity === "error" && item.node_id)
|
||||
.map((item) => item.node_id as string)
|
||||
),
|
||||
[diagnostics]
|
||||
);
|
||||
const nodes = useMemo<WorkflowFlowNode[]>(
|
||||
() => graph.nodes.flatMap((node) => {
|
||||
const definition = definitions.get(node.type);
|
||||
if (!definition) return [];
|
||||
return [{
|
||||
id: node.id,
|
||||
type: "workflow" as const,
|
||||
position: node.position,
|
||||
initialWidth: canvasNodeSize(node, definition).width,
|
||||
initialHeight: canvasNodeSize(node, definition).height,
|
||||
selected: node.id === selectedNodeId,
|
||||
data: {
|
||||
label: node.label,
|
||||
workflowType: node.type,
|
||||
definition,
|
||||
hasError: errorNodeIds.has(node.id)
|
||||
}
|
||||
}];
|
||||
}),
|
||||
[definitions, errorNodeIds, graph.nodes, selectedNodeId]
|
||||
);
|
||||
const edges = useMemo<Edge[]>(
|
||||
() => graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.source_port ?? "output",
|
||||
targetHandle: edge.target_port ?? "input",
|
||||
type: "smoothstep",
|
||||
label: edge.label || undefined,
|
||||
className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`,
|
||||
selected: edge.id === selectedEdgeId,
|
||||
animated: edge.type === "bpmn.messageFlow",
|
||||
markerEnd: edgeMarkerEnd(edge),
|
||||
style: edge.type === "bpmn.association"
|
||||
? { strokeDasharray: "4 4" }
|
||||
: edge.type === "bpmn.messageFlow"
|
||||
? { strokeDasharray: "8 5" }
|
||||
: undefined
|
||||
})),
|
||||
[graph.edges, selectedEdgeId]
|
||||
);
|
||||
|
||||
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
|
||||
const ids = new Set(nextNodes.map((node) => node.id));
|
||||
const movedIds = new Set(
|
||||
nextNodes
|
||||
.filter((flowNode) => {
|
||||
const current = graph.nodes.find((node) => node.id === flowNode.id);
|
||||
return current
|
||||
&& (
|
||||
current.position.x !== flowNode.position.x
|
||||
|| current.position.y !== flowNode.position.y
|
||||
);
|
||||
})
|
||||
.map((node) => node.id)
|
||||
);
|
||||
onGraphChange({
|
||||
...graph,
|
||||
nodes: nextNodes.map((flowNode) => {
|
||||
const current = graph.nodes.find((node) => node.id === flowNode.id);
|
||||
if (!current) throw new Error(`Unknown Workflow node: ${flowNode.id}`);
|
||||
return { ...current, position: flowNode.position };
|
||||
}),
|
||||
edges: graph.edges.filter(
|
||||
(edge) => ids.has(edge.source) && ids.has(edge.target)
|
||||
).map((edge) =>
|
||||
movedIds.has(edge.source) || movedIds.has(edge.target)
|
||||
? { ...edge, waypoints: [] }
|
||||
: edge
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
const updateEdges = (nextEdges: Edge[]) => {
|
||||
onGraphChange({
|
||||
...graph,
|
||||
edges: nextEdges.map((edge) => {
|
||||
const current = graph.edges.find((item) => item.id === edge.id);
|
||||
const endpointsChanged = Boolean(
|
||||
current
|
||||
&& (
|
||||
current.source !== edge.source
|
||||
|| current.target !== edge.target
|
||||
)
|
||||
);
|
||||
return {
|
||||
id: edge.id,
|
||||
type: current?.type ?? "bpmn.sequenceFlow",
|
||||
label: current?.label ?? "",
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
source_port: edge.sourceHandle ?? "outgoing",
|
||||
target_port: edge.targetHandle ?? "incoming",
|
||||
config: structuredClone(current?.config ?? {}),
|
||||
waypoints: endpointsChanged
|
||||
? []
|
||||
: structuredClone(current?.waypoints ?? [])
|
||||
} satisfies WorkflowGraphEdge;
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const isValidConnection = (connection: Connection | Edge): boolean => {
|
||||
if (readOnly || !connection.source || !connection.target) return false;
|
||||
return definitionConnectionError(
|
||||
reconnectingEdgeId.current
|
||||
? {
|
||||
...graph,
|
||||
edges: graph.edges.filter(
|
||||
(edge) => edge.id !== reconnectingEdgeId.current
|
||||
)
|
||||
}
|
||||
: graph,
|
||||
nodeLibrary,
|
||||
{
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourcePort: connection.sourceHandle,
|
||||
targetPort: connection.targetHandle
|
||||
},
|
||||
{ allowCycles: allowsCycles }
|
||||
) === null;
|
||||
};
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
if (readOnly || !instance) return;
|
||||
const type = event.dataTransfer.getData(
|
||||
"application/x-govoplan-workflow-node"
|
||||
);
|
||||
if (!type) return;
|
||||
const node = newWorkflowNode(
|
||||
type,
|
||||
instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
||||
nodeLibrary
|
||||
);
|
||||
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||
onSelectNode(node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-canvas"
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
event.dataTransfer.types.includes(
|
||||
"application/x-govoplan-workflow-node"
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow<WorkflowFlowNode, Edge>
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onInit={setInstance}
|
||||
onNodesChange={(changes) => {
|
||||
if (readOnly) return;
|
||||
const graphChanges = changes.filter(
|
||||
(change) => change.type !== "dimensions"
|
||||
);
|
||||
if (graphChanges.length) {
|
||||
updateNodes(applyNodeChanges(graphChanges, nodes));
|
||||
}
|
||||
}}
|
||||
onEdgesChange={(changes) => {
|
||||
if (readOnly) return;
|
||||
const selectedChange = changes.find(
|
||||
(change) => change.type === "select" && change.selected
|
||||
);
|
||||
if (selectedChange?.type === "select") {
|
||||
onSelectEdge(selectedChange.id);
|
||||
}
|
||||
const graphChanges = changes.filter(
|
||||
(change) => change.type !== "select"
|
||||
);
|
||||
if (graphChanges.length) {
|
||||
updateEdges(applyEdgeChanges(graphChanges, edges));
|
||||
}
|
||||
}}
|
||||
onConnect={(connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
updateEdges(addEdge({
|
||||
...connection,
|
||||
id: `edge-${crypto.randomUUID()}`,
|
||||
type: "smoothstep"
|
||||
}, edges));
|
||||
}}
|
||||
onReconnect={(oldEdge, connection) => {
|
||||
if (readOnly || !isValidConnection(connection)) return;
|
||||
reconnectSuccessful.current = true;
|
||||
updateEdges(reconnectEdge(
|
||||
oldEdge,
|
||||
connection,
|
||||
edges,
|
||||
{ shouldReplaceId: false }
|
||||
));
|
||||
}}
|
||||
onReconnectStart={(_event, edge) => {
|
||||
reconnectSuccessful.current = false;
|
||||
reconnectingEdgeId.current = edge.id;
|
||||
}}
|
||||
onReconnectEnd={(_event, edge) => {
|
||||
if (!reconnectSuccessful.current && !readOnly) {
|
||||
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
|
||||
onSelectEdge(null);
|
||||
}
|
||||
reconnectSuccessful.current = true;
|
||||
reconnectingEdgeId.current = null;
|
||||
}}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={(_event, node) => {
|
||||
onSelectEdge(null);
|
||||
onSelectNode(node.id);
|
||||
}}
|
||||
onEdgeClick={(_event, edge) => {
|
||||
onSelectNode(null);
|
||||
onSelectEdge(edge.id);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
onSelectNode(null);
|
||||
onSelectEdge(null);
|
||||
}}
|
||||
nodesDraggable={!readOnly}
|
||||
nodesConnectable={!readOnly}
|
||||
edgesReconnectable={!readOnly}
|
||||
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
|
||||
connectionRadius={32}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.8}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeStrokeWidth={2}
|
||||
nodeColor={(node) =>
|
||||
node.data.hasError ? "var(--danger)" : "var(--accent)"
|
||||
}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
{!graph.nodes.length ? (
|
||||
<div className="workflow-canvas-empty">Drop a start node here</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function edgeMarkerEnd(edge: WorkflowGraphEdge) {
|
||||
if (edge.type === "bpmn.sequenceFlow") {
|
||||
return {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "var(--line-dark)"
|
||||
};
|
||||
}
|
||||
if (
|
||||
edge.type === "bpmn.messageFlow"
|
||||
|| edge.type === "bpmn.dataInputAssociation"
|
||||
|| edge.type === "bpmn.dataOutputAssociation"
|
||||
) {
|
||||
return {
|
||||
type: MarkerType.Arrow,
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "var(--line-dark)"
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function canvasNodeSize(
|
||||
node: WorkflowGraphNode,
|
||||
definition: WorkflowNodeType
|
||||
): { width: number; height: number } {
|
||||
const shape = String(definition.metadata?.shape ?? "activity");
|
||||
if (shape.startsWith("event")) return { width: 124, height: 70 };
|
||||
if (shape === "gateway") return { width: 124, height: 82 };
|
||||
if (shape === "participant" || shape === "lane") {
|
||||
return {
|
||||
width: Math.max(240, Math.min(node.size?.width ?? 360, 720)),
|
||||
height: Math.max(100, Math.min(node.size?.height ?? 160, 360))
|
||||
};
|
||||
}
|
||||
if (shape === "group") {
|
||||
return {
|
||||
width: Math.max(220, node.size?.width ?? 300),
|
||||
height: Math.max(120, node.size?.height ?? 180)
|
||||
};
|
||||
}
|
||||
return { width: 190, height: 64 };
|
||||
}
|
||||
|
||||
export function updateWorkflowGraphNode(
|
||||
graph: WorkflowGraph,
|
||||
updatedNode: WorkflowGraphNode
|
||||
): WorkflowGraph {
|
||||
return {
|
||||
...graph,
|
||||
nodes: graph.nodes.map((node) =>
|
||||
node.id === updatedNode.id ? updatedNode : node
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
ReferenceMultiSelect,
|
||||
useViewSurfaces,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
import type {
|
||||
WorkflowGraphEdge,
|
||||
WorkflowGraphNode,
|
||||
WorkflowNodeType
|
||||
} from "../../api/workflow";
|
||||
|
||||
export default function WorkflowInspector({
|
||||
node,
|
||||
edge,
|
||||
nodeLibrary,
|
||||
readOnly,
|
||||
onChange,
|
||||
onDelete,
|
||||
onEdgeChange,
|
||||
onEdgeDelete
|
||||
}: {
|
||||
node: WorkflowGraphNode | null;
|
||||
edge: WorkflowGraphEdge | null;
|
||||
nodeLibrary: WorkflowNodeType[];
|
||||
readOnly: boolean;
|
||||
onChange: (node: WorkflowGraphNode) => void;
|
||||
onDelete: (nodeId: string) => void;
|
||||
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
||||
onEdgeDelete: (edgeId: string) => void;
|
||||
}) {
|
||||
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState("");
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const viewSurfaceProvider = useMemo<ReferenceOptionProvider>(() => {
|
||||
const options = viewSurfaces.map((surface) => ({
|
||||
value: surface.id,
|
||||
label: surface.label,
|
||||
description: `${surface.moduleId} · ${surface.kind}`,
|
||||
searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}`
|
||||
}));
|
||||
const byId = new Map(options.map((option) => [option.value, option]));
|
||||
return {
|
||||
search: async (query, context) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return options
|
||||
.filter((option) => (
|
||||
!normalized
|
||||
|| option.searchText.toLowerCase().includes(normalized)
|
||||
))
|
||||
.slice(0, context.limit);
|
||||
},
|
||||
resolve: async (values) => values
|
||||
.map((value) => byId.get(value))
|
||||
.filter((option): option is (typeof options)[number] => Boolean(option))
|
||||
};
|
||||
}, [viewSurfaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
setJsonDrafts({});
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
const definition = nodeLibrary.find((item) => item.type === node.type);
|
||||
setJsonDrafts(Object.fromEntries(
|
||||
(definition?.config_fields ?? [])
|
||||
.filter((field) => field.kind === "mapping")
|
||||
.map((field) => [
|
||||
field.id,
|
||||
JSON.stringify(node.config[field.id] ?? {}, null, 2)
|
||||
])
|
||||
));
|
||||
setError("");
|
||||
}, [node?.id, nodeLibrary]);
|
||||
|
||||
if (edge) {
|
||||
const updateEdgeConfig = (field: string, value: unknown) => {
|
||||
onEdgeChange({
|
||||
...edge,
|
||||
config: { ...edge.config, [field]: value }
|
||||
});
|
||||
};
|
||||
return (
|
||||
<aside className="workflow-inspector" aria-label="Flow inspector">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Flow</strong>
|
||||
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="workflow-inspector-delete"
|
||||
onClick={() => onEdgeDelete(edge.id)}
|
||||
disabled={readOnly}
|
||||
aria-label="Delete flow"
|
||||
title="Delete flow"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="workflow-inspector-fields">
|
||||
<FormField label="Flow type">
|
||||
<select
|
||||
value={edge.type}
|
||||
onChange={(event) => onEdgeChange({
|
||||
...edge,
|
||||
type: event.target.value as WorkflowGraphEdge["type"]
|
||||
})}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="bpmn.sequenceFlow">Sequence flow</option>
|
||||
<option value="bpmn.messageFlow">Message flow</option>
|
||||
<option value="bpmn.association">Association</option>
|
||||
<option value="bpmn.dataInputAssociation">
|
||||
Data input association
|
||||
</option>
|
||||
<option value="bpmn.dataOutputAssociation">
|
||||
Data output association
|
||||
</option>
|
||||
<option value="bpmn.conversationLink">
|
||||
Conversation link
|
||||
</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<input
|
||||
value={edge.label}
|
||||
onChange={(event) => onEdgeChange({
|
||||
...edge,
|
||||
label: event.target.value
|
||||
})}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{edge.type === "bpmn.sequenceFlow" ? (
|
||||
<>
|
||||
<FormField
|
||||
label="Condition"
|
||||
help="A constrained expression evaluated when this flow is reached."
|
||||
>
|
||||
<textarea
|
||||
value={textValue(edge.config.condition)}
|
||||
onChange={(event) =>
|
||||
updateEdgeConfig("condition", event.target.value)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Runtime outcome"
|
||||
help="Optional GovOPlaN task outcome mapped to this sequence flow."
|
||||
>
|
||||
<input
|
||||
value={textValue(edge.config.outcome)}
|
||||
onChange={(event) =>
|
||||
updateEdgeConfig("outcome", event.target.value)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
<label className="workflow-inspector-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edge.config.default === true}
|
||||
onChange={(event) =>
|
||||
updateEdgeConfig("default", event.target.checked)
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
Default flow
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
|
||||
<div className="workflow-inspector-empty">No node selected</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const definition = nodeLibrary.find((item) => item.type === node.type);
|
||||
const updateConfig = (field: string, value: unknown) => {
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, [field]: value }
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Inspector</strong>
|
||||
<small>{definition?.label ?? node.type}</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="workflow-inspector-delete"
|
||||
onClick={() => onDelete(node.id)}
|
||||
disabled={readOnly}
|
||||
aria-label="Delete node"
|
||||
title="Delete node"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="workflow-inspector-fields">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<FormField label="Name">
|
||||
<input
|
||||
value={node.label}
|
||||
onChange={(event) =>
|
||||
onChange({ ...node, label: event.target.value })
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{(definition?.config_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
label={`${field.label}${field.required ? " *" : ""}`}
|
||||
help={field.description ?? undefined}
|
||||
>
|
||||
{field.kind === "select" ? (
|
||||
<select
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Choose</option>
|
||||
{field.options.map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "textarea" || field.kind === "expression" ? (
|
||||
<textarea
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : field.kind === "mapping" ? (
|
||||
<textarea
|
||||
className="workflow-json-editor"
|
||||
value={jsonDrafts[field.id] ?? "{}"}
|
||||
onChange={(event) => setJsonDrafts((current) => ({
|
||||
...current,
|
||||
[field.id]: event.target.value
|
||||
}))}
|
||||
onBlur={() => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
jsonDrafts[field.id] ?? "{}"
|
||||
);
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error(`${field.label} must be a JSON object.`);
|
||||
}
|
||||
setError("");
|
||||
updateConfig(field.id, parsed);
|
||||
} catch (parseError) {
|
||||
setError(
|
||||
parseError instanceof Error
|
||||
? parseError.message
|
||||
: `${field.label} is invalid.`
|
||||
);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : field.kind === "string_list" ? (
|
||||
<input
|
||||
value={stringList(node.config[field.id]).join(", ")}
|
||||
onChange={(event) => updateConfig(
|
||||
field.id,
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : field.kind === "view_surfaces" ? (
|
||||
<ReferenceMultiSelect
|
||||
values={stringList(node.config[field.id])}
|
||||
onChange={(values) => updateConfig(field.id, values)}
|
||||
provider={viewSurfaceProvider}
|
||||
aria-label={field.label}
|
||||
placeholder="Add a visible surface"
|
||||
searchPlaceholder="Search modules and interface surfaces"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Asterisk,
|
||||
BadgeDollarSign,
|
||||
BoxSelect,
|
||||
Braces,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleDotDashed,
|
||||
CirclePlus,
|
||||
CircleStop,
|
||||
Cog,
|
||||
Database,
|
||||
Diamond,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileCode2,
|
||||
GitBranch,
|
||||
Hand,
|
||||
Inbox,
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
RadioTower,
|
||||
RectangleHorizontal,
|
||||
Rows3,
|
||||
Scale,
|
||||
Send,
|
||||
Shuffle,
|
||||
Square,
|
||||
TextQuote,
|
||||
UserRoundCheck,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
CirclePlay,
|
||||
CircleX,
|
||||
ClipboardCheck,
|
||||
GitFork,
|
||||
PlugZap,
|
||||
Radio,
|
||||
Split,
|
||||
SquareCheckBig,
|
||||
Timer,
|
||||
Waypoints,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type NodeProps
|
||||
} from "@xyflow/react";
|
||||
import type { WorkflowNodeType } from "../../api/workflow";
|
||||
|
||||
export type WorkflowFlowNodeData = {
|
||||
label: string;
|
||||
workflowType: string;
|
||||
definition: WorkflowNodeType;
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
|
||||
|
||||
const iconByName: Record<string, LucideIcon> = {
|
||||
"calendar-clock": CalendarClock,
|
||||
"circle-check-big": CheckCircle2,
|
||||
"circle-play": CirclePlay,
|
||||
"circle-x": CircleX,
|
||||
"clipboard-check": ClipboardCheck,
|
||||
"plug-zap": PlugZap,
|
||||
radio: Radio,
|
||||
split: Split,
|
||||
"square-check-big": SquareCheckBig,
|
||||
timer: Timer,
|
||||
waypoints: Waypoints,
|
||||
asterisk: Asterisk,
|
||||
"badge-dollar-sign": BadgeDollarSign,
|
||||
"box-select": BoxSelect,
|
||||
braces: Braces,
|
||||
"circle-dashed": CircleDashed,
|
||||
"circle-dot": CircleDot,
|
||||
"circle-dot-dashed": CircleDotDashed,
|
||||
"circle-plus": CirclePlus,
|
||||
"circle-stop": CircleStop,
|
||||
cog: Cog,
|
||||
database: Database,
|
||||
diamond: Diamond,
|
||||
"external-link": ExternalLink,
|
||||
file: File,
|
||||
"file-code-2": FileCode2,
|
||||
"git-branch": GitBranch,
|
||||
hand: Hand,
|
||||
inbox: Inbox,
|
||||
"messages-square": MessagesSquare,
|
||||
plus: Plus,
|
||||
"radio-tower": RadioTower,
|
||||
"rectangle-horizontal": RectangleHorizontal,
|
||||
"rows-3": Rows3,
|
||||
scale: Scale,
|
||||
send: Send,
|
||||
shuffle: Shuffle,
|
||||
square: Square,
|
||||
"text-quote": TextQuote,
|
||||
"user-round-check": UserRoundCheck
|
||||
};
|
||||
|
||||
export default function WorkflowNode({
|
||||
data,
|
||||
selected,
|
||||
isConnectable
|
||||
}: NodeProps<WorkflowFlowNode>) {
|
||||
const Icon = iconByName[data.definition.icon] ?? GitFork;
|
||||
const shape = String(data.definition.metadata?.shape ?? "activity");
|
||||
const inputPorts = data.definition.input_ports;
|
||||
const outputPorts = data.definition.output_ports;
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"workflow-node",
|
||||
`workflow-node-${data.definition.category}`,
|
||||
`workflow-node-shape-${shape}`,
|
||||
selected ? "is-selected" : "",
|
||||
data.hasError ? "has-error" : ""
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{inputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="workflow-node-handle"
|
||||
style={{ top: portPosition(index, inputPorts.length) }}
|
||||
isConnectable={isConnectable}
|
||||
title={port.label}
|
||||
/>
|
||||
))}
|
||||
<span className="workflow-node-icon"><Icon size={17} /></span>
|
||||
<span className="workflow-node-copy">
|
||||
<strong>{data.label}</strong>
|
||||
<small>{data.definition.label}</small>
|
||||
</span>
|
||||
{outputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="workflow-node-handle"
|
||||
style={{ top: portPosition(index, outputPorts.length) }}
|
||||
isConnectable={isConnectable}
|
||||
title={port.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function portPosition(index: number, count: number): string {
|
||||
return `${((index + 1) / (count + 1)) * 100}%`;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings,
|
||||
type DashboardWidgetConfiguration
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listWorkflowInstances,
|
||||
type WorkflowInstance,
|
||||
type WorkflowInstanceStep
|
||||
} from "../../api/workflow";
|
||||
|
||||
export default function WorkflowOpenWorkWidget({
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
}) {
|
||||
const maxItems = numberSetting(configuration.maxItems, 6, 1, 20);
|
||||
const includeRunning = configuration.includeRunning !== false;
|
||||
const load = useCallback(async () => {
|
||||
const instances = await listWorkflowInstances(settings);
|
||||
return instances
|
||||
.filter((instance) =>
|
||||
instance.status === "waiting"
|
||||
|| (includeRunning && instance.status === "running")
|
||||
)
|
||||
.sort(compareOpenWork)
|
||||
.slice(0, maxItems);
|
||||
}, [includeRunning, maxItems, settings]);
|
||||
const { data: instances, loading, error } = useDashboardWidgetData(
|
||||
load,
|
||||
refreshKey
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="Loading open workflow work">
|
||||
{error && (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<DashboardWidgetList
|
||||
emptyText="No workflow work is currently open."
|
||||
items={(instances ?? []).map((instance) => {
|
||||
const step = currentStep(instance);
|
||||
return {
|
||||
id: instance.id,
|
||||
title: instance.definition_name,
|
||||
detail: handoffTitle(step),
|
||||
meta: updatedLabel(instance.updated_at),
|
||||
leading: <ListChecks size={17} aria-hidden="true" />,
|
||||
trailing: (
|
||||
<StatusBadge
|
||||
status={instance.status}
|
||||
label={instance.status === "waiting" ? "Waiting" : "Running"}
|
||||
/>
|
||||
),
|
||||
to: workflowRunUrl(instance)
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/workflow">
|
||||
Open Workflow
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function currentStep(
|
||||
instance: WorkflowInstance
|
||||
): WorkflowInstanceStep | null {
|
||||
return instance.steps.find(
|
||||
(step) => step.id === instance.current_step_id
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function handoffTitle(step: WorkflowInstanceStep | null): string {
|
||||
const title = step?.handoff.title;
|
||||
if (typeof title === "string" && title.trim()) return title;
|
||||
if (!step) return "Preparing next step";
|
||||
return step.node_type
|
||||
.replace(/^workflow\./, "")
|
||||
.split(".")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function workflowRunUrl(instance: WorkflowInstance): string {
|
||||
const query = new URLSearchParams({
|
||||
definition: instance.definition_id,
|
||||
run: instance.id
|
||||
});
|
||||
return `/workflow?${query.toString()}`;
|
||||
}
|
||||
|
||||
function compareOpenWork(
|
||||
left: WorkflowInstance,
|
||||
right: WorkflowInstance
|
||||
): number {
|
||||
if (left.status !== right.status) {
|
||||
return left.status === "waiting" ? -1 : 1;
|
||||
}
|
||||
return (
|
||||
new Date(right.updated_at).getTime()
|
||||
- new Date(left.updated_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
function updatedLabel(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function numberSetting(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number
|
||||
): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric)
|
||||
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||
: fallback;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,595 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Circle,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Play,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
StageRail,
|
||||
StatusBadge,
|
||||
dispatchWorkflowViewChanged,
|
||||
usePlatformUiCapability,
|
||||
type StageRailTone,
|
||||
type ApiSettings,
|
||||
type ViewsRuntimeUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
cancelWorkflowInstance,
|
||||
listWorkflowInstances,
|
||||
reconcileWorkflowInstance,
|
||||
resolveWorkflowStep,
|
||||
startWorkflowInstance,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowInstance,
|
||||
type WorkflowInstanceStep
|
||||
} from "../../api/workflow";
|
||||
|
||||
type WorkflowAction =
|
||||
| "complete"
|
||||
| "approve"
|
||||
| "changes"
|
||||
| "reject"
|
||||
| "resume"
|
||||
| "retry"
|
||||
| "confirm_effect"
|
||||
| "confirm_absent"
|
||||
| "cancel";
|
||||
|
||||
export default function WorkflowRunsDialog({
|
||||
open,
|
||||
settings,
|
||||
definition,
|
||||
initialInstanceId,
|
||||
canStart,
|
||||
canTransition,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
definition: WorkflowDefinition | null;
|
||||
initialInstanceId?: string | null;
|
||||
canStart: boolean;
|
||||
canTransition: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [instances, setInstances] = useState<WorkflowInstance[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [evidence, setEvidence] = useState("");
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
|
||||
"views.runtime"
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
|
||||
[instances, selectedId]
|
||||
);
|
||||
const currentStep = useMemo(
|
||||
() => currentInstanceStep(selected),
|
||||
[selected]
|
||||
);
|
||||
const allowedActions = useMemo(
|
||||
() => handoffActions(currentStep),
|
||||
[currentStep]
|
||||
);
|
||||
|
||||
const mergeInstance = useCallback((instance: WorkflowInstance) => {
|
||||
setInstances((current) => [
|
||||
instance,
|
||||
...current.filter((item) => item.id !== instance.id)
|
||||
]);
|
||||
setSelectedId(instance.id);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!open || !definition?.id) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listWorkflowInstances(settings, definition.id);
|
||||
setInstances(items);
|
||||
setSelectedId((current) => (
|
||||
initialInstanceId
|
||||
&& items.some((item) => item.id === initialInstanceId)
|
||||
? initialInstanceId
|
||||
: items.some((item) => item.id === current)
|
||||
? current
|
||||
: items[0]?.id ?? null
|
||||
));
|
||||
} catch (loadError) {
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [definition?.id, initialInstanceId, open, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setComment("");
|
||||
setEvidence("");
|
||||
void load();
|
||||
}, [load, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selected) return;
|
||||
const context = selected.view_context;
|
||||
if (!context || !viewsRuntime) {
|
||||
dispatchWorkflowViewChanged(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void viewsRuntime.resolveWorkflowView(settings, {
|
||||
viewId: context.view_id,
|
||||
revisionId: context.revision_id,
|
||||
visibleSurfaceIds: context.visible_surface_ids
|
||||
}).then((projection) => {
|
||||
if (!cancelled) {
|
||||
dispatchWorkflowViewChanged(projection, selected.id);
|
||||
}
|
||||
}).catch((viewError) => {
|
||||
if (!cancelled) {
|
||||
dispatchWorkflowViewChanged(null);
|
||||
setError(errorMessage(viewError));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
selected?.id,
|
||||
selected?.updated_at,
|
||||
settings,
|
||||
viewsRuntime
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open
|
||||
|| !canTransition
|
||||
|| !selected
|
||||
|| !currentStep
|
||||
|| currentStep.node_type !== "workflow.dataflow"
|
||||
|| !["queued", "retrying", "running"].includes(
|
||||
String(currentStep.handoff.state ?? "")
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const poll = window.setInterval(() => {
|
||||
void reconcileWorkflowInstance(settings, selected.id)
|
||||
.then((instance) => {
|
||||
if (!stopped) mergeInstance(instance);
|
||||
})
|
||||
.catch((pollError) => {
|
||||
if (!stopped) setError(errorMessage(pollError));
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [
|
||||
canTransition,
|
||||
currentStep,
|
||||
mergeInstance,
|
||||
open,
|
||||
selected,
|
||||
settings
|
||||
]);
|
||||
|
||||
const start = async () => {
|
||||
if (!definition?.id) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const instance = await startWorkflowInstance(settings, definition.id, {
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
input: {}
|
||||
});
|
||||
mergeInstance(instance);
|
||||
} catch (startError) {
|
||||
setError(errorMessage(startError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelected = async () => {
|
||||
if (!selected) {
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const instance = canTransition
|
||||
? await reconcileWorkflowInstance(settings, selected.id)
|
||||
: (await listWorkflowInstances(settings, definition?.id))
|
||||
.find((item) => item.id === selected.id);
|
||||
if (instance) mergeInstance(instance);
|
||||
else await load();
|
||||
} catch (refreshError) {
|
||||
setError(errorMessage(refreshError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performAction = async (action: WorkflowAction) => {
|
||||
if (!selected || !currentStep) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const instance = await resolveWorkflowStep(
|
||||
settings,
|
||||
selected.id,
|
||||
currentStep.id,
|
||||
{
|
||||
action,
|
||||
comment: comment.trim() || null,
|
||||
evidence: evidence
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
);
|
||||
mergeInstance(instance);
|
||||
setComment("");
|
||||
setEvidence("");
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (!selected) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
mergeInstance(await cancelWorkflowInstance(settings, selected.id));
|
||||
setCancelOpen(false);
|
||||
} catch (cancelError) {
|
||||
setError(errorMessage(cancelError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
||||
? currentStep.handoff.action_url
|
||||
: "";
|
||||
const close = () => {
|
||||
dispatchWorkflowViewChanged(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
|
||||
className="workflow-runs-dialog"
|
||||
bodyClassName="workflow-runs-dialog-body"
|
||||
onClose={close}
|
||||
footer={<Button onClick={close}>Close</Button>}
|
||||
>
|
||||
<div className="workflow-runs-toolbar">
|
||||
<span>
|
||||
<strong>Workflow instances</strong>
|
||||
<small>Revision-pinned runs and human handoffs</small>
|
||||
</span>
|
||||
<span>
|
||||
<IconButton
|
||||
label="Refresh runs"
|
||||
icon={<RefreshCw size={16} />}
|
||||
variant="ghost"
|
||||
onClick={() => void refreshSelected()}
|
||||
disabled={loading || working}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void start()}
|
||||
disabled={!canStart || working || definition?.status !== "active"}
|
||||
disabledReason={
|
||||
definition?.status !== "active"
|
||||
? "Activate a definition revision before starting it."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Play size={16} /> Start
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<LoadingFrame loading={loading} className="workflow-runs-frame">
|
||||
<div className="workflow-runs-layout">
|
||||
<div className="workflow-run-list">
|
||||
{instances.map((instance) => (
|
||||
<button
|
||||
key={instance.id}
|
||||
type="button"
|
||||
className={instance.id === selected?.id ? "is-selected" : ""}
|
||||
onClick={() => {
|
||||
setSelectedId(instance.id);
|
||||
setComment("");
|
||||
setEvidence("");
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatDateTime(instance.started_at)}</strong>
|
||||
<small>
|
||||
Revision {instance.definition_revision} · {instance.steps.length} steps
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={instance.status}
|
||||
label={instance.status}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{!instances.length ? (
|
||||
<div className="workflow-run-empty">No runs yet</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="workflow-run-detail">
|
||||
{selected ? (
|
||||
<>
|
||||
<header>
|
||||
<span>
|
||||
<strong>{selected.definition_name}</strong>
|
||||
<small>
|
||||
Revision {selected.definition_revision} · {selected.definition_hash.slice(0, 12)}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<StatusBadge status={selected.status} label={selected.status} />
|
||||
{["running", "waiting"].includes(selected.status) ? (
|
||||
<IconButton
|
||||
label="Cancel workflow run"
|
||||
icon={<XCircle size={16} />}
|
||||
variant="danger"
|
||||
onClick={() => setCancelOpen(true)}
|
||||
disabled={!canTransition || working}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</header>
|
||||
{selected.error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={selected.error}>
|
||||
{selected.error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
{currentStep ? (
|
||||
<section className="workflow-run-handoff">
|
||||
<div>
|
||||
<span>
|
||||
<strong>
|
||||
{String(
|
||||
currentStep.handoff.title
|
||||
?? currentStep.handoff.kind
|
||||
?? currentStep.node_type
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
Step {currentStep.sequence} · attempt {currentStep.attempt}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={String(currentStep.handoff.state ?? currentStep.status)}
|
||||
label={String(currentStep.handoff.state ?? currentStep.status)}
|
||||
/>
|
||||
</div>
|
||||
{typeof currentStep.handoff.message === "string" ? (
|
||||
<p>{currentStep.handoff.message}</p>
|
||||
) : null}
|
||||
{typeof currentStep.handoff.instructions === "string"
|
||||
&& currentStep.handoff.instructions ? (
|
||||
<p>{currentStep.handoff.instructions}</p>
|
||||
) : null}
|
||||
{actionUrl ? (
|
||||
<a href={actionUrl}>
|
||||
Open linked Dataflow result <ExternalLink size={14} />
|
||||
</a>
|
||||
) : null}
|
||||
{allowedActions.some((action) => action !== "cancel") ? (
|
||||
<div className="workflow-run-action-form">
|
||||
<FormField label="Comment">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={2}
|
||||
disabled={!canTransition || working}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Evidence references"
|
||||
help="Enter one durable evidence reference per line."
|
||||
>
|
||||
<textarea
|
||||
value={evidence}
|
||||
onChange={(event) => setEvidence(event.target.value)}
|
||||
rows={2}
|
||||
disabled={!canTransition || working}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="workflow-run-actions">
|
||||
{allowedActions
|
||||
.filter((action) => action !== "cancel")
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
variant={
|
||||
action === "reject"
|
||||
? "danger"
|
||||
: action === "approve"
|
||||
|| action === "complete"
|
||||
|| action === "resume"
|
||||
|| action === "confirm_effect"
|
||||
? "primary"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void performAction(action)}
|
||||
disabled={!canTransition || working}
|
||||
>
|
||||
{action === "retry" ? <RotateCcw size={15} /> : null}
|
||||
{actionLabel(action)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="workflow-run-history">
|
||||
<h3>Progress</h3>
|
||||
<StageRail
|
||||
ariaLabel="Workflow instance progress"
|
||||
items={selected.steps.map((step) => ({
|
||||
id: step.id,
|
||||
label: step.node_id,
|
||||
detail: `${step.node_type} · attempt ${step.attempt}`,
|
||||
statusLabel: step.status,
|
||||
current: step.id === selected.current_step_id,
|
||||
tone: stepTone(step.status),
|
||||
icon: step.status === "completed" ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
) : step.status === "failed" ? (
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
) : ["running", "waiting"].includes(step.status) ? (
|
||||
<Clock3 size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" />
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
<section className="workflow-run-events">
|
||||
<h3>Evidence trail</h3>
|
||||
<div>
|
||||
{[...selected.events].reverse().map((event) => (
|
||||
<span key={event.id}>
|
||||
<strong>{event.kind}</strong>
|
||||
<small>
|
||||
{formatDateTime(event.created_at)}
|
||||
{event.actor_id ? ` · ${event.actor_id}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="workflow-run-empty">
|
||||
Start a run to track its progress here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={cancelOpen}
|
||||
title="Cancel workflow run"
|
||||
message="Cancel this workflow instance and its active Dataflow run?"
|
||||
confirmLabel="Cancel run"
|
||||
tone="danger"
|
||||
busy={working}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
onConfirm={() => void cancel()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function currentInstanceStep(
|
||||
instance: WorkflowInstance | null
|
||||
): WorkflowInstanceStep | null {
|
||||
if (!instance?.current_step_id) return null;
|
||||
return instance.steps.find(
|
||||
(step) => step.id === instance.current_step_id
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function handoffActions(step: WorkflowInstanceStep | null): WorkflowAction[] {
|
||||
const actions = step?.handoff.allowed_actions;
|
||||
if (!Array.isArray(actions)) return [];
|
||||
return actions.filter((action): action is WorkflowAction => (
|
||||
typeof action === "string"
|
||||
&& [
|
||||
"complete",
|
||||
"approve",
|
||||
"changes",
|
||||
"reject",
|
||||
"resume",
|
||||
"retry",
|
||||
"confirm_effect",
|
||||
"confirm_absent",
|
||||
"cancel"
|
||||
].includes(action)
|
||||
));
|
||||
}
|
||||
|
||||
function actionLabel(action: WorkflowAction): string {
|
||||
return {
|
||||
complete: "Complete",
|
||||
approve: "Approve",
|
||||
changes: "Request changes",
|
||||
reject: "Reject",
|
||||
resume: "Resume",
|
||||
retry: "Retry",
|
||||
confirm_effect: "Effect confirmed",
|
||||
confirm_absent: "Effect absent",
|
||||
cancel: "Cancel"
|
||||
}[action];
|
||||
}
|
||||
|
||||
function stepTone(
|
||||
status: WorkflowInstanceStep["status"]
|
||||
): StageRailTone {
|
||||
if (status === "completed") return "success";
|
||||
if (status === "running" || status === "waiting") return "active";
|
||||
if (status === "failed" || status === "cancelled") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "The Workflow request failed.";
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
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