32 Commits
Author SHA1 Message Date
zemion 8e5415dfe7 feat: explain external work hand-offs
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 02:14:35 +02:00
zemion d5214c641c fix: consume Workflow Engine through compatibility facade
Module Package Release / publish-packages (push) Successful in 14s
2026-08-21 16:13:27 +02:00
zemion 0fd744a2db feat: expose workflow semantic documentation subjects 2026-08-21 15:53:44 +02:00
zemion f49ad8eff4 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 244c0ee4c1 feat: align workflow with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion a071692e75 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 55bbd15ecc Adopt shared WebUI layout primitives 2026-08-18 10:42:55 +02:00
zemion 8305bd5c5b Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:51 +02:00
zemion 15956173f6 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:12 +02:00
zemion 46c849e2fa Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:26 +02:00
zemion e344af0b0a Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:21 +02:00
zemion 5f00898bed Make package publication retries hash-safe 2026-08-04 14:32:21 +02:00
zemion db8970adea Harden module package publication 2026-08-04 14:02:42 +02:00
zemion 629f76440b Adopt the shared interface pattern language 2026-08-04 08:21:50 +02:00
zemion 06f93f4f7a Add protected package release workflow 2026-08-04 04:14:08 +02:00
zemion 00fb6b56a9 Restore workflow facade recovery tests 2026-08-03 07:40:53 +02:00
zemion 7936aadd93 Expose workflow effect reconciliation controls 2026-08-03 06:37:45 +02:00
zemion 6e27e72a54 Cover extracted workflow trigger tables 2026-08-01 20:57:26 +02:00
zemion ca32a6ee5c docs: declare institutional architecture boundary 2026-08-01 17:48:41 +02:00
zemion 9d987c7339 Add workflow standard comparison controls 2026-07-31 19:40:08 +02:00
zemion 8fd8753012 refactor: retain workflow as optional editor 2026-07-31 16:59:02 +02:00
zemion 484ac43352 docs: define workflow engine and editor split 2026-07-31 15:07:53 +02:00
zemion f4974b4949 Implement native BPMN workflows and guided modes 2026-07-31 02:48:57 +02:00
zemion c505e81006 feat: add BPMN inspection and workflow progress visuals 2026-07-30 17:42:10 +02:00
zemion a6e0e89829 feat(workflow): orchestrate resumable dataflow handoffs 2026-07-30 03:11:56 +02:00
zemion a1654e70cf perf(workflow): page scope references 2026-07-30 01:30:38 +02:00
zemion c45e2b808c feat: select governed workflow scope targets 2026-07-29 14:32:54 +02:00
zemion b015569b5e Declare Views integration contract 2026-07-28 21:04:55 +02:00
zemion 63e5ce949d Keep workflow nodes initialized 2026-07-28 15:51:38 +02:00
zemion 1ec8336c02 Add governed reusable workflow definitions 2026-07-28 15:04:32 +02:00
zemion 6737b60c11 feat: persist and edit versioned workflow definitions 2026-07-28 13:48:06 +02:00
zemion 85eef00913 feat: add reusable workflow definition graphs 2026-07-28 12:43:26 +02:00
57 changed files with 9649 additions and 34 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Workflow Codex Guide
## Scope
This repository owns the workflow editing and inspection experience, including BPMN-compatible native graph authoring and module-provided definition views.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Workflow internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- `govoplan-workflow-engine` owns headless definitions, versions, execution, and resumable runtime state.
- The editor must consume engine contracts without duplicating execution semantics.
+27 -11
View File
@@ -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
```
+84
View File
@@ -0,0 +1,84 @@
# Native BPMN Graph
GovOPlaN uses BPMN 2.0 as Workflow's canonical graph language while keeping
notation support distinct from executable runtime support.
## Current Contract
- The native Workflow graph stores BPMN element and flow types, process
membership, containment, geometry, properties, and preserved extension
content. There is one editor and one graph representation.
- BPMN XML import maps standard elements and BPMN DI into the native graph.
Export deterministically renders XML and DI from the current graph. The
normalized XML artifact, its hash, and the native profile version are pinned
with every immutable revision.
- No browser-side BPMN modeler is required. The WebUI uses the same graph
surface and shared controls as the rest of GovOPlaN.
- `GET /api/v1/workflow/bpmn/profile` publishes all installed, versioned
conformance profiles.
- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML,
inventories every BPMN model element, detects duplicate IDs and selected
dangling references, and classifies elements as interchange-only, natively
mappable, or natively executable.
- `POST /api/v1/workflow/bpmn/compile` imports a bounded BPMN document into the
canonical native graph.
- `POST /api/v1/workflow/bpmn/render` exports a native graph as normalized BPMN
XML with BPMN DI geometry.
- `GET /api/v1/workflow/definitions/{id}/revisions/{revision}/bpmn` returns
the exact pinned document and its current availability/conformance
assessment.
- XML entities, DTD-based expansion, oversized documents, and malformed roots
are rejected.
Inspection is not XML Schema validation and does not claim that every editable
BPMN construct can be executed. Notation and interchange remain available when
the native runtime cannot activate the document.
## Built-In Profiles
- `govoplan.native.bpmn@1.0.0` is the canonical graph and interchange profile.
It maps the supported BPMN vocabulary into native nodes and edges. Activation
separately validates whether every execution semantic is implemented.
- `govoplan.native.linear@1.0.0` and `bpmn.interchange@1.0.0` remain registered
for historical revision compatibility; new editor revisions use the native
BPMN profile.
Gateways, subprocesses, event definitions, transactions, compensation,
collaboration, and choreography remain editable and exportable even when their
token or lifecycle semantics are not yet implemented.
## Execution Boundary
Adding a BPMN shape is not equivalent to implementing its token semantics,
event subscriptions, compensation, transactions, choreography, or conformance
behavior. Each executable mapping therefore needs:
1. an explicit native semantic mapping;
2. validation rules and lifecycle behavior;
3. resumability and idempotency tests;
4. migration and round-trip fixtures;
5. a declared fallback when the installed runtime cannot execute it.
Unsupported execution constructs remain visible in the native graph, but
activation remains blocked until an execution adapter declares support.
## Adapter Boundary
Adapter packages register through the
`govoplan.workflow.bpmn_adapters` Python entry-point group. Workflow discovers
them without importing a concrete module. An adapter publishes a stable ID,
version, runtime kind, conformance statement, supported elements and event
definitions, operational requirements, validation, and canonical graph
materialization.
Revisions pin the exact adapter version. If that version is unavailable after
an installation change, the document remains readable and exportable but
cannot activate. External-engine adapters must still materialize lifecycle,
handoff, retry, cancellation, and audit evidence through the canonical
Workflow instance contract; a remote engine's private state is not the
platform record.
The conformance fixtures under `tests/fixtures/bpmn` cover processes,
collaboration, choreography, events, transactions, compensation, and data
elements. Every fixture must import and export through the native graph without
losing modeled nodes or flows; activation has its own narrower test matrix.
+40 -23
View File
@@ -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.
+137
View File
@@ -0,0 +1,137 @@
# Workflow Engine And Workflow Editor Split
## Decision
Split the current module into two installable modules:
- `govoplan-workflow-engine` (runtime module ID `workflow_engine`) is the
headless definition and execution platform available to all modules.
- `govoplan-workflow` remains the optional authoring, inspection, catalogue,
diff, override, and reset WebUI.
Other modules depend on Workflow Engine capabilities, never on the Workflow
editor package. Workflow depends on Workflow Engine.
This is a packaging and ownership split, not a second workflow model. BPMN 2.0
and the existing native graph remain the canonical language and use the same
versioned contracts.
## Existing Versioning
Workflow definitions are already versioned:
- `workflow_definitions.current_revision` identifies the latest graph revision.
- `workflow_definitions.active_revision` selects the revision used for new
instances.
- `workflow_definition_revisions` stores immutable graph content, hashes, node
library versions, execution mode, pinned View revision, BPMN XML/hash, and
execution-adapter profile/version.
- Graph changes create a new revision and return an active definition to draft;
metadata-only changes do not create graph revisions.
- Updates use `expected_revision` optimistic concurrency.
- Instances pin `definition_revision_id`; later edits or module upgrades cannot
mutate running or historical instances.
- Derived definitions record source definition, source revision/hash, actor,
Policy decision, scope, and effective ancestor limits.
The missing model is a durable module-owned baseline and local override/reset
relationship.
## Workflow Engine Ownership
Workflow Engine owns:
- definition, immutable revision, instance, step, event, command, timer, and
execution-record persistence and migrations
- definition CRUD/activation/derivation APIs and headless read APIs
- Workflow graph/BPMN schemas, validation, import/export, and conformance
- node-library and execution-adapter registries
- instance start/transition/cancel/retry/reconciliation services
- runtime worker, event/API/schedule/parent dispatch, idempotency, and recovery
- definition governance capability integration and audit/event emission
- configuration-package workflow fragments
- module-contributed standard definition discovery and reconciliation
It contributes no primary navigation or full editor. A module can install and
run its workflows when the editor is absent.
## Workflow Editor Ownership
Workflow owns:
- the Workflow workspace and visual BPMN/graph editor
- definition/revision catalogue, preview, diff, validation, and activation UI
- instance inspection and operator controls built on Engine APIs
- derivation and governed override UX
- module-standard update comparison and reset-to-standard UX
- reusable embedded editor/inspector components for other module surfaces
The editor never owns workflow tables or executes transitions directly.
## Module-Contributed Definitions
Modules announce standard workflows through a versioned Engine contribution
contract or a module-owned configuration-package fragment. A contribution has:
- origin module ID/version, stable definition key, contribution schema version,
and content hash
- graph plus BPMN representation, node-library/profile requirements, and
execution mode
- default scope, start/reuse/automation ceilings, required capabilities, and
Policy metadata
- upgrade compatibility and optional migration diagnostics
Engine reconciles contributions idempotently after module discovery. The
module-provided baseline is immutable. A module update may add a new baseline
revision, but it never rewrites a running instance or silently replaces a local
override.
## Override And Reset
Editing a system/module standard creates a local override derived from a pinned
baseline revision. The UI may present this as editing the effective definition,
but the canonical baseline remains available.
- View is always possible when the caller can read the definition.
- Edit/derive is controlled by Policy and scope ceilings.
- An upstream baseline update is shown as an available update with a three-way
diff; it is not merged silently.
- Reset archives the local override and selects the latest permitted baseline.
- Historical overrides, baselines, and instances remain addressable for audit.
- A tenant/group/user override cannot loosen inherited restrictions.
## Compatibility And Extraction Order
1. Define Engine-owned capability and contribution DTOs in Core-neutral
contracts while preserving existing `workflow.*` interface names.
2. Create `govoplan-workflow-engine` and move backend code, tests, migrations,
and runtime workers without changing table names or API paths.
3. Transfer migration ownership without replaying the existing chain. Test both
upgrades and fresh installs with Engine alone.
4. Keep a compatibility facade in `govoplan-workflow` for one release line;
make it depend on `workflow_engine` and retain only WebUI/editor code.
5. Update Core workers and consuming modules to resolve Engine capabilities.
6. Add module contributions, immutable baselines, override/update/reset, and
configuration-package support.
7. Remove compatibility imports only under the platform compatibility policy.
The extraction must preserve existing definition IDs, revision IDs, active
revision selection, instance foreign keys, idempotency keys, API routes, and
audit references.
## Implemented Boundary
The split is implemented in the `govoplan-workflow-engine` repository. Engine
owns the unchanged migration chain and `/api/v1/workflow` API, retains the
existing `workflow:*` permission namespace through an explicit manifest
compatibility field, and exposes headless runtime and contribution
capabilities. `govoplan-workflow` now has a hard dependency on runtime module
ID `workflow_engine`, contributes only its WebUI/navigation/editor contract,
and keeps one release line of `govoplan_workflow.backend` import facades.
Module manifests can announce versioned workflow baselines. Reconciliation is
idempotent, records module/schema/hash provenance, keeps a newly supplied
baseline revision inactive when an older revision is active, and fails closed
when required capabilities or interfaces are absent. Baselines are immutable;
editing derives a pinned local override, and reset archives that override
without removing revision or instance history.
+30
View File
@@ -0,0 +1,30 @@
# Workflow Interface Pattern Migration
Workflow is the optional visual authoring and inspection surface for the
headless Workflow Engine. It composes native BPMN graph editing, immutable
revision inspection, governed activation, and instance evidence without owning
runtime persistence or importing module-private action implementations.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| `/workflow` definition catalogue | List-detail workspace | Search, loading, empty, selected, current/historical revision, baseline, override, and update-available states retain definition context. |
| Native BPMN graph | Specialized create/edit workspace | Palette nodes can be dragged or added with keyboard activation. Nodes and edges can be selected, edited, reconnected, or removed through explicit controls; drag is not the only path. |
| Definition settings and derivation | Governed create/edit dialogs | Scope, kind, execution mode, inherited visibility, reuse, automation, and immutable View revision expose field help and policy provenance. |
| Validate/save/activate/archive/delete/reset | Review and consequential actions | Validation diagnostics identify graph elements. Save creates a revision; activation changes the runnable revision; archive/delete/reset use explicit state gates and Core confirmation dialogs. |
| Runs dialog and open-work widget | Monitoring, progress, and human decision | Instance/step status, handoff instructions, evidence references, transitions, retries, cancellation, reconciliation, failures, and partial outcomes remain durable Workflow Engine evidence. |
Core owns buttons, icon buttons, dialog/focus behavior, alerts, status, form
help, toggles, selectors, unsaved-navigation protection, and documentation help.
The graph itself is the authorized domain-specific editor. Module standards and
optional Views/Policy integrations are consumed through public capabilities.
Responsive layouts move catalogue, palette, graph, and inspector into task
order; reduced-motion preferences disable editor animation as a source of
meaning.
Verification:
- `npm run typecheck`
- `npm run test:interface-pattern`
- Workflow editor and Workflow Engine backend suites
- manifest shape, optional-module permutations, structural localization, theme,
and full-product bundle-budget checks
+27
View File
@@ -0,0 +1,27 @@
# Workflow Visual Model
The Campaign review flow is the reference for runtime workflow progress:
- a compact stage rail communicates order, current state, completion, warning,
failure, partial progress, and locks;
- the active handoff owns the detailed controls;
- unknown module-action outcomes expose evidence fields plus **Effect
confirmed** and **Effect absent** actions; Retry stays hidden until absence is
verified;
- evidence remains visible without turning every stage into a permanent card;
- unavailable stages stay visibly unavailable while non-blocking optional
stages do not interrupt the connector state.
Workflow now applies that language to instance progress without importing
Campaign code. Once the state vocabulary has stabilized, the rail should move
to Core as a generic process-stage component and Campaign should consume it.
Navigation has three distinct layers:
1. the platform siderail selects a module or focused View;
2. the module workspace selects an object or definition;
3. the workflow stage rail describes progress inside that object.
A focused View or active Workflow may suppress unrelated platform and module
navigation, but must always provide a visible escape back to the normal View.
Modules should not add another persistent navigation tier for workflow stages.
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow"
version = "0.1.20"
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.28",
"govoplan-workflow-engine>=0.1.19",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_workflow = ["py.typed"]
[project.entry-points."govoplan.modules"]
workflow = "govoplan_workflow.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""Optional GovOPlaN Workflow editor and compatibility package."""
__version__ = "0.1.18"
@@ -0,0 +1 @@
"""Compatibility facades for the extracted Workflow Engine backend."""
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn_adapters import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.bpmn_graph import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.db import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.db.models import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.governance import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.instance_service import * # noqa: F401,F403
+301
View File
@@ -0,0 +1,301 @@
from __future__ import annotations
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
ProductAreaContribution,
ViewSurface,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
from govoplan_workflow.backend.semantic_subjects import (
WorkflowSemanticDocumentationSubjectProvider,
)
from govoplan_workflow_engine.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
INSTANCE_READ_SCOPE,
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
)
MODULE_ID = "workflow"
MODULE_NAME = "Workflow"
MODULE_VERSION = "0.1.20"
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
def _semantic_subjects(
context: ModuleContext,
) -> WorkflowSemanticDocumentationSubjectProvider:
return WorkflowSemanticDocumentationSubjectProvider(context.registry)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("workflow_engine",),
optional_dependencies=("docs",),
provides_interfaces=(
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
ModuleInterfaceProvider(
name=SEMANTIC_SUBJECT_CAPABILITY,
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
),
),
capability_factories={
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
},
capability_documentation={
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
label="Workflow semantic-documentation subjects",
summary=(
"Lists currently authorized workflow definitions and stable "
"step lineages with documentation-safe review fingerprints."
),
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
documentation_types=("admin", "user"),
),
},
requires_interfaces=(
ModuleInterfaceRequirement(
name="workflow.definition_graph",
version_min="0.1.0",
version_max_exclusive="1.0.0",
),
ModuleInterfaceRequirement(
name="workflow.definition_catalogue",
version_min="0.1.0",
version_max_exclusive="1.0.0",
),
ModuleInterfaceRequirement(
name="workflow.bpmn_interchange",
version_min="1.0.0",
version_max_exclusive="2.0.0",
),
),
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/workflow-webui",
routes=(
FrontendRoute(
path="/workflow",
component="WorkflowPage",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("workflow.nav.workflow", "workflow.route.workflow"),
order=10,
),
),
view_surfaces=(
ViewSurface(
id="workflow.widget.open-work",
module_id=MODULE_ID,
kind="section",
label="Open workflow work widget",
order=76,
),
),
),
documentation=(
DocumentationTopic(
id="workflow.external-handoff-inspection",
title="Inspect and return to external work hand-offs",
summary=(
"Follow the exact linked module work while Workflow retains "
"its durable position and immutable reference."
),
body=(
"The runs dialog shows the current external hand-off state, exact "
"revision reference, safe linked-work action, timeout, and the "
"immutable Workflow evidence trail. Leaving the dialog or linked "
"Campaign page does not complete the step. Declared Campaign events "
"drive acceptance, reassignment, completion, rejection, and "
"cancellation without browser polling. A configured focused View "
"narrows the available surfaces while the step is active. If Views "
"is absent, the linked Campaign action remains available and the "
"dialog explains the reduced presentation. Missing optional Tasks or "
"Notifications is also shown without changing the hand-off outcome."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "workflow_designer", "campaign_manager"),
related_modules=(
"workflow_engine",
"campaigns",
"views",
"tasks",
"notifications",
),
conditions=(
DocumentationCondition(
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
),
),
metadata={
"kind": "workflow",
"help_contexts": [
"workflow.external-handoff",
"workflow.instances",
],
},
order=74,
),
DocumentationTopic(
id="workflow.semantic-documentation",
title="Document configured workflow and step meaning",
summary=(
"Attach tenant-owned semantic guidance to an authorized workflow "
"definition or stable step without changing execution."
),
body=(
"When Docs is installed, Workflow supplies documentation-safe "
"subjects for each definition the current actor may view and for "
"each step in its current immutable revision. Step identity survives "
"label and canvas-position changes. Removing and later recreating a "
"step with the same graph ID creates a new lineage, leaving prior "
"documentation explicitly orphaned. Relevant changes to step type, "
"label, hierarchy, safe configuration, or connected flow semantics "
"change the review fingerprint; layout-only movement does not. "
"Semantic prose can explain purpose, non-purpose, entry and exit "
"conditions, handoffs, exceptions, ownership, stewardship, and "
"examples, but it cannot alter validation, activation, policy, or "
"Workflow Engine execution. Workflow rechecks tenant scope and the "
"current per-definition governance decision for discovery and direct "
"resolution. If Docs is absent, editing and static help continue."
),
layer="configured",
documentation_types=("admin", "user"),
audience=(
"user",
"workflow_designer",
"process_owner",
"module_admin",
),
related_modules=("docs", "workflow_engine", "policy"),
links=(
DocumentationLink(
label="Semantic documentation authoring",
href="/docs/semantic",
kind="runtime",
),
DocumentationLink(
label="Workflow editor and engine boundary",
href="govoplan-workflow/docs/ENGINE_EDITOR_SPLIT.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"help_contexts": ["workflow.semantic-documentation"],
},
order=75,
),
DocumentationTopic(
id="workflow.editor",
title="Workflow editor and inspection workspace",
summary=(
"Optional authoring, validation, revision inspection, and "
"operator controls for Workflow Engine."
),
body=(
"Workflow adds the visual BPMN/native graph editor, definition "
"catalogue, immutable revision comparison, activation controls, "
"instance inspection, and governed override/reset experience. "
"Definitions and instances remain owned and executed by the "
"headless Workflow Engine module."
),
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.",
"Semantic documentation requires Docs for tenant-authored content; static Workflow help remains available without it.",
),
owned_concepts=(
"workflow editing surface",
"workflow revision inspection",
"workflow activation controls",
"workflow semantic documentation subjects",
),
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
security_docs=("docs/CONCEPT.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ADMIN_SCOPE",
"DEFINITION_READ_SCOPE",
"DEFINITION_WRITE_SCOPE",
"INSTANCE_READ_SCOPE",
"INSTANCE_START_SCOPE",
"INSTANCE_TRANSITION_SCOPE",
"MODULE_ID",
"MODULE_VERSION",
"get_manifest",
"manifest",
]
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.node_library import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.router import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.runtime import * # noqa: F401,F403
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.schemas import * # noqa: F401,F403
@@ -0,0 +1,428 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import quote
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
SemanticDocumentationBreadcrumb,
SemanticDocumentationSubjectAnchor,
SemanticDocumentationSubjectDescriptor,
SemanticDocumentationSubjectPage,
SemanticDocumentationSubjectQuery,
SemanticDocumentationSubjectReference,
SemanticDocumentationSubjectResolution,
semantic_documentation_fingerprint,
)
from govoplan_core.security.redaction import redact_secret_values
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.governance import definition_decision
from govoplan_workflow.backend.service import (
get_definition_revision,
list_definition_revisions,
list_definitions,
)
SUBJECT_KIND = "workflow_definition"
ADMIN_SCOPE = "workflow:instance:admin"
DEFINITION_READ_SCOPE = "workflow:definition:read"
_MAX_SUBJECTS = 20_000
@dataclass(frozen=True, slots=True)
class _LineageState:
node_ids: Mapping[str, str]
historical_node_ids: frozenset[str]
class WorkflowSemanticDocumentationSubjectProvider:
provider_id = "workflow.semantic_subjects"
module_id = "workflow"
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
def __init__(self, registry: object | None) -> None:
self._registry = registry
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage:
actor = _principal(principal, tenant_id=request.tenant_id)
if actor is None:
return SemanticDocumentationSubjectPage()
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
return SemanticDocumentationSubjectPage()
db = _session(session)
subjects: list[SemanticDocumentationSubjectDescriptor] = []
for definition in list_definitions(db, tenant_id=request.tenant_id):
if not self._can_view(definition, actor):
continue
revision = get_definition_revision(db, definition=definition)
lineage = _lineage_state(db, definition)
subjects.extend(
_descriptors(
definition,
revision,
lineage,
tenant_id=request.tenant_id,
)
)
if len(subjects) > _MAX_SUBJECTS:
raise ValueError(
"Workflow semantic subject limit exceeded; narrow the query."
)
query = request.query.casefold().strip()
if query:
subjects = [
item
for item in subjects
if query in _search_text(item).casefold()
]
offset = _cursor_offset(request.cursor)
selected = tuple(subjects[offset : offset + request.limit])
next_offset = offset + len(selected)
has_more = next_offset < len(subjects)
return SemanticDocumentationSubjectPage(
subjects=selected,
next_cursor=str(next_offset) if has_more else None,
has_more=has_more,
)
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
actor = _principal(principal, tenant_id=reference.tenant_id)
if (
reference.module_id != self.module_id
or reference.subject_kind != SUBJECT_KIND
or actor is None
):
return None
db = _session(session)
definition = db.scalar(
select(WorkflowDefinition).where(
WorkflowDefinition.id == reference.subject_id,
or_(
WorkflowDefinition.tenant_id == reference.tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
)
)
if definition is None or not self._can_view(definition, actor):
return None
if definition.deleted_at is not None:
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code="definition_deleted",
)
revision = get_definition_revision(db, definition=definition)
lineage = _lineage_state(db, definition)
descriptor = next(
(
item
for item in _descriptors(
definition,
revision,
lineage,
tenant_id=reference.tenant_id,
)
if item.reference.stable_key == reference.stable_key
),
None,
)
if descriptor is None:
anchor = reference.anchor
reason = "subject_missing"
if anchor is not None and anchor.kind == "step":
reason = (
"step_deleted"
if anchor.id in lineage.historical_node_ids
else "step_missing"
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code=reason,
)
changed = any(
expected is not None and expected != actual
for expected, actual in (
(reference.observed_revision, descriptor.reference.observed_revision),
(
reference.observed_fingerprint,
descriptor.reference.observed_fingerprint,
),
)
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="changed" if changed else "available",
subject=descriptor,
)
def _can_view(
self,
definition: WorkflowDefinition,
principal: ApiPrincipal,
) -> bool:
return definition_decision(
definition,
principal=principal,
registry=self._registry,
action="view",
).allowed
def _descriptors(
definition: WorkflowDefinition,
revision: WorkflowDefinitionRevision,
lineage: _LineageState,
*,
tenant_id: str,
) -> tuple[SemanticDocumentationSubjectDescriptor, ...]:
route = f"/workflow?definition={quote(definition.id, safe='')}"
definition_fingerprint = semantic_documentation_fingerprint(
{
"revision": definition.current_revision,
"content_hash": revision.content_hash,
"name": definition.name,
"description": definition.description,
"status": definition.status,
"active_revision": definition.active_revision,
"scope_type": definition.scope_type,
"definition_kind": definition.definition_kind,
"allow_start": definition.allow_start,
"allow_reuse": definition.allow_reuse,
"allow_automation": definition.allow_automation,
}
)
result = [
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
tenant_id=tenant_id,
revision=str(definition.current_revision),
fingerprint=definition_fingerprint,
),
labels={"en": definition.name},
descriptions=(
{"en": definition.description} if definition.description else {}
),
route=route,
# The provider already applies the engine's READ-or-ADMIN and
# per-definition governance decision. The descriptor contract
# represents an AND-only scope list, so it cannot restate that
# disjunction without incorrectly excluding administrators.
required_scopes=(),
)
]
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
nodes = tuple(
item for item in graph.get("nodes", ()) if isinstance(item, Mapping)
)
edges = tuple(
item for item in graph.get("edges", ()) if isinstance(item, Mapping)
)
node_by_id = {str(item.get("id")): item for item in nodes if item.get("id")}
for node_id, node in node_by_id.items():
identity = lineage.node_ids[node_id]
fingerprint = _node_fingerprint(node, edges)
label = str(node.get("label") or node.get("type") or node_id)[:300]
breadcrumbs = [
SemanticDocumentationBreadcrumb(
label=definition.name,
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
)
]
parent_id = str(node.get("parent_id") or "")
parent = node_by_id.get(parent_id)
if parent is not None and parent_id in lineage.node_ids:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=str(parent.get("label") or parent_id)[:300],
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
anchor=SemanticDocumentationSubjectAnchor(
kind="step",
id=lineage.node_ids[parent_id],
),
)
)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
tenant_id=tenant_id,
anchor=SemanticDocumentationSubjectAnchor(
kind="step", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels={"en": label},
descriptions={
"en": f"Configured {str(node.get('type') or 'workflow step')[:200]} step."
},
breadcrumbs=tuple(breadcrumbs),
route=route,
route_anchor=_route_anchor(node_id),
required_scopes=(),
)
)
return tuple(result)
def _reference(
definition: WorkflowDefinition,
*,
tenant_id: str,
revision: str,
fingerprint: str,
anchor: SemanticDocumentationSubjectAnchor | None = None,
) -> SemanticDocumentationSubjectReference:
return SemanticDocumentationSubjectReference(
module_id="workflow",
tenant_id=tenant_id,
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
anchor=anchor,
observed_revision=revision,
observed_fingerprint=fingerprint,
)
def _lineage_state(
session: Session,
definition: WorkflowDefinition,
) -> _LineageState:
revisions = sorted(
list_definition_revisions(session, definition=definition),
key=lambda item: item.revision,
)
active: dict[str, str] = {}
historical: set[str] = set()
for revision in revisions:
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
node_ids = {
str(item.get("id"))
for item in graph.get("nodes", ())
if isinstance(item, Mapping) and item.get("id")
}
active = {key: value for key, value in active.items() if key in node_ids}
for node_id in sorted(node_ids):
active.setdefault(node_id, _lineage_id(revision.id, node_id))
historical.add(active[node_id])
return _LineageState(
node_ids=active,
historical_node_ids=frozenset(historical),
)
def _node_fingerprint(
node: Mapping[str, object],
edges: tuple[Mapping[str, object], ...],
) -> str:
node_id = str(node.get("id") or "")
connected = [
{
"id": edge.get("id"),
"type": edge.get("type"),
"label": edge.get("label"),
"source": edge.get("source"),
"target": edge.get("target"),
"source_port": edge.get("source_port"),
"target_port": edge.get("target_port"),
"config": redact_secret_values(edge.get("config") or {}),
}
for edge in edges
if node_id in {str(edge.get("source") or ""), str(edge.get("target") or "")}
]
connected.sort(key=lambda item: str(item["id"] or ""))
return semantic_documentation_fingerprint(
{
"type": node.get("type"),
"label": node.get("label"),
"parent_id": node.get("parent_id"),
"process_id": node.get("process_id"),
"config": redact_secret_values(node.get("config") or {}),
"connections": connected,
}
)
def _lineage_id(revision_id: str, node_id: str) -> str:
digest = hashlib.sha256(f"{revision_id}\x1f{node_id}".encode()).hexdigest()
return f"step-{digest[:40]}"
def _route_anchor(node_id: str) -> str:
route_anchor = f"workflow-node-{node_id}"
if len(route_anchor) <= 255:
return route_anchor
digest = hashlib.sha256(node_id.encode()).hexdigest()
return f"workflow-node-{digest[:40]}"
def _search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
return " ".join(
(
item.reference.subject_id,
*item.labels.values(),
*item.descriptions.values(),
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
)
)
def _principal(
principal: object,
*,
tenant_id: str,
) -> ApiPrincipal | None:
if not isinstance(principal, ApiPrincipal) or principal.tenant_id != tenant_id:
return None
if not (
principal.has(DEFINITION_READ_SCOPE)
or principal.has(ADMIN_SCOPE)
):
return None
return principal
def _cursor_offset(value: str | None) -> int:
if value is None:
return 0
if not value.isdigit() or int(value) < 0:
raise ValueError("Workflow semantic subject cursor is invalid.")
return int(value)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Workflow semantic subjects require a SQLAlchemy session.")
return value
__all__ = [
"SUBJECT_KIND",
"WorkflowSemanticDocumentationSubjectProvider",
]
+3
View File
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.service import * # noqa: F401,F403
@@ -0,0 +1,3 @@
"""Compatibility facade for the extracted Workflow Engine backend."""
from govoplan_workflow_engine.backend.validation import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
"""Workflow test package for both discovery and targeted module execution."""
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Choreography"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Approval" name="Approval" />
<bpmn:collaboration id="Collaboration_Choreography">
<bpmn:participant id="Participant_Applicant" name="Applicant" />
<bpmn:participant id="Participant_Authority" name="Authority" />
</bpmn:collaboration>
<bpmn:choreography id="Choreography_1" name="Permit decision">
<bpmn:startEvent id="Choreography_Start" />
<bpmn:choreographyTask
id="Choreography_Task"
initiatingParticipantRef="Participant_Authority">
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
</bpmn:choreographyTask>
<bpmn:endEvent id="Choreography_End" />
<bpmn:sequenceFlow
id="Choreography_Flow_1"
sourceRef="Choreography_Start"
targetRef="Choreography_Task" />
<bpmn:sequenceFlow
id="Choreography_Flow_2"
sourceRef="Choreography_Task"
targetRef="Choreography_End" />
</bpmn:choreography>
<bpmn:messageFlow
id="MessageFlow_Approval"
sourceRef="Participant_Authority"
targetRef="Participant_Applicant"
messageRef="Message_Approval" />
</bpmn:definitions>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Collaboration"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Request" name="Request" />
<bpmn:process id="Process_Requester">
<bpmn:startEvent id="Requester_Start" />
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
</bpmn:process>
<bpmn:process id="Process_Reviewer">
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
<bpmn:endEvent id="Reviewer_End" />
</bpmn:process>
<bpmn:collaboration id="Collaboration_1">
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
<bpmn:messageFlow
id="MessageFlow_1"
sourceRef="Send_Request"
targetRef="Receive_Request"
messageRef="Message_Request" />
</bpmn:collaboration>
</bpmn:definitions>
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Control_Flow"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:signal id="Signal_Escalation" name="Escalation" />
<bpmn:process id="Called_Process" isExecutable="true">
<bpmn:startEvent id="Called_Start" />
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
<bpmn:endEvent id="Called_End" />
<bpmn:sequenceFlow
id="Called_Flow_1"
sourceRef="Called_Start"
targetRef="Called_Human_Task" />
<bpmn:sequenceFlow
id="Called_Flow_2"
sourceRef="Called_Human_Task"
targetRef="Called_End" />
</bpmn:process>
<bpmn:process id="Control_Process" isExecutable="true">
<bpmn:startEvent id="Control_Start" />
<bpmn:exclusiveGateway id="Control_Decision" />
<bpmn:subProcess id="Review_Subprocess" name="Review">
<bpmn:startEvent id="Subprocess_Start" />
<bpmn:userTask id="Subprocess_Review" name="Review request" />
<bpmn:endEvent id="Subprocess_End" />
<bpmn:sequenceFlow
id="Subprocess_Flow_1"
sourceRef="Subprocess_Start"
targetRef="Subprocess_Review" />
<bpmn:sequenceFlow
id="Subprocess_Flow_2"
sourceRef="Subprocess_Review"
targetRef="Subprocess_End" />
</bpmn:subProcess>
<bpmn:boundaryEvent
id="Review_Escalation"
attachedToRef="Review_Subprocess"
cancelActivity="false">
<bpmn:signalEventDefinition
id="Review_Escalation_Definition"
signalRef="Signal_Escalation" />
</bpmn:boundaryEvent>
<bpmn:parallelGateway id="Control_Join" />
<bpmn:callActivity
id="Call_Confirmation"
name="Confirm"
calledElement="Called_Process" />
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
<bpmn:signalEventDefinition
id="Escalation_Thrown_Definition"
signalRef="Signal_Escalation" />
</bpmn:intermediateThrowEvent>
<bpmn:task
id="Compensation_Handler"
name="Undo review"
isForCompensation="true" />
<bpmn:boundaryEvent
id="Review_Compensation"
attachedToRef="Review_Subprocess">
<bpmn:compensateEventDefinition
id="Review_Compensation_Definition"
activityRef="Compensation_Handler" />
</bpmn:boundaryEvent>
<bpmn:endEvent id="Control_End" />
<bpmn:sequenceFlow
id="Control_Flow_1"
sourceRef="Control_Start"
targetRef="Control_Decision" />
<bpmn:sequenceFlow
id="Control_Flow_2"
sourceRef="Control_Decision"
targetRef="Review_Subprocess" />
<bpmn:sequenceFlow
id="Control_Flow_3"
sourceRef="Review_Subprocess"
targetRef="Control_Join" />
<bpmn:sequenceFlow
id="Control_Flow_4"
sourceRef="Control_Join"
targetRef="Call_Confirmation" />
<bpmn:sequenceFlow
id="Control_Flow_5"
sourceRef="Call_Confirmation"
targetRef="Escalation_Thrown" />
<bpmn:sequenceFlow
id="Control_Flow_6"
sourceRef="Escalation_Thrown"
targetRef="Control_End" />
</bpmn:process>
</bpmn:definitions>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Data"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
<bpmn:process id="Process_Data">
<bpmn:dataObject id="DataObject_Request" name="Request" />
<bpmn:dataObjectReference
id="DataObjectReference_Request"
dataObjectRef="DataObject_Request" />
<bpmn:dataStoreReference
id="DataStoreReference_Archive"
dataStoreRef="DataStore_Archive" />
<bpmn:scriptTask id="Transform_Data" name="Transform data">
<bpmn:script>result = input</bpmn:script>
<bpmn:dataInputAssociation id="InputAssociation_1">
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
</bpmn:dataInputAssociation>
<bpmn:dataOutputAssociation id="OutputAssociation_1">
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
</bpmn:dataOutputAssociation>
</bpmn:scriptTask>
</bpmn:process>
</bpmn:definitions>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Events"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Continue" name="Continue" />
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
<bpmn:process id="Process_Transaction" isExecutable="true">
<bpmn:startEvent id="Start_Timer">
<bpmn:timerEventDefinition id="Timer_Start_Definition">
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
</bpmn:timerEventDefinition>
</bpmn:startEvent>
<bpmn:transaction id="Transaction_1">
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
<bpmn:boundaryEvent
id="Charge_Error"
attachedToRef="Charge_Account">
<bpmn:errorEventDefinition
id="Charge_Error_Definition"
errorRef="Error_Processing" />
</bpmn:boundaryEvent>
<bpmn:task
id="Undo_Charge"
name="Undo charge"
isForCompensation="true" />
<bpmn:association
id="Compensation_Association"
sourceRef="Charge_Error"
targetRef="Undo_Charge"
associationDirection="One" />
</bpmn:transaction>
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
<bpmn:messageEventDefinition
id="Wait_Message_Definition"
messageRef="Message_Continue" />
</bpmn:intermediateCatchEvent>
<bpmn:endEvent id="End_Transaction">
<bpmn:terminateEventDefinition id="Terminate_Definition" />
</bpmn:endEvent>
</bpmn:process>
</bpmn:definitions>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
id="Definitions_Process"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:process id="Process_Linear" isExecutable="true">
<bpmn:extensionElements>
<govoplan:fixture revision="1">
<govoplan:note>Preserve this extension exactly.</govoplan:note>
</govoplan:fixture>
</bpmn:extensionElements>
<bpmn:startEvent id="Start_1">
<bpmn:outgoing>Flow_1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="Task_1" name="Review">
<bpmn:incoming>Flow_1</bpmn:incoming>
<bpmn:outgoing>Flow_2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="End_1">
<bpmn:incoming>Flow_2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_1">
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
<dc:Bounds x="80" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
<dc:Bounds x="430" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
<di:waypoint x="116" y="130" />
<di:waypoint x="220" y="130" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
<di:waypoint x="320" y="130" />
<di:waypoint x="430" y="130" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
+355
View File
@@ -0,0 +1,355 @@
from __future__ import annotations
from collections import Counter
import unittest
from pathlib import Path
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnInspectionError,
inspect_bpmn_xml,
parse_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.backend.bpmn_graph import (
NATIVE_BPMN_ADAPTER_ID,
export_bpmn_graph,
import_bpmn_graph,
)
BPMN = """<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_1"
targetNamespace="https://govoplan.example.test/workflow">
<bpmn:process id="Process_1" isExecutable="true">
<bpmn:startEvent id="Start_1" />
<bpmn:userTask id="Review_1" name="Review request" />
<bpmn:exclusiveGateway id="Decision_1" />
<bpmn:endEvent id="End_1" />
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Review_1" />
<bpmn:sequenceFlow id="Flow_2" sourceRef="Review_1" targetRef="Decision_1" />
<bpmn:sequenceFlow id="Flow_3" sourceRef="Decision_1" targetRef="End_1" />
</bpmn:process>
<bpmn:collaboration id="Collaboration_1">
<bpmn:participant id="Participant_1" processRef="Process_1" />
</bpmn:collaboration>
</bpmn:definitions>
"""
NATIVE_BPMN = """<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
id="Definitions_native"
targetNamespace="https://govoplan.example.test/workflow/native">
<bpmn:process id="Process_native" isExecutable="true">
<bpmn:startEvent id="Start_native" />
<bpmn:userTask id="Review_native" name="Review request" />
<bpmn:endEvent id="End_native" />
<bpmn:sequenceFlow id="Flow_start_review" sourceRef="Start_native" targetRef="Review_native" />
<bpmn:sequenceFlow id="Flow_review_end" sourceRef="Review_native" targetRef="End_native" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_native">
<bpmndi:BPMNPlane id="Plane_native" bpmnElement="Process_native">
<bpmndi:BPMNShape id="Shape_start" bpmnElement="Start_native">
<dc:Bounds x="40" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_review" bpmnElement="Review_native">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_end" bpmnElement="End_native">
<dc:Bounds x="460" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
"""
class BpmnInspectionTests(unittest.TestCase):
def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
results = {
path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8"))
for path in sorted(fixture_directory.glob("*.bpmn"))
}
self.assertEqual(
{
"choreography",
"collaboration",
"control-flow",
"data",
"events-transaction-compensation",
"process",
},
set(results),
)
self.assertEqual(1, results["choreography"].choreography_count)
self.assertEqual(1, results["collaboration"].collaboration_count)
self.assertEqual(
1,
results["events-transaction-compensation"].element_counts[
"transaction"
],
)
self.assertEqual(
1,
results["data"].element_counts["dataStoreReference"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["exclusiveGateway"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["subProcess"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["callActivity"],
)
self.assertEqual(
1,
results["control-flow"].element_counts[
"compensateEventDefinition"
],
)
self.assertEqual(
2,
results["control-flow"].element_counts["signalEventDefinition"],
)
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
result = inspect_bpmn_xml(BPMN)
self.assertTrue(result.valid_xml)
self.assertEqual(1, result.process_count)
self.assertEqual(1, result.executable_process_count)
self.assertEqual(1, result.collaboration_count)
self.assertEqual(3, result.element_counts["sequenceFlow"])
review = next(
item for item in result.elements if item.element_id == "Review_1"
)
collaboration = next(
item
for item in result.elements
if item.element_id == "Collaboration_1"
)
self.assertEqual("native_execution", review.support_level)
self.assertEqual("native_mapping", collaboration.support_level)
def test_dangling_references_are_reported(self) -> None:
result = inspect_bpmn_xml(
BPMN.replace('targetRef="End_1"', 'targetRef="Missing_1"')
)
self.assertFalse(result.valid_xml)
self.assertTrue(
any(
item.code == "dangling_bpmn_reference"
for item in result.diagnostics
)
)
def test_entities_are_rejected(self) -> None:
unsafe = """<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_1" targetNamespace="x">&xxe;</bpmn:definitions>"""
with self.assertRaisesRegex(
BpmnInspectionError,
"not safe and well formed",
):
inspect_bpmn_xml(unsafe)
def test_non_bpmn_root_is_rejected(self) -> None:
with self.assertRaisesRegex(
BpmnInspectionError,
"bpmn:definitions",
):
inspect_bpmn_xml("<definitions />")
def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None:
profiles = {
item.id: item for item in bpmn_adapter_registry().profiles()
}
self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable)
adapter, inspection, graph = compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
self.assertEqual("1.0.0", adapter.profile.version)
self.assertTrue(inspection.valid_xml)
self.assertIsNotNone(graph)
assert graph is not None
self.assertEqual(
[
"workflow.start.manual",
"workflow.activity",
"workflow.end.completed",
],
[item.type for item in graph.nodes],
)
self.assertEqual(220, graph.nodes[1].position.x)
self.assertEqual(
["Flow_start_review", "Flow_review_end"],
[item.id for item in graph.edges],
)
def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None:
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusiveGateway is not supported",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
def test_model_only_profile_remains_read_compatible(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
)
self.assertIsNone(graph)
def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None:
graph = import_bpmn_graph(BPMN)
self.assertEqual(
[
"bpmn.startEvent",
"bpmn.userTask",
"bpmn.exclusiveGateway",
"bpmn.endEvent",
"bpmn.participant",
],
[node.type for node in graph.nodes],
)
self.assertTrue(
all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges)
)
rendered = export_bpmn_graph(graph, name="Round trip")
imported = import_bpmn_graph(rendered)
self.assertEqual(
[(node.id, node.type) for node in graph.nodes],
[(node.id, node.type) for node in imported.nodes],
)
self.assertEqual(
[(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges],
[
(edge.id, edge.type, edge.source, edge.target)
for edge in imported.edges
],
)
def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
for path in sorted(fixture_directory.glob("*.bpmn")):
with self.subTest(path=path.name):
graph = import_bpmn_graph(path.read_text(encoding="utf-8"))
imported = import_bpmn_graph(
export_bpmn_graph(graph, name=path.stem)
)
self.assertEqual(
Counter(node.type for node in graph.nodes),
Counter(node.type for node in imported.nodes),
)
self.assertEqual(
Counter(edge.type for edge in graph.edges),
Counter(edge.type for edge in imported.edges),
)
self.assertEqual(len(graph.nodes), len(imported.nodes))
self.assertEqual(len(graph.edges), len(imported.edges))
def test_nested_flows_remain_in_their_bpmn_container(self) -> None:
fixture = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "events-transaction-compensation.bpmn"
)
rendered = export_bpmn_graph(
import_bpmn_graph(fixture.read_text(encoding="utf-8"))
)
root = parse_bpmn_xml(rendered)
transaction = next(
item
for item in root.iter()
if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction"
)
self.assertTrue(
any(
child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association"
and child.attrib.get("id") == "Compensation_Association"
for child in transaction
)
)
def test_default_flow_is_an_editable_edge_property(self) -> None:
source = BPMN.replace(
'<bpmn:exclusiveGateway id="Decision_1" />',
'<bpmn:exclusiveGateway id="Decision_1" default="Flow_3" />',
)
graph = import_bpmn_graph(source)
default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3")
self.assertIs(default_edge.config.get("default"), True)
rendered = export_bpmn_graph(graph)
self.assertIn('default="Flow_3"', rendered)
graph.edges = [
edge.model_copy(update={"config": {**edge.config, "default": False}})
if edge.id == "Flow_3"
else edge
for edge in graph.edges
]
rendered_without_default = export_bpmn_graph(graph)
self.assertNotIn('default="Flow_3"', rendered_without_default)
def test_native_graph_import_is_separate_from_runtime_support(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
)
self.assertIsNotNone(graph)
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusive gateway",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
activation=True,
)
def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None:
with self.assertRaisesRegex(BpmnAdapterError, "is not installed"):
compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="9.0.0",
)
if __name__ == "__main__":
unittest.main()
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import PolicyDecision
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.governance import normalize_definition_scope
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionUpdateRequest,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
definition_response,
derive_definition,
update_definition,
)
try:
from test_service import sample_graph
except ModuleNotFoundError as exc:
if exc.name != "test_service":
raise
from tests.test_service import sample_graph
POLICY_CAPABILITY = "policy.definitionGovernance"
def principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(
{
"workflow:definition:read",
"workflow:definition:write",
"workflow:instance:start",
}
),
),
account=object(),
user=object(),
)
class DefinitionPolicy:
def resolve_definition_action(self, *, request):
local = request.definition_scope.scope_type == "tenant"
inherited = (
request.definition_scope.scope_type == "system"
and request.inherit_to_lower_scopes
)
allowed = local or inherited
if request.action == "edit":
allowed = local
elif request.action in {"reuse", "derive"}:
allowed = allowed and request.allow_reuse
elif request.action == "run":
allowed = (
allowed
and request.definition_kind == "flow"
and request.status == "active"
and request.allow_run
)
elif request.action == "automate":
allowed = (
allowed
and request.definition_kind == "flow"
and request.allow_automation
)
return PolicyDecision(
allowed=allowed,
reason=None if allowed else "Definition action denied.",
)
class Registry:
def has_capability(self, name: str) -> bool:
return name == POLICY_CAPABILITY
def capability(self, name: str):
if not self.has_capability(name):
raise KeyError(name)
return DefinitionPolicy()
class WorkflowGovernanceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
self.principal = principal()
self.registry = Registry()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
WorkflowDefinitionRevision.__table__,
WorkflowDefinition.__table__,
],
)
self.engine.dispose()
def test_derivation_pins_template_revision_and_provenance(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="system-admin",
payload=WorkflowDefinitionCreateRequest(
key="permit-review",
name="Permit review template",
graph=sample_graph(),
scope_type="system",
definition_kind="template",
inherit_to_lower_scopes=True,
allow_reuse=True,
),
)
derived = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=template.id,
payload=WorkflowDefinitionDeriveRequest(
name="Tenant permit review",
allow_start=True,
),
)
self.session.commit()
response = definition_response(
self.session,
derived,
principal=self.principal,
registry=self.registry,
)
self.assertEqual(template.id, derived.derived_from_definition_id)
self.assertEqual(1, derived.derived_from_revision)
self.assertEqual(
template.revisions[0].content_hash,
derived.derived_from_hash,
)
self.assertEqual(
"system",
response.governance.derivation_provenance["source_scope"][
"scope_type"
],
)
self.assertFalse(response.governance.automation_runtime_available)
def test_user_scope_normalizes_membership_to_account_id(self) -> None:
tenant_id, scope_type, scope_id, scope_key = normalize_definition_scope(
self.principal,
scope_type="user",
scope_id="membership-1",
administrative=False,
)
self.assertEqual("tenant-1", tenant_id)
self.assertEqual("user", scope_type)
self.assertEqual("account-1", scope_id)
self.assertEqual("user:account-1", scope_key)
def test_template_cannot_be_activated(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Reusable review",
graph=sample_graph(),
definition_kind="template",
allow_reuse=True,
),
)
with self.assertRaises(WorkflowConflictError):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=template.id,
actor_id="account-1",
)
def test_derived_limits_cannot_be_broadened_transitively(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Restricted review",
graph=sample_graph(),
definition_kind="template",
allow_reuse=True,
allow_automation=False,
inherit_to_lower_scopes=False,
),
)
derived = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=template.id,
payload=WorkflowDefinitionDeriveRequest(
name="Tenant review",
allow_reuse=True,
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=derived.id,
actor_id="account-1",
payload=WorkflowDefinitionUpdateRequest(
name=derived.name,
graph=sample_graph(),
expected_revision=1,
allow_reuse=True,
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
grandchild = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=derived.id,
payload=WorkflowDefinitionDeriveRequest(
name="User review",
scope_type="user",
scope_id="membership-1",
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
self.assertFalse(derived.allow_automation)
self.assertFalse(derived.inherit_to_lower_scopes)
self.assertFalse(grandchild.allow_automation)
self.assertFalse(grandchild.inherit_to_lower_scopes)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from pathlib import Path
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.assertIn("documentation.semantic_subjects.workflow", manifest.capability_factories)
self.assertIn("docs", manifest.optional_dependencies)
self.assertIn(
"documentation.semantic_subjects.workflow",
{item.name for item in manifest.provides_interfaces},
)
self.assertIn(
"workflow.semantic-documentation",
{topic.id for topic in manifest.documentation},
)
self.assertEqual(
"@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None,
)
def test_editor_exposes_semantic_help_without_replacing_static_help(self) -> None:
root = Path(__file__).parents[1]
page = (root / "webui/src/features/workflow/WorkflowPage.tsx").read_text()
inspector = (
root / "webui/src/features/workflow/WorkflowInspector.tsx"
).read_text()
self.assertIn("workflow.semantic-documentation", {
topic.id for topic in get_manifest().documentation
})
self.assertIn('"semantic"', page)
self.assertIn('"workflow_definition"', page)
self.assertIn('target="_blank"', page)
self.assertIn('target="_blank"', inspector)
self.assertIn("workflow-node-${nodeId}", inspector)
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.manifest import get_manifest
class WorkflowEditorMigrationTests(unittest.TestCase):
def test_editor_does_not_own_database_migrations(self) -> None:
self.assertIsNone(get_manifest().migration_spec)
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
from govoplan_workflow.backend.validation import validate_workflow_graph
class WorkflowNodeLibraryTests(unittest.TestCase):
def test_valid_workflow_graph(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="workflow.start.manual"),
WorkflowNode(
id="work",
type="workflow.activity",
config={"title": "Check submission"},
),
WorkflowNode(id="done", type="workflow.end.completed"),
],
edges=[
WorkflowEdge(id="e1", source="start", target="work"),
WorkflowEdge(id="e2", source="work", target="done"),
],
)
self.assertEqual(validate_workflow_graph(graph), ())
def test_correction_loop_is_allowed(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="workflow.start.manual"),
WorkflowNode(
id="work",
type="workflow.activity",
config={"title": "Prepare"},
),
WorkflowNode(
id="review",
type="workflow.review",
config={"title": "Review"},
),
WorkflowNode(id="done", type="workflow.end.completed"),
],
edges=[
WorkflowEdge(id="e1", source="start", target="work"),
WorkflowEdge(id="e2", source="work", target="review"),
WorkflowEdge(
id="e3",
source="review",
source_port="changes",
target="work",
),
WorkflowEdge(
id="e4",
source="review",
source_port="approved",
target="done",
),
],
)
self.assertNotIn(
"graph.cycle",
{item.code for item in validate_workflow_graph(graph)},
)
def test_constraints_and_required_configuration_are_reported(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start-1", type="workflow.start.manual"),
WorkflowNode(
id="start-2",
type="workflow.start.event",
config={"event_type": ""},
),
],
edges=[
WorkflowEdge(id="e1", source="start-1", target="start-2"),
],
)
diagnostics = validate_workflow_graph(graph)
codes = {item.code for item in diagnostics}
self.assertIn("graph.trigger_count", codes)
self.assertIn("graph.outcome_count", codes)
self.assertIn("node.config_required", codes)
self.assertIn("node.outgoing_required", codes)
def test_library_has_domain_specific_cycle_policy(self) -> None:
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow")
self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version)
activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity")
self.assertIn(
"view_surface_ids",
{field.id for field in activity.config_fields},
)
def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None:
node_types = {item.type for item in BPMN_NODE_TYPES}
self.assertIn("bpmn.startEvent", node_types)
self.assertIn("bpmn.userTask", node_types)
self.assertIn("bpmn.exclusiveGateway", node_types)
self.assertIn("bpmn.participant", node_types)
self.assertIn("bpmn.textAnnotation", node_types)
self.assertTrue(all(item.startswith("bpmn.") for item in node_types))
def test_bpmn_rejects_multiple_default_flows(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="bpmn.startEvent"),
WorkflowNode(id="choice", type="bpmn.exclusiveGateway"),
WorkflowNode(id="end-a", type="bpmn.endEvent"),
WorkflowNode(id="end-b", type="bpmn.endEvent"),
],
edges=[
WorkflowEdge(id="to-choice", source="start", target="choice"),
WorkflowEdge(
id="default-a",
source="choice",
target="end-a",
config={"default": True},
),
WorkflowEdge(
id="default-b",
source="choice",
target="end-b",
config={"default": True},
),
],
)
self.assertIn(
"bpmn.multiple_default_flows",
{item.code for item in validate_workflow_graph(graph)},
)
if __name__ == "__main__":
unittest.main()
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.semantic_documentation import (
SemanticDocumentationSubjectQuery,
)
from govoplan_core.db.base import Base
from govoplan_workflow.backend.semantic_subjects import (
WorkflowSemanticDocumentationSubjectProvider,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow_engine.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow_engine.backend.service import (
create_definition,
delete_definition,
update_definition,
)
def principal(
*,
tenant_id: str = "tenant-1",
scopes: frozenset[str] = frozenset({"workflow:definition:read"}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="author-1",
membership_id="membership-1",
tenant_id=tenant_id,
scopes=scopes,
),
account=object(),
user=object(),
)
def graph(
*,
activity_label: str = "Review",
activity_x: float = 280,
include_activity: bool = True,
) -> WorkflowGraph:
nodes = [
WorkflowNode(
id="start",
type="workflow.start.manual",
label="Start",
position=WorkflowPosition(x=40, y=100),
),
]
if include_activity:
nodes.append(
WorkflowNode(
id="activity",
type="workflow.activity",
label=activity_label,
position=WorkflowPosition(x=activity_x, y=100),
config={
"instructions": "Check the submitted evidence.",
"api_secret": "must-not-leak",
},
)
)
nodes.append(
WorkflowNode(
id="complete",
type="workflow.end.completed",
label="Completed",
position=WorkflowPosition(x=520, y=100),
)
)
edges = []
if include_activity:
edges.extend(
(
WorkflowEdge(id="start-activity", source="start", target="activity"),
WorkflowEdge(
id="activity-complete",
source="activity",
target="complete",
),
)
)
else:
edges.append(
WorkflowEdge(id="start-complete", source="start", target="complete")
)
return WorkflowGraph(nodes=nodes, edges=edges)
class WorkflowSemanticSubjectTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=(
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
),
)
self.session = Session(self.engine)
self.principal = principal()
self.provider = WorkflowSemanticDocumentationSubjectProvider(None)
self.definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="author-1",
payload=WorkflowDefinitionCreateRequest(
name="Resident permit review",
description="Review an application before issuing the permit.",
graph=graph(),
),
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def subjects(self):
return self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
limit=200,
),
).subjects
def activity(self):
return next(
item
for item in self.subjects()
if item.route_anchor == "workflow-node-activity"
)
def revise(self, updated_graph: WorkflowGraph) -> None:
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="author-1",
payload=WorkflowDefinitionUpdateRequest(
name=self.definition.name,
description=self.definition.description,
graph=updated_graph,
expected_revision=self.definition.current_revision,
),
)
self.session.commit()
def test_exposes_safe_definition_and_step_descriptors(self) -> None:
subjects = self.subjects()
self.assertEqual(4, len(subjects))
definition = next(item for item in subjects if item.reference.anchor is None)
activity = self.activity()
self.assertEqual("Resident permit review", definition.labels["en"])
self.assertEqual("step", activity.reference.anchor.kind)
self.assertTrue(activity.reference.anchor.id.startswith("step-"))
self.assertEqual("/workflow?definition=" + self.definition.id, activity.route)
payload = activity.to_dict()
self.assertNotIn("config", payload)
self.assertNotIn("must-not-leak", str(payload))
def test_lineage_survives_layout_and_label_changes_with_review_fingerprint(self) -> None:
before = self.activity().reference
self.revise(graph(activity_x=900))
after_layout = self.activity().reference
self.assertEqual(before.stable_key, after_layout.stable_key)
self.assertEqual(
before.observed_fingerprint,
after_layout.observed_fingerprint,
)
self.revise(graph(activity_x=900, activity_label="Assess evidence"))
after_label = self.activity().reference
self.assertEqual(before.stable_key, after_label.stable_key)
self.assertNotEqual(
before.observed_fingerprint,
after_label.observed_fingerprint,
)
resolution = self.provider.resolve_subject(
self.session,
self.principal,
reference=before,
)
self.assertEqual("changed", resolution.availability)
def test_deleted_and_recreated_step_id_gets_new_lineage(self) -> None:
old = self.activity().reference
self.revise(graph(include_activity=False))
deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("missing", deleted.availability)
self.assertEqual("step_deleted", deleted.reason_code)
self.revise(graph(activity_label="Recreated review"))
recreated = self.activity().reference
self.assertNotEqual(old.stable_key, recreated.stable_key)
still_deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("step_deleted", still_deleted.reason_code)
def test_resolution_rechecks_scope_tenant_governance_and_deletion(self) -> None:
reference = self.activity().reference
self.assertIsNone(
self.provider.resolve_subject(
self.session,
principal(scopes=frozenset()),
reference=reference,
)
)
self.assertIsNone(
self.provider.resolve_subject(
self.session,
principal(tenant_id="tenant-2"),
reference=reference,
)
)
admin = principal(scopes=frozenset({"workflow:instance:admin"}))
self.assertIsNotNone(
self.provider.resolve_subject(
self.session,
admin,
reference=reference,
)
)
delete_definition(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="author-1",
)
self.session.commit()
deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=reference,
)
self.assertEqual("missing", deleted.availability)
self.assertEqual("definition_deleted", deleted.reason_code)
def test_query_filters_and_pages_without_docs_runtime(self) -> None:
page = self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
query="review",
limit=1,
),
)
self.assertEqual(1, len(page.subjects))
self.assertTrue(page.has_more)
second = self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
query="review",
limit=1,
cursor=page.next_cursor,
),
)
self.assertEqual(1, len(second.subjects))
if __name__ == "__main__":
unittest.main()
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
import unittest
from pathlib import Path
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
create_definition,
delete_definition,
get_definition,
list_definition_revisions,
list_definitions,
update_definition,
)
from govoplan_workflow.backend.bpmn_adapters import (
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
)
from govoplan_workflow.backend.bpmn import inspect_bpmn_xml
from govoplan_workflow.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID
try:
from test_bpmn import BPMN, NATIVE_BPMN
except ModuleNotFoundError as exc:
if exc.name != "test_bpmn":
raise
from tests.test_bpmn import BPMN, NATIVE_BPMN
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
label="Start",
position=WorkflowPosition(x=40, y=100),
config={"input_schema_ref": ""},
),
WorkflowNode(
id="activity",
type="workflow.activity",
label="Review",
position=WorkflowPosition(x=280, y=100),
config={
"title": title,
"instructions": "",
"assignee": "",
"due_after": "",
},
),
WorkflowNode(
id="complete",
type="workflow.end.completed",
label="Completed",
position=WorkflowPosition(x=520, y=100),
config={"output_mapping": {}},
),
],
edges=[
WorkflowEdge(id="start-activity", source="start", target="activity"),
WorkflowEdge(id="activity-complete", source="activity", target="complete"),
],
)
class WorkflowServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
WorkflowDefinitionRevision.__table__,
WorkflowDefinition.__table__,
],
)
self.engine.dispose()
def _create(self, *, tenant_id: str = "tenant-1") -> WorkflowDefinition:
definition = create_definition(
self.session,
tenant_id=tenant_id,
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Monthly case handling",
graph=sample_graph(),
),
)
self.session.commit()
return definition
def test_create_update_and_activate_pin_immutable_revisions(self) -> None:
definition = self._create()
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Monthly case handling",
graph=sample_graph(title="Review corrected request"),
expected_revision=1,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
revision=1,
)
self.session.commit()
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual(1, updated.active_revision)
self.assertEqual("active", updated.status)
self.assertEqual([2, 1], [item.revision for item in revisions])
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
historical = next(item for item in revisions if item.revision == 1)
self.assertEqual(
"Review request",
historical.graph["nodes"][1]["config"]["title"],
)
def test_metadata_update_does_not_create_graph_revision(self) -> None:
definition = self._create()
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Renamed workflow",
description="Updated metadata only",
graph=sample_graph(),
metadata={"owner": "finance"},
expected_revision=1,
),
)
self.session.commit()
self.assertEqual(1, updated.current_revision)
self.assertEqual(
1,
len(
list(
self.session.scalars(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id
== definition.id
)
)
)
),
)
def test_execution_mode_and_view_pin_are_immutable_revision_content(
self,
) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided review",
graph=sample_graph(),
execution_mode="guided",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Guided review",
graph=sample_graph(),
expected_revision=1,
execution_mode="hybrid",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual("hybrid", revisions[0].execution_mode)
self.assertEqual("guided", revisions[1].execution_mode)
self.assertEqual("view-revision-1", revisions[1].view_revision_id)
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_automated_mode_rejects_human_handoff_paths(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Invalid automation",
graph=sample_graph(),
execution_mode="automated",
allow_automation=True,
),
)
with self.assertRaisesRegex(
WorkflowConflictError,
"human handoff paths",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
definition = self._create()
with self.assertRaises(WorkflowConflictError):
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Stale",
graph=sample_graph(),
expected_revision=2,
),
)
with self.assertRaises(WorkflowNotFoundError):
get_definition(
self.session,
tenant_id="tenant-2",
definition_id=definition.id,
)
def test_soft_delete_preserves_revisions_and_hides_definition(self) -> None:
definition = self._create()
delete_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
)
self.session.commit()
self.assertEqual([], list_definitions(self.session, tenant_id="tenant-1"))
self.assertEqual(
1,
len(
list(
self.session.scalars(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id
== definition.id
)
)
)
),
)
def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
self.session.commit()
first = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml)
self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id)
self.assertEqual("1.0.0", first.bpmn_adapter_version)
self.assertEqual("native_graph", first.bpmn_runtime_kind)
self.assertEqual(
"Review request",
first.graph["nodes"][1]["config"]["title"],
)
changed_xml = NATIVE_BPMN.replace(
"Review request",
"Review corrected request",
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=changed_xml,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
),
expected_revision=1,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
revision=2,
)
self.session.commit()
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual(2, updated.active_revision)
self.assertIn("Review corrected request", revisions[0].bpmn_xml or "")
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
),
),
)
self.session.commit()
with self.assertRaisesRegex(
WorkflowBpmnValidationError,
"exclusive gateway",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_interchange_revision_preserves_extension_xml_exactly(self) -> None:
xml = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "process.bpmn"
).read_text(encoding="utf-8")
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Extended interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=xml,
adapter_id=INTERCHANGE_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
revision = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml)
self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "")
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/workflow-webui",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/workflow/WorkflowPage.tsx", "utf8");
const runs = fs.readFileSync("src/features/workflow/WorkflowRunsDialog.tsx", "utf8");
const inspector = fs.readFileSync("src/features/workflow/WorkflowInspector.tsx", "utf8");
const styles = fs.readFileSync("src/styles/workflow.css", "utf8");
assert.ok(page.includes("DocumentationHelpLink"), "Workflow exposes configured-system help");
assert.ok(page.includes("useUnsavedDraftGuard"), "Workflow protects dirty graph revisions during navigation");
assert.ok(page.includes("<ConfirmDialog"), "Destructive and corrective definition actions use shared confirmation");
assert.ok(page.includes("<Dialog"), "Definition settings and derivation use shared focus-contained dialogs");
assert.ok(page.includes("onClick={() => addNodeFromPalette(nodeType.type)}"), "Palette nodes have a keyboard/pointer alternative to drag and drop");
assert.ok(inspector.includes("onEdgeChange"), "Edges can be edited without reconnect dragging");
assert.ok(runs.includes("StatusBadge"), "Run and handoff states are not conveyed by color alone");
assert.ok(runs.includes("DismissibleAlert"), "Run failures use the shared alert contract");
assert.ok(!page.includes("window.alert("), "Workflow must not use browser alerts");
assert.ok(styles.includes("@media (max-width: 680px)"), "Workflow retains a narrow-viewport layout");
assert.ok(styles.includes("@media (prefers-reduced-motion: reduce)"), "Workflow honors reduced motion");
console.log("Workflow interface pattern contract passed.");
+668
View File
@@ -0,0 +1,668 @@
import {
apiFetch,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphEdge,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowExecutionMode = "guided" | "automated" | "hybrid";
export type WorkflowStartOrigin =
| "user"
| "api"
| "schedule"
| "event"
| "parent_workflow"
| "dependency"
| "retry"
| "replay"
| "backfill";
export type WorkflowGraphNode = DefinitionGraphNode & {
size?: { width: number; height: number } | null;
parent_id?: string | null;
process_id?: string | null;
};
export type WorkflowGraphEdge = DefinitionGraphEdge & {
type:
| "bpmn.sequenceFlow"
| "bpmn.messageFlow"
| "bpmn.association"
| "bpmn.dataInputAssociation"
| "bpmn.dataOutputAssociation"
| "bpmn.conversationLink";
label: string;
config: Record<string, unknown>;
waypoints: Array<{ x: number; y: number }>;
};
export type WorkflowGraph = Omit<DefinitionGraph, "nodes" | "edges"> & {
schema_version: 1;
nodes: WorkflowGraphNode[];
edges: WorkflowGraphEdge[];
metadata: Record<string, unknown>;
};
export type WorkflowDiagnostic = {
severity: "error" | "warning";
code: string;
message: string;
node_id?: string | null;
field?: string | null;
};
export type WorkflowNodeType = DefinitionGraphNodeType;
export type BpmnRuntimeKind = "model_only" | "native_graph" | "external";
export type BpmnDiagnostic = {
severity: "error" | "warning" | "info";
code: string;
message: string;
element_id?: string | null;
};
export type BpmnAdapterProfile = {
id: string;
version: string;
label: string;
description: string;
conformance: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
supported_elements: string[];
supported_event_definitions: string[];
requirements: string[];
};
export type BpmnInspection = {
valid_xml: boolean;
definitions_id?: string | null;
target_namespace?: string | null;
process_count: number;
executable_process_count: number;
collaboration_count: number;
choreography_count: number;
element_counts: Record<string, number>;
support_counts: Record<string, number>;
elements: Array<{
element_type: string;
element_id?: string | null;
name?: string | null;
parent_type?: string | null;
parent_id?: string | null;
support_level: "interchange_only" | "native_mapping" | "native_execution";
}>;
diagnostics: BpmnDiagnostic[];
adapter_id?: string | null;
adapter_version?: string | null;
runtime_kind?: BpmnRuntimeKind | null;
executable: boolean;
activatable: boolean;
};
export type BpmnRevisionSummary = {
format: "bpmn-2.0";
content_hash: string;
adapter_id: string;
adapter_version: string;
runtime_kind: BpmnRuntimeKind;
executable: boolean;
adapter_available: boolean;
};
export type BpmnRevisionDocument = BpmnRevisionSummary & {
definition_id: string;
revision: number;
xml: string;
inspection: BpmnInspection;
};
export type WorkflowRevision = {
id: string;
revision: number;
schema_version: number;
graph: WorkflowGraph;
content_hash: string;
library_id: string;
library_version: string;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
bpmn?: BpmnRevisionSummary | null;
contribution_origin_module_version?: string | null;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
contribution_metadata: Record<string, unknown>;
created_by?: string | null;
created_at: string;
};
export type WorkflowStandardProvenance = {
kind: "baseline" | "override";
origin_module_id: string;
origin_module_version?: string | null;
definition_key: string;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
baseline_definition_id: string;
latest_baseline_revision: number;
active_baseline_revision?: number | null;
pinned_baseline_revision?: number | null;
pinned_baseline_hash?: string | null;
update_available: boolean;
reset_available: boolean;
};
export type WorkflowStandardDiffItem = {
resource_type: "graph" | "node" | "edge";
resource_id: string;
state: "unchanged" | "local_only" | "upstream_only" | "same_change" | "conflict";
recommended_action: "none" | "keep_local" | "adopt_upstream" | "either" | "manual_resolution";
changed_fields: string[];
baseline?: Record<string, unknown> | null;
local?: Record<string, unknown> | null;
latest?: Record<string, unknown> | null;
};
export type WorkflowStandardDiff = {
override_definition_id: string;
baseline_definition_id: string;
pinned_baseline_revision: number;
local_revision: number;
latest_baseline_revision: number;
counts: Record<string, number>;
conflict_count: number;
auto_mergeable: boolean;
items: WorkflowStandardDiffItem[];
};
export type WorkflowDefinition = {
id: string;
tenant_id: string | null;
key: string;
name: string;
description?: string | null;
status: WorkflowStatus;
current_revision: number;
active_revision?: number | null;
metadata: Record<string, unknown>;
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
revision: WorkflowRevision;
governance: WorkflowGovernance;
standard?: WorkflowStandardProvenance | null;
};
export type WorkflowActionDecision = {
allowed: boolean;
reason?: string | null;
source_path: Array<Record<string, unknown>>;
requirements: string[];
details: Record<string, unknown>;
};
export type WorkflowGovernance = {
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
derived_from_definition_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
derivation_provenance: Record<string, unknown>;
actions: Record<string, WorkflowActionDecision>;
automation_runtime_available: boolean;
automation_runtime_reason?: string | null;
};
export type WorkflowInstanceStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled";
export type WorkflowStepStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled"
| "superseded";
export type WorkflowInstanceStep = {
id: string;
sequence: number;
node_id: string;
node_type: string;
status: WorkflowStepStatus;
attempt: number;
input: Record<string, unknown>;
output: Record<string, unknown>;
handoff: Record<string, unknown>;
external_ref?: string | null;
started_at?: string | null;
finished_at?: string | null;
error?: string | null;
completed_by?: string | null;
created_at: string;
updated_at: string;
};
export type WorkflowInstanceEvent = {
id: string;
sequence: number;
step_id?: string | null;
kind: string;
actor_id?: string | null;
payload: Record<string, unknown>;
created_at: string;
};
export type WorkflowInstance = {
id: string;
definition_id: string;
definition_name: string;
definition_revision: number;
definition_hash: string;
execution_mode: WorkflowExecutionMode;
start_origin: WorkflowStartOrigin;
view_context?: {
view_id: string;
revision_id?: string | null;
visible_surface_ids: string[];
step_id?: string | null;
node_id?: string | null;
} | null;
status: WorkflowInstanceStatus;
idempotency_key: string;
correlation_id?: string | null;
current_step_id?: string | null;
input: Record<string, unknown>;
context: Record<string, unknown>;
output: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
cancellation_requested_at?: string | null;
error?: string | null;
created_by?: string | null;
created_at: string;
updated_at: string;
steps: WorkflowInstanceStep[];
events: WorkflowInstanceEvent[];
replayed: boolean;
};
export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
bpmn?: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
} | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode: WorkflowExecutionMode;
view_id?: string | null;
view_revision_id?: string | null;
};
export async function getBpmnSupportProfile(
settings: ApiSettings
): Promise<{
specification: string;
model_namespace: string;
interchange: string;
native_runtime: string;
native_execution_elements: string[];
native_mapping_elements: string[];
adapters: BpmnAdapterProfile[];
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/profile");
}
export function inspectWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
activation?: boolean;
}
): Promise<BpmnInspection> {
return apiFetch(settings, "/api/v1/workflow/bpmn/inspect", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function compileWorkflowBpmn(
settings: ApiSettings,
payload: {
xml: string;
adapter_id: string;
adapter_version?: string | null;
}
): Promise<{
adapter: BpmnAdapterProfile;
graph: WorkflowGraph;
inspection: BpmnInspection;
}> {
return apiFetch(settings, "/api/v1/workflow/bpmn/compile", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function renderWorkflowBpmn(
settings: ApiSettings,
payload: { graph: WorkflowGraph; name?: string }
): Promise<{ xml: string; inspection: BpmnInspection }> {
return apiFetch(settings, "/api/v1/workflow/bpmn/render", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
return apiFetch(settings, "/api/v1/workflow/node-types");
}
export async function listWorkflowDefinitions(
settings: ApiSettings
): Promise<WorkflowDefinition[]> {
const response = await apiFetch<{ definitions: WorkflowDefinition[] }>(
settings,
"/api/v1/workflow/definitions"
);
return response.definitions;
}
export function createWorkflowDefinition(
settings: ApiSettings,
payload: WorkflowDefinitionPayload
): Promise<WorkflowDefinition> {
return apiFetch(settings, "/api/v1/workflow/definitions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: WorkflowDefinitionPayload & { expected_revision: number }
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{
method: "PUT",
body: JSON.stringify(payload)
}
);
}
export function deriveWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: {
key?: string | null;
name: string;
description?: string | null;
source_revision?: number | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
execution_mode?: WorkflowExecutionMode | null;
view_id?: string | null;
view_revision_id?: string | null;
}
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function reconcileWorkflowStandards(
settings: ApiSettings
): Promise<{
discovered: number;
created: number;
updated: number;
unchanged: number;
blocked: number;
pending_tenant_scope: number;
items: Array<Record<string, unknown>>;
}> {
return apiFetch(settings, "/api/v1/workflow/standards/reconcile", {
method: "POST"
});
}
export function resetWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/reset-standard`,
{ method: "POST" }
);
}
export function compareWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowStandardDiff> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/standard-diff`
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowRevision[]> {
const response = await apiFetch<{ revisions: WorkflowRevision[] }>(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions`
);
return response.revisions;
}
export function getWorkflowRevision(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<WorkflowRevision> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}`
);
}
export function getWorkflowRevisionBpmn(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<BpmnRevisionDocument> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}/bpmn`
);
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
revision?: number
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/activate`,
{
method: "POST",
body: JSON.stringify({ revision })
}
);
}
export function archiveWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/archive`,
{ method: "POST" }
);
}
export function deleteWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<{ deleted: boolean; definition_id: string }> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{ method: "DELETE" }
);
}
export function validateWorkflowDefinition(
settings: ApiSettings,
graph: WorkflowGraph
): Promise<{ valid: boolean; diagnostics: WorkflowDiagnostic[] }> {
return apiFetch(settings, "/api/v1/workflow/definitions/validate", {
method: "POST",
body: JSON.stringify({ graph })
});
}
export function workflowScopeReferenceProvider(
settings: ApiSettings,
scopeType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
"/api/v1/workflow/scope-targets",
{ scope_type: scopeType }
);
}
export async function listWorkflowInstances(
settings: ApiSettings,
definitionId?: string | null
): Promise<WorkflowInstance[]> {
const params = new URLSearchParams();
if (definitionId) params.set("definition_id", definitionId);
const query = params.size ? `?${params.toString()}` : "";
const response = await apiFetch<{ instances: WorkflowInstance[] }>(
settings,
`/api/v1/workflow/instances${query}`
);
return response.instances;
}
export function startWorkflowInstance(
settings: ApiSettings,
definitionId: string,
payload: {
idempotency_key: string;
input?: Record<string, unknown>;
correlation_id?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/instances`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function getWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}`
);
}
export function reconcileWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/reconcile`,
{ method: "POST" }
);
}
export function resolveWorkflowStep(
settings: ApiSettings,
instanceId: string,
stepId: string,
payload: {
action: "complete" | "approve" | "changes" | "reject" | "resume" | "retry" | "confirm_effect" | "confirm_absent" | "cancel";
output?: Record<string, unknown>;
evidence?: string[];
comment?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/steps/${encodeURIComponent(stepId)}/actions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function cancelWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/cancel`,
{ method: "POST" }
);
}
@@ -0,0 +1,386 @@
import { useMemo, useRef, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
MarkerType,
MiniMap,
ReactFlow,
reconnectEdge,
type Connection,
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { StatePanel } from "@govoplan/core-webui";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDiagnostic,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
import { newWorkflowNode } from "./model";
import WorkflowNode, { type WorkflowFlowNode } from "./WorkflowNode";
const nodeTypes = { workflow: WorkflowNode };
export default function WorkflowCanvas({
graph,
diagnostics,
nodeLibrary,
selectedNodeId,
selectedEdgeId,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode,
onSelectEdge
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
selectedEdgeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
onSelectEdge: (edgeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | null
>(null);
const reconnectSuccessful = useRef(true);
const reconnectingEdgeId = useRef<string | null>(null);
const definitions = useMemo(
() => new Map(nodeLibrary.map((item) => [item.type, item])),
[nodeLibrary]
);
const errorNodeIds = useMemo(
() => new Set(
diagnostics
.filter((item) => item.severity === "error" && item.node_id)
.map((item) => item.node_id as string)
),
[diagnostics]
);
const nodes = useMemo<WorkflowFlowNode[]>(
() => graph.nodes.flatMap((node) => {
const definition = definitions.get(node.type);
if (!definition) return [];
return [{
id: node.id,
type: "workflow" as const,
position: node.position,
initialWidth: canvasNodeSize(node, definition).width,
initialHeight: canvasNodeSize(node, definition).height,
selected: node.id === selectedNodeId,
data: {
label: node.label,
workflowType: node.type,
definition,
hasError: errorNodeIds.has(node.id)
}
}];
}),
[definitions, errorNodeIds, graph.nodes, selectedNodeId]
);
const edges = useMemo<Edge[]>(
() => graph.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.source_port ?? "output",
targetHandle: edge.target_port ?? "input",
type: "smoothstep",
label: edge.label || undefined,
className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`,
selected: edge.id === selectedEdgeId,
animated: edge.type === "bpmn.messageFlow",
markerEnd: edgeMarkerEnd(edge),
style: edge.type === "bpmn.association"
? { strokeDasharray: "4 4" }
: edge.type === "bpmn.messageFlow"
? { strokeDasharray: "8 5" }
: undefined
})),
[graph.edges, selectedEdgeId]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.map((node) => node.id));
const movedIds = new Set(
nextNodes
.filter((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
return current
&& (
current.position.x !== flowNode.position.x
|| current.position.y !== flowNode.position.y
);
})
.map((node) => node.id)
);
onGraphChange({
...graph,
nodes: nextNodes.map((flowNode) => {
const current = graph.nodes.find((node) => node.id === flowNode.id);
if (!current) throw new Error(`Unknown Workflow node: ${flowNode.id}`);
return { ...current, position: flowNode.position };
}),
edges: graph.edges.filter(
(edge) => ids.has(edge.source) && ids.has(edge.target)
).map((edge) =>
movedIds.has(edge.source) || movedIds.has(edge.target)
? { ...edge, waypoints: [] }
: edge
)
});
};
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => {
const current = graph.edges.find((item) => item.id === edge.id);
const endpointsChanged = Boolean(
current
&& (
current.source !== edge.source
|| current.target !== edge.target
)
);
return {
id: edge.id,
type: current?.type ?? "bpmn.sequenceFlow",
label: current?.label ?? "",
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "outgoing",
target_port: edge.targetHandle ?? "incoming",
config: structuredClone(current?.config ?? {}),
waypoints: endpointsChanged
? []
: structuredClone(current?.waypoints ?? [])
} satisfies WorkflowGraphEdge;
})
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
reconnectingEdgeId.current
? {
...graph,
edges: graph.edges.filter(
(edge) => edge.id !== reconnectingEdgeId.current
)
}
: graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
},
{ allowCycles: allowsCycles }
) === null;
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
const type = event.dataTransfer.getData(
"application/x-govoplan-workflow-node"
);
if (!type) return;
const node = newWorkflowNode(
type,
instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
nodeLibrary
);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectNode(node.id);
};
return (
<div
className="definition-graph-canvas workflow-canvas"
onDragOver={(event) => {
if (
event.dataTransfer.types.includes(
"application/x-govoplan-workflow-node"
)
) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={onDrop}
>
<ReactFlow<WorkflowFlowNode, Edge>
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
if (readOnly) return;
const graphChanges = changes.filter(
(change) => change.type !== "dimensions"
);
if (graphChanges.length) {
updateNodes(applyNodeChanges(graphChanges, nodes));
}
}}
onEdgesChange={(changes) => {
if (readOnly) return;
const selectedChange = changes.find(
(change) => change.type === "select" && change.selected
);
if (selectedChange?.type === "select") {
onSelectEdge(selectedChange.id);
}
const graphChanges = changes.filter(
(change) => change.type !== "select"
);
if (graphChanges.length) {
updateEdges(applyEdgeChanges(graphChanges, edges));
}
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
updateEdges(addEdge({
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
}}
onReconnect={(oldEdge, connection) => {
if (readOnly || !isValidConnection(connection)) return;
reconnectSuccessful.current = true;
updateEdges(reconnectEdge(
oldEdge,
connection,
edges,
{ shouldReplaceId: false }
));
}}
onReconnectStart={(_event, edge) => {
reconnectSuccessful.current = false;
reconnectingEdgeId.current = edge.id;
}}
onReconnectEnd={(_event, edge) => {
if (!reconnectSuccessful.current && !readOnly) {
updateEdges(edges.filter((candidate) => candidate.id !== edge.id));
onSelectEdge(null);
}
reconnectSuccessful.current = true;
reconnectingEdgeId.current = null;
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => {
onSelectEdge(null);
onSelectNode(node.id);
}}
onEdgeClick={(_event, edge) => {
onSelectNode(null);
onSelectEdge(edge.id);
}}
onPaneClick={() => {
onSelectNode(null);
onSelectEdge(null);
}}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
edgesReconnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
connectionRadius={32}
fitView
fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }}
minZoom={0.25}
maxZoom={1.8}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
<MiniMap
pannable
zoomable
nodeStrokeWidth={2}
nodeColor={(node) =>
node.data.hasError ? "var(--danger)" : "var(--accent)"
}
/>
<Controls showInteractive={false} />
</ReactFlow>
{!graph.nodes.length ? (
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a start node here" />
) : null}
</div>
);
}
function edgeMarkerEnd(edge: WorkflowGraphEdge) {
if (edge.type === "bpmn.sequenceFlow") {
return {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
if (
edge.type === "bpmn.messageFlow"
|| edge.type === "bpmn.dataInputAssociation"
|| edge.type === "bpmn.dataOutputAssociation"
) {
return {
type: MarkerType.Arrow,
width: 16,
height: 16,
color: "var(--line-dark)"
};
}
return undefined;
}
function canvasNodeSize(
node: WorkflowGraphNode,
definition: WorkflowNodeType
): { width: number; height: number } {
const shape = String(definition.metadata?.shape ?? "activity");
if (shape.startsWith("event")) return { width: 124, height: 70 };
if (shape === "gateway") return { width: 124, height: 82 };
if (shape === "participant" || shape === "lane") {
return {
width: Math.max(240, Math.min(node.size?.width ?? 360, 720)),
height: Math.max(100, Math.min(node.size?.height ?? 160, 360))
};
}
if (shape === "group") {
return {
width: Math.max(220, node.size?.width ?? 300),
height: Math.max(120, node.size?.height ?? 180)
};
}
return { width: 190, height: 64 };
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
): WorkflowGraph {
return {
...graph,
nodes: graph.nodes.map((node) =>
node.id === updatedNode.id ? updatedNode : node
)
};
}
@@ -0,0 +1,378 @@
import { useEffect, useMemo, useState } from "react";
import { BookOpen, Trash2 } from "lucide-react";
import {
ActionToolbar,
Button,
DocumentationHelpLink,
DismissibleAlert,
FormField,
ReferenceMultiSelect,
StatePanel,
useViewSurfaces,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
WorkflowGraphEdge,
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
edge,
nodeLibrary,
readOnly,
semanticDefinitionId,
semanticStepAvailable,
onChange,
onDelete,
onEdgeChange,
onEdgeDelete
}: {
node: WorkflowGraphNode | null;
edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
semanticDefinitionId: string | null;
semanticStepAvailable: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void;
onEdgeDelete: (edgeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
const viewSurfaces = useViewSurfaces();
const viewSurfaceProvider = useMemo<ReferenceOptionProvider>(() => {
const options = viewSurfaces.map((surface) => ({
value: surface.id,
label: surface.label,
description: `${surface.moduleId} · ${surface.kind}`,
searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}`
}));
const byId = new Map(options.map((option) => [option.value, option]));
return {
search: async (query, context) => {
const normalized = query.trim().toLowerCase();
return options
.filter((option) => (
!normalized
|| option.searchText.toLowerCase().includes(normalized)
))
.slice(0, context.limit);
},
resolve: async (values) => values
.map((value) => byId.get(value))
.filter((option): option is (typeof options)[number] => Boolean(option))
};
}, [viewSurfaces]);
useEffect(() => {
if (!node) {
setJsonDrafts({});
setError("");
return;
}
const definition = nodeLibrary.find((item) => item.type === node.type);
setJsonDrafts(Object.fromEntries(
(definition?.config_fields ?? [])
.filter((field) => field.kind === "mapping")
.map((field) => [
field.id,
JSON.stringify(node.config[field.id] ?? {}, null, 2)
])
));
setError("");
}, [node?.id, nodeLibrary]);
if (edge) {
const updateEdgeConfig = (field: string, value: unknown) => {
onEdgeChange({
...edge,
config: { ...edge.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Flow inspector">
<ActionToolbar surface="section-header" className="workflow-panel-heading">
<span>
<strong>Flow</strong>
<small>{edge.type.replace(/^bpmn\./, "")}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onEdgeDelete(edge.id)}
disabled={readOnly}
aria-label="Delete flow"
title="Delete flow"
>
<Trash2 size={16} />
</Button>
</ActionToolbar>
<div className="workflow-inspector-fields">
<FormField label="Flow type">
<select
value={edge.type}
onChange={(event) => onEdgeChange({
...edge,
type: event.target.value as WorkflowGraphEdge["type"]
})}
disabled={readOnly}
>
<option value="bpmn.sequenceFlow">Sequence flow</option>
<option value="bpmn.messageFlow">Message flow</option>
<option value="bpmn.association">Association</option>
<option value="bpmn.dataInputAssociation">
Data input association
</option>
<option value="bpmn.dataOutputAssociation">
Data output association
</option>
<option value="bpmn.conversationLink">
Conversation link
</option>
</select>
</FormField>
<FormField label="Name">
<input
value={edge.label}
onChange={(event) => onEdgeChange({
...edge,
label: event.target.value
})}
disabled={readOnly}
/>
</FormField>
{edge.type === "bpmn.sequenceFlow" ? (
<>
<FormField
label="Condition"
help="A constrained expression evaluated when this flow is reached."
>
<textarea
value={textValue(edge.config.condition)}
onChange={(event) =>
updateEdgeConfig("condition", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<FormField
label="Runtime outcome"
help="Optional GovOPlaN task outcome mapped to this sequence flow."
>
<input
value={textValue(edge.config.outcome)}
onChange={(event) =>
updateEdgeConfig("outcome", event.target.value)
}
disabled={readOnly}
/>
</FormField>
<label className="workflow-inspector-checkbox">
<input
type="checkbox"
checked={edge.config.default === true}
onChange={(event) =>
updateEdgeConfig("default", event.target.checked)
}
disabled={readOnly}
/>
Default flow
</label>
</>
) : null}
</div>
</aside>
);
}
if (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<ActionToolbar surface="section-header" className="workflow-panel-heading"><strong>Inspector</strong></ActionToolbar>
<StatePanel size="compact" description="No node selected" />
</aside>
);
}
const definition = nodeLibrary.find((item) => item.type === node.type);
const updateConfig = (field: string, value: unknown) => {
onChange({
...node,
config: { ...node.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<ActionToolbar surface="section-header" className="workflow-panel-heading">
<span>
<strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small>
</span>
<span className="workflow-inspector-actions">
{semanticDefinitionId && semanticStepAvailable ? <>
<DocumentationHelpLink
reference={{
contextId: semanticHelpContext(semanticDefinitionId, node.id),
documentationType: "user"
}}
label="Open step meaning"
/>
<a
className="btn btn-secondary"
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
target="_blank"
rel="noreferrer"
>
<BookOpen size={15} aria-hidden="true" /> Document meaning
</a>
</> : null}
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</span>
</ActionToolbar>
<div className="workflow-inspector-fields">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<FormField label="Name">
<input
value={node.label}
onChange={(event) =>
onChange({ ...node, label: event.target.value })
}
disabled={readOnly}
/>
</FormField>
{(definition?.config_fields ?? []).map((field) => (
<FormField
key={field.id}
label={`${field.label}${field.required ? " *" : ""}`}
help={field.description ?? undefined}
>
{field.kind === "select" ? (
<select
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
>
<option value="">Choose</option>
{field.options.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : field.kind === "textarea" || field.kind === "expression" ? (
<textarea
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
) : field.kind === "mapping" ? (
<textarea
className="workflow-json-editor"
value={jsonDrafts[field.id] ?? "{}"}
onChange={(event) => setJsonDrafts((current) => ({
...current,
[field.id]: event.target.value
}))}
onBlur={() => {
try {
const parsed: unknown = JSON.parse(
jsonDrafts[field.id] ?? "{}"
);
if (!isRecord(parsed)) {
throw new Error(`${field.label} must be a JSON object.`);
}
setError("");
updateConfig(field.id, parsed);
} catch (parseError) {
setError(
parseError instanceof Error
? parseError.message
: `${field.label} is invalid.`
);
}
}}
spellCheck={false}
disabled={readOnly}
/>
) : field.kind === "string_list" ? (
<input
value={stringList(node.config[field.id]).join(", ")}
onChange={(event) => updateConfig(
field.id,
event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
)}
disabled={readOnly}
/>
) : field.kind === "view_surfaces" ? (
<ReferenceMultiSelect
values={stringList(node.config[field.id])}
onChange={(values) => updateConfig(field.id, values)}
provider={viewSurfaceProvider}
aria-label={field.label}
placeholder="Add a visible surface"
searchPlaceholder="Search modules and interface surfaces"
disabled={readOnly}
/>
) : (
<input
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
)}
</FormField>
))}
</div>
</aside>
);
}
function textValue(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function semanticHelpContext(definitionId: string, nodeId: string): string {
return [
"semantic",
"workflow",
"workflow_definition",
definitionId,
`workflow-node-${nodeId}`
].join(".");
}
function semanticAuthoringHref(definitionId: string, nodeId: string): string {
const params = new URLSearchParams({
module: "workflow",
subjectKind: "workflow_definition",
subjectId: definitionId,
routeAnchor: `workflow-node-${nodeId}`
});
return `/docs/semantic?${params}`;
}
@@ -0,0 +1,162 @@
import {
Asterisk,
BadgeDollarSign,
BoxSelect,
Braces,
CircleDashed,
CircleDot,
CircleDotDashed,
CirclePlus,
CircleStop,
Cog,
Database,
Diamond,
ExternalLink,
File,
FileCode2,
GitBranch,
Hand,
Inbox,
MessagesSquare,
Plus,
RadioTower,
RectangleHorizontal,
Rows3,
Scale,
Send,
Shuffle,
Square,
TextQuote,
UserRoundCheck,
CalendarClock,
CheckCircle2,
CirclePlay,
CircleX,
ClipboardCheck,
GitFork,
PlugZap,
Radio,
Split,
SquareCheckBig,
Timer,
Waypoints,
type LucideIcon
} from "lucide-react";
import {
Handle,
Position,
type Node,
type NodeProps
} from "@xyflow/react";
import { DefinitionNodeIcon } from "@govoplan/core-webui";
import type { WorkflowNodeType } from "../../api/workflow";
export type WorkflowFlowNodeData = {
label: string;
workflowType: string;
definition: WorkflowNodeType;
hasError: boolean;
};
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
const iconByName: Record<string, LucideIcon> = {
"calendar-clock": CalendarClock,
"circle-check-big": CheckCircle2,
"circle-play": CirclePlay,
"circle-x": CircleX,
"clipboard-check": ClipboardCheck,
"plug-zap": PlugZap,
radio: Radio,
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints,
asterisk: Asterisk,
"badge-dollar-sign": BadgeDollarSign,
"box-select": BoxSelect,
braces: Braces,
"circle-dashed": CircleDashed,
"circle-dot": CircleDot,
"circle-dot-dashed": CircleDotDashed,
"circle-plus": CirclePlus,
"circle-stop": CircleStop,
cog: Cog,
database: Database,
diamond: Diamond,
"external-link": ExternalLink,
file: File,
"file-code-2": FileCode2,
"git-branch": GitBranch,
hand: Hand,
inbox: Inbox,
"messages-square": MessagesSquare,
plus: Plus,
"radio-tower": RadioTower,
"rectangle-horizontal": RectangleHorizontal,
"rows-3": Rows3,
scale: Scale,
send: Send,
shuffle: Shuffle,
square: Square,
"text-quote": TextQuote,
"user-round-check": UserRoundCheck
};
export default function WorkflowNode({
id,
data,
selected,
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const shape = String(data.definition.metadata?.shape ?? "activity");
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
<div
id={`workflow-node-${id}`}
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
`workflow-node-shape-${shape}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
className="definition-node-handle"
style={{ top: portPosition(index, inputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
<DefinitionNodeIcon className="workflow-node-icon"><Icon size={17} /></DefinitionNodeIcon>
<span className="workflow-node-copy">
<strong>{data.label}</strong>
<small>{data.definition.label}</small>
</span>
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
className="definition-node-handle"
style={{ top: portPosition(index, outputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
</div>
);
}
function portPosition(index: number, count: number): string {
return `${((index + 1) / (count + 1)) * 100}%`;
}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { ListChecks } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listWorkflowInstances,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
export default function WorkflowOpenWorkWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 6, 1, 20);
const includeRunning = configuration.includeRunning !== false;
const load = useCallback(async () => {
const instances = await listWorkflowInstances(settings);
return instances
.filter((instance) =>
instance.status === "waiting"
|| (includeRunning && instance.status === "running")
)
.sort(compareOpenWork)
.slice(0, maxItems);
}, [includeRunning, maxItems, settings]);
const { data: instances, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading open workflow work">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No workflow work is currently open."
items={(instances ?? []).map((instance) => {
const step = currentStep(instance);
return {
id: instance.id,
title: instance.definition_name,
detail: handoffTitle(step),
meta: updatedLabel(instance.updated_at),
leading: <ListChecks size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={instance.status}
label={instance.status === "waiting" ? "Waiting" : "Running"}
/>
),
to: workflowRunUrl(instance)
};
})}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/workflow">
Open Workflow
</Link>
</div>
</LoadingFrame>
);
}
function currentStep(
instance: WorkflowInstance
): WorkflowInstanceStep | null {
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffTitle(step: WorkflowInstanceStep | null): string {
const title = step?.handoff.title;
if (typeof title === "string" && title.trim()) return title;
if (!step) return "Preparing next step";
return step.node_type
.replace(/^workflow\./, "")
.split(".")
.join(" ");
}
function workflowRunUrl(instance: WorkflowInstance): string {
const query = new URLSearchParams({
definition: instance.definition_id,
run: instance.id
});
return `/workflow?${query.toString()}`;
}
function compareOpenWork(
left: WorkflowInstance,
right: WorkflowInstance
): number {
if (left.status !== right.status) {
return left.status === "waiting" ? -1 : 1;
}
return (
new Date(right.updated_at).getTime()
- new Date(left.updated_at).getTime()
);
}
function updatedLabel(value: string): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,621 @@
import {
useCallback,
useEffect,
useMemo,
useState
} from "react";
import {
AlertTriangle,
Check,
Circle,
Clock3,
ExternalLink,
Play,
RefreshCw,
RotateCcw,
XCircle
} from "lucide-react";
import { ActionToolbar,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
ContentGrid,
FormField,
IconButton,
LoadingFrame,
StageRail,
StatusBadge,
dispatchWorkflowViewChanged,
usePlatformUiCapability,
type StageRailTone,
type ApiSettings,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
listWorkflowInstances,
reconcileWorkflowInstance,
resolveWorkflowStep,
startWorkflowInstance,
type WorkflowDefinition,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
type WorkflowAction =
| "complete"
| "approve"
| "changes"
| "reject"
| "resume"
| "retry"
| "confirm_effect"
| "confirm_absent"
| "cancel";
export default function WorkflowRunsDialog({
open,
settings,
definition,
initialInstanceId,
canStart,
canTransition,
onClose
}: {
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
initialInstanceId?: string | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
}) {
const [instances, setInstances] = useState<WorkflowInstance[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
"views.runtime"
);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
[instances, selectedId]
);
const currentStep = useMemo(
() => currentInstanceStep(selected),
[selected]
);
const allowedActions = useMemo(
() => handoffActions(currentStep),
[currentStep]
);
const mergeInstance = useCallback((instance: WorkflowInstance) => {
setInstances((current) => [
instance,
...current.filter((item) => item.id !== instance.id)
]);
setSelectedId(instance.id);
}, []);
const load = useCallback(async () => {
if (!open || !definition?.id) return;
setLoading(true);
setError("");
try {
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
initialInstanceId
&& items.some((item) => item.id === initialInstanceId)
? initialInstanceId
: items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [definition?.id, initialInstanceId, open, settings]);
useEffect(() => {
if (!open) return;
setComment("");
setEvidence("");
void load();
}, [load, open]);
useEffect(() => {
if (!open || !selected) return;
const context = selected.view_context;
if (!context || !viewsRuntime) {
dispatchWorkflowViewChanged(null);
return;
}
let cancelled = false;
void viewsRuntime.resolveWorkflowView(settings, {
viewId: context.view_id,
revisionId: context.revision_id,
visibleSurfaceIds: context.visible_surface_ids
}).then((projection) => {
if (!cancelled) {
dispatchWorkflowViewChanged(projection, selected.id);
}
}).catch((viewError) => {
if (!cancelled) {
dispatchWorkflowViewChanged(null);
setError(errorMessage(viewError));
}
});
return () => {
cancelled = true;
};
}, [
open,
selected?.id,
selected?.updated_at,
settings,
viewsRuntime
]);
useEffect(() => {
if (
!open
|| !canTransition
|| !selected
|| !currentStep
|| currentStep.node_type !== "workflow.dataflow"
|| !["queued", "retrying", "running"].includes(
String(currentStep.handoff.state ?? "")
)
) {
return;
}
let stopped = false;
const poll = window.setInterval(() => {
void reconcileWorkflowInstance(settings, selected.id)
.then((instance) => {
if (!stopped) mergeInstance(instance);
})
.catch((pollError) => {
if (!stopped) setError(errorMessage(pollError));
});
}, 2500);
return () => {
stopped = true;
window.clearInterval(poll);
};
}, [
canTransition,
currentStep,
mergeInstance,
open,
selected,
settings
]);
const start = async () => {
if (!definition?.id) return;
setWorking(true);
setError("");
try {
const instance = await startWorkflowInstance(settings, definition.id, {
idempotency_key: crypto.randomUUID(),
input: {}
});
mergeInstance(instance);
} catch (startError) {
setError(errorMessage(startError));
} finally {
setWorking(false);
}
};
const refreshSelected = async () => {
if (!selected) {
await load();
return;
}
setWorking(true);
setError("");
try {
const instance = canTransition
? await reconcileWorkflowInstance(settings, selected.id)
: (await listWorkflowInstances(settings, definition?.id))
.find((item) => item.id === selected.id);
if (instance) mergeInstance(instance);
else await load();
} catch (refreshError) {
setError(errorMessage(refreshError));
} finally {
setWorking(false);
}
};
const performAction = async (action: WorkflowAction) => {
if (!selected || !currentStep) return;
setWorking(true);
setError("");
try {
const instance = await resolveWorkflowStep(
settings,
selected.id,
currentStep.id,
{
action,
comment: comment.trim() || null,
evidence: evidence
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
);
mergeInstance(instance);
setComment("");
setEvidence("");
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setWorking(false);
}
};
const cancel = async () => {
if (!selected) return;
setWorking(true);
setError("");
try {
mergeInstance(await cancelWorkflowInstance(settings, selected.id));
setCancelOpen(false);
} catch (cancelError) {
setError(errorMessage(cancelError));
} finally {
setWorking(false);
}
};
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
const unavailableOptionalCapabilities = Array.isArray(
currentStep?.handoff.unavailable_optional_capabilities
)
? currentStep.handoff.unavailable_optional_capabilities.filter(
(value): value is string => typeof value === "string" && Boolean(value)
)
: [];
const close = () => {
dispatchWorkflowViewChanged(null);
onClose();
};
return (
<>
<Dialog
open={open}
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={close}
footer={<Button onClick={close}>Close</Button>}
>
<ActionToolbar className="workflow-runs-toolbar">
<span>
<strong>Workflow instances</strong>
<small>Revision-pinned runs and human handoffs</small>
</span>
<span>
<IconButton
label="Refresh runs"
icon={<RefreshCw size={16} />}
variant="ghost"
onClick={() => void refreshSelected()}
disabled={loading || working}
/>
<Button
variant="primary"
onClick={() => void start()}
disabled={!canStart || working || definition?.status !== "active"}
disabledReason={
definition?.status !== "active"
? "Activate a definition revision before starting it."
: undefined
}
>
<Play size={16} /> Start
</Button>
</span>
</ActionToolbar>
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<LoadingFrame loading={loading} className="workflow-runs-frame">
<div className="workflow-runs-layout">
<div className="workflow-run-list">
{instances.map((instance) => (
<button
key={instance.id}
type="button"
className={instance.id === selected?.id ? "is-selected" : ""}
onClick={() => {
setSelectedId(instance.id);
setComment("");
setEvidence("");
}}
>
<span>
<strong>{formatDateTime(instance.started_at)}</strong>
<small>
Revision {instance.definition_revision} · {instance.steps.length} steps
</small>
</span>
<StatusBadge
status={instance.status}
label={instance.status}
/>
</button>
))}
{!instances.length ? (
<div className="workflow-run-empty">No runs yet</div>
) : null}
</div>
<div className="workflow-run-detail">
{selected ? (
<>
<header>
<span>
<strong>{selected.definition_name}</strong>
<small>
Revision {selected.definition_revision} · {selected.definition_hash.slice(0, 12)}
</small>
</span>
<span>
<StatusBadge status={selected.status} label={selected.status} />
{["running", "waiting"].includes(selected.status) ? (
<IconButton
label="Cancel workflow run"
icon={<XCircle size={16} />}
variant="danger"
onClick={() => setCancelOpen(true)}
disabled={!canTransition || working}
/>
) : null}
</span>
</header>
{selected.error ? (
<DismissibleAlert tone="danger" resetKey={selected.error}>
{selected.error}
</DismissibleAlert>
) : null}
{selected.view_context && !viewsRuntime ? (
<DismissibleAlert
tone="warning"
resetKey={`view-unavailable:${selected.id}:${selected.current_step_id ?? "none"}`}
>
The focused View is unavailable. Workflow position is preserved;
use the linked module action or ask an administrator to enable Views.
</DismissibleAlert>
) : null}
{currentStep ? (
<section className="workflow-run-handoff">
<div>
<span>
<strong>
{String(
currentStep.handoff.title
?? currentStep.handoff.kind
?? currentStep.node_type
)}
</strong>
<small>
Step {currentStep.sequence} · attempt {currentStep.attempt}
</small>
</span>
<StatusBadge
status={String(currentStep.handoff.state ?? currentStep.status)}
label={String(currentStep.handoff.state ?? currentStep.status)}
/>
</div>
{typeof currentStep.handoff.message === "string" ? (
<p>{currentStep.handoff.message}</p>
) : null}
{typeof currentStep.handoff.instructions === "string"
&& currentStep.handoff.instructions ? (
<p>{currentStep.handoff.instructions}</p>
) : null}
{unavailableOptionalCapabilities.length ? (
<DismissibleAlert
tone="info"
resetKey={`optional:${currentStep.id}:${unavailableOptionalCapabilities.join(",")}`}
>
Optional integration unavailable: {unavailableOptionalCapabilities.join(", ")}.
The hand-off remains valid and resumable.
</DismissibleAlert>
) : null}
{actionUrl ? (
<a href={actionUrl}>
Open linked work <ExternalLink size={14} />
</a>
) : null}
{allowedActions.some((action) => action !== "cancel") ? (
<ContentGrid columns={2} gap="compact" collapseAt="narrow" className="workflow-run-action-form">
<FormField label="Comment">
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<FormField
label="Evidence references"
help="Enter one durable evidence reference per line."
>
<textarea
value={evidence}
onChange={(event) => setEvidence(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<div className="workflow-run-actions">
{allowedActions
.filter((action) => action !== "cancel")
.map((action) => (
<Button
key={action}
variant={
action === "reject"
? "danger"
: action === "approve"
|| action === "complete"
|| action === "resume"
|| action === "confirm_effect"
? "primary"
: undefined
}
onClick={() => void performAction(action)}
disabled={!canTransition || working}
>
{action === "retry" ? <RotateCcw size={15} /> : null}
{actionLabel(action)}
</Button>
))}
</div>
</ContentGrid>
) : null}
</section>
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<StageRail
ariaLabel="Workflow instance progress"
items={selected.steps.map((step) => ({
id: step.id,
label: step.node_id,
detail: `${step.node_type} · attempt ${step.attempt}`,
statusLabel: step.status,
current: step.id === selected.current_step_id,
tone: stepTone(step.status),
icon: step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)
}))}
/>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
<div>
{[...selected.events].reverse().map((event) => (
<span key={event.id}>
<strong>{event.kind}</strong>
<small>
{formatDateTime(event.created_at)}
{event.actor_id ? ` · ${event.actor_id}` : ""}
</small>
</span>
))}
</div>
</section>
</>
) : (
<div className="workflow-run-empty">
Start a run to track its progress here.
</div>
)}
</div>
</div>
</LoadingFrame>
</Dialog>
<ConfirmDialog
open={cancelOpen}
title="Cancel workflow run"
message="Cancel this workflow instance and its active Dataflow run?"
confirmLabel="Cancel run"
tone="danger"
busy={working}
onCancel={() => setCancelOpen(false)}
onConfirm={() => void cancel()}
/>
</>
);
}
function currentInstanceStep(
instance: WorkflowInstance | null
): WorkflowInstanceStep | null {
if (!instance?.current_step_id) return null;
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffActions(step: WorkflowInstanceStep | null): WorkflowAction[] {
const actions = step?.handoff.allowed_actions;
if (!Array.isArray(actions)) return [];
return actions.filter((action): action is WorkflowAction => (
typeof action === "string"
&& [
"complete",
"approve",
"changes",
"reject",
"resume",
"retry",
"confirm_effect",
"confirm_absent",
"cancel"
].includes(action)
));
}
function actionLabel(action: WorkflowAction): string {
return {
complete: "Complete",
approve: "Approve",
changes: "Request changes",
reject: "Reject",
resume: "Resume",
retry: "Retry",
confirm_effect: "Effect confirmed",
confirm_absent: "Effect absent",
cancel: "Cancel"
}[action];
}
function stepTone(
status: WorkflowInstanceStep["status"]
): StageRailTone {
if (status === "completed") return "success";
if (status === "running" || status === "waiting") return "active";
if (status === "failed" || status === "cancelled") return "danger";
return "neutral";
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "The Workflow request failed.";
}
+327
View File
@@ -0,0 +1,327 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
DefinitionKind,
DefinitionScopeType,
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowExecutionMode,
WorkflowNodeType,
WorkflowStandardProvenance,
WorkflowStatus
} from "../../api/workflow";
export type WorkflowDraft = {
id?: string;
name: string;
description: string;
status: WorkflowStatus;
currentRevision?: number;
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
scopeType: DefinitionScopeType;
scopeId: string;
definitionKind: DefinitionKind;
inheritToLowerScopes: boolean;
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
executionMode: WorkflowExecutionMode;
viewId: string;
viewRevisionId: string;
governance: WorkflowGovernance | null;
standard: WorkflowStandardProvenance | null;
};
const inputPort = [{
id: "incoming",
label: "Incoming",
required: true,
multiple: true,
minimum_connections: 1
}];
const outputPort = [{
id: "outgoing",
label: "Outgoing",
required: false,
multiple: true,
minimum_connections: 0
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "bpmn.startEvent",
category: "bpmn_event",
category_label: "Events",
label: "Start event",
description: "Start a BPMN process.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-start" }
},
{
type: "bpmn.userTask",
category: "bpmn_activity",
category_label: "Activities",
label: "User task",
description: "A governed user task.",
icon: "user-round-check",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
{
id: "title",
label: "Title",
kind: "text",
required: true,
options: []
}
],
default_config: {
title: "",
instructions: "",
assignee: "",
due_after: "",
task_mode: "activity",
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "activity" }
},
{
type: "bpmn.endEvent",
category: "bpmn_event",
category_label: "Events",
label: "End event",
description: "End the BPMN process.",
icon: "circle-stop",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
},
metadata: { notation: "bpmn-2.0", shape: "event-end" }
}
];
export function sampleWorkflowDraft(): WorkflowDraft {
return {
name: "New workflow",
description: "",
status: "draft",
metadata: {},
scopeType: "tenant",
scopeId: "",
definitionKind: "flow",
inheritToLowerScopes: false,
allowStart: true,
allowReuse: false,
allowAutomation: false,
executionMode: "hybrid",
viewId: "",
viewRevisionId: "",
governance: null,
standard: null,
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "bpmn.startEvent",
label: "Start",
position: { x: 60, y: 150 },
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
start_kind: "manual",
input_schema_ref: "",
documentation: ""
}
},
{
id: "activity",
type: "bpmn.userTask",
label: "Activity",
position: { x: 320, y: 150 },
size: { width: 120, height: 80 },
process_id: "Process_1",
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: "",
task_mode: "activity",
documentation: ""
}
},
{
id: "complete",
type: "bpmn.endEvent",
label: "Completed",
position: { x: 580, y: 150 },
size: { width: 36, height: 36 },
process_id: "Process_1",
config: {
event_definition: "none",
outcome: "completed",
output_mapping: {},
documentation: ""
}
}
],
edges: [
{
id: "start-activity",
type: "bpmn.sequenceFlow",
label: "",
source: "start",
target: "activity",
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
},
{
id: "activity-complete",
type: "bpmn.sequenceFlow",
label: "",
source: "activity",
target: "complete",
source_port: "outgoing",
target_port: "incoming",
config: {},
waypoints: []
}
],
metadata: {
notation: "bpmn-2.0",
bpmn: {
definitions_id: "Definitions_1",
target_namespace: "urn:govoplan:workflow",
processes: [{
id: "Process_1",
name: "",
is_executable: true,
attributes: {}
}],
collaborations: [],
choreographies: [],
root_elements_xml: []
}
}
}
};
}
export function draftFromDefinition(
definition: WorkflowDefinition
): WorkflowDraft {
return {
id: definition.id,
name: definition.name,
description: definition.description ?? "",
status: definition.status,
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph),
scopeType: definition.governance.scope_type,
scopeId: definition.governance.scope_id ?? "",
definitionKind: definition.governance.definition_kind,
inheritToLowerScopes: definition.governance.inherit_to_lower_scopes,
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
executionMode: definition.revision.execution_mode,
viewId: definition.revision.view_id ?? "",
viewRevisionId: definition.revision.view_revision_id ?? "",
governance: definition.governance,
standard: definition.standard ?? null
};
}
export function workflowPayload(
draft: WorkflowDraft
): WorkflowDefinitionPayload {
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata,
scope_type: draft.scopeType,
scope_id: draft.scopeId.trim() || null,
definition_kind: draft.definitionKind,
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation,
execution_mode: draft.executionMode,
view_id: draft.viewId || null,
view_revision_id: draft.viewRevisionId || null
};
}
export function workflowFingerprint(
draft: WorkflowDraft | null
): string {
if (!draft) return "";
return JSON.stringify({
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata,
scopeType: draft.scopeType,
scopeId: draft.scopeId,
definitionKind: draft.definitionKind,
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation,
executionMode: draft.executionMode,
viewId: draft.viewId,
viewRevisionId: draft.viewRevisionId
});
}
export function newWorkflowNode(
type: string,
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
const node = createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
const shape = library.find((item) => item.type === type)?.metadata?.shape;
return {
...node,
process_id: "Process_1",
size: defaultNodeSize(String(shape ?? "activity"))
};
}
function defaultNodeSize(shape: string): { width: number; height: number } {
if (shape.startsWith("event")) return { width: 36, height: 36 };
if (shape === "gateway") return { width: 50, height: 50 };
if (shape === "participant") return { width: 600, height: 180 };
if (shape === "lane") return { width: 560, height: 140 };
if (shape === "data-object") return { width: 36, height: 50 };
if (shape === "data-store") return { width: 50, height: 50 };
return { width: 120, height: 80 };
}
+2
View File
@@ -0,0 +1,2 @@
export { workflowModule as default, workflowModule } from "./module";
export * from "./api/workflow";
+111
View File
@@ -0,0 +1,111 @@
import { createElement, lazy } from "react";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "@xyflow/react/dist/style.css";
import "./styles/workflow.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const WorkflowOpenWorkWidget = lazy(
() => import("./features/workflow/WorkflowOpenWorkWidget")
);
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
const instanceReadScopes = [
"workflow:instance:read",
"workflow:instance:admin"
];
const workflowDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "workflow.open-work",
surfaceId: "workflow.widget.open-work",
title: "Open workflow work",
description: "Running workflows and steps that require attention.",
moduleId: "workflow",
category: "Work",
order: 42,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: instanceReadScopes,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 6,
includeRunning: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum items",
kind: "number",
min: 1,
max: 20,
step: 1,
required: true
},
{
id: "includeRunning",
label: "Include running steps",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(WorkflowOpenWorkWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const workflowModule: PlatformWebModule = {
id: "workflow",
label: "Workflow",
version: "0.1.14",
optionalDependencies: [
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks"
],
viewSurfaces: [
{
id: "workflow.widget.open-work",
moduleId: "workflow",
kind: "section",
label: "Open workflow work widget",
order: 76
}
],
navItems: [
{
to: "/workflow",
label: "Workflow",
iconName: "workflow",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/workflow",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
],
uiCapabilities: {
"dashboard.widgets": workflowDashboardWidgets
}
};
export default workflowModule;
+838
View File
@@ -0,0 +1,838 @@
.workflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
.workflow-standard-comparison-dialog {
width: min(1180px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.workflow-standard-comparison {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 10px;
overflow: hidden;
}
.workflow-standard-comparison-summary {
display: flex;
align-items: center;
gap: 18px;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.workflow-standard-comparison-summary .status-badge {
margin-left: auto;
}
.workflow-standard-diff-list {
display: grid;
min-height: 0;
gap: 6px;
overflow: auto;
}
.workflow-standard-diff-item {
padding: 10px 12px;
border: var(--border-line);
border-left: 3px solid var(--success);
border-radius: var(--radius-sm);
background: var(--panel);
}
.workflow-standard-diff-item.state-conflict {
border-left-color: var(--warning);
}
.workflow-standard-diff-item.state-upstream_only {
border-left-color: var(--info-text-strong);
}
.workflow-standard-diff-item header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.workflow-standard-diff-item header > span {
display: grid;
min-width: 0;
gap: 2px;
}
.workflow-standard-diff-item small,
.workflow-standard-diff-item p,
.workflow-standard-diff-empty {
color: var(--muted);
}
.workflow-standard-diff-item p {
margin: 6px 0 0;
}
@media (max-width: 900px) {
.workflow-standard-comparison-summary {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.workflow-standard-comparison-summary .status-badge {
margin-left: 0;
}
}
.workflow-definition-fields {
display: grid;
gap: 12px;
}
.workflow-definition-fields input,
.workflow-definition-fields select {
width: 100%;
}
.workflow-provenance span,
.workflow-provenance small {
color: var(--muted);
font-size: 12px;
}
.workflow-provenance code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.workflow-workspace,
.workflow-editor,
.workflow-canvas,
.workflow-definition-list-frame,
.workflow-inspector-column {
min-width: 0;
min-height: 0;
}
.workflow-toolbar-actions,
.workflow-command-bar,
.workflow-identity-fields {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-definition-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.workflow-workspace-toolbar {
min-width: 0;
}
.workflow-identity-fields {
min-width: 240px;
flex: 1 1 auto;
}
.workflow-identity-fields input,
.workflow-revision-select {
min-height: 34px;
padding: 7px 9px;
}
.workflow-name-input {
max-width: 250px;
color: var(--text-strong);
font-weight: 700;
}
.workflow-description-input {
min-width: 140px;
flex: 1 1 240px;
}
.workflow-revision-select {
width: 142px;
}
.workflow-command-bar {
justify-content: flex-end;
flex-wrap: wrap;
}
.workflow-command-bar .btn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
white-space: nowrap;
}
.workflow-alerts {
position: absolute;
z-index: 12;
top: 66px;
right: 12px;
display: grid;
width: min(500px, calc(100% - 24px));
gap: 6px;
pointer-events: none;
}
.workflow-alerts .alert {
pointer-events: auto;
box-shadow: var(--shadow-popover);
}
.workflow-editor {
display: grid;
grid-template-columns: 210px minmax(0, 1fr) minmax(260px, 310px);
flex: 1 1 auto;
overflow: hidden;
}
.workflow-bpmn-file-input {
display: none;
}
.workflow-inspector,
.workflow-inspector-column {
overflow: hidden;
background: var(--panel-soft);
}
.workflow-inspector-column {
display: flex;
flex-direction: column;
border-left: var(--border-line);
}
.workflow-inspector {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.workflow-panel-heading > span {
min-width: 0;
}
.workflow-panel-heading strong,
.workflow-panel-heading small {
display: block;
}
.workflow-panel-heading strong {
color: var(--text-strong);
font-size: 13px;
}
.workflow-panel-heading small {
overflow: hidden;
margin-top: 2px;
color: var(--muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-node {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
height: 100%;
min-height: 56px;
box-sizing: border-box;
border: 1px solid var(--line-dark);
border-radius: var(--radius-compact);
background: var(--panel);
box-shadow: var(--shadow-xs);
padding: 8px 10px;
}
.workflow-node-bpmn_activity {
border-width: 2px;
}
.workflow-node-bpmn_collaboration {
border-width: 2px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
}
.workflow-node-shape-participant,
.workflow-node-shape-lane {
align-items: flex-start;
padding: 12px;
}
.workflow-node-shape-lane {
border-width: 1px;
}
.workflow-node-shape-group {
align-items: flex-start;
border: 2px dashed var(--line-dark);
background: transparent;
box-shadow: none;
}
.workflow-node-shape-text-annotation {
border: 0;
border-left: 2px solid var(--line-dark);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"],
.workflow-node-shape-gateway {
justify-content: flex-start;
border: 0;
background: transparent;
box-shadow: none;
padding: 4px;
}
.workflow-node[class*="workflow-node-shape-event"] .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: var(--radius-round);
background: var(--panel);
}
.workflow-node-shape-event-intermediate-catch .workflow-node-icon,
.workflow-node-shape-event-intermediate-throw .workflow-node-icon {
box-shadow: inset 0 0 0 3px var(--panel), inset 0 0 0 4px var(--text-strong);
}
.workflow-node-shape-event-boundary .workflow-node-icon {
border-style: dashed;
}
.workflow-node-shape-event-end .workflow-node-icon {
border-width: 4px;
}
.workflow-node-shape-gateway .workflow-node-icon {
width: 42px;
height: 42px;
flex-basis: 42px;
border: 2px solid var(--text-strong);
border-radius: var(--radius-hairline);
background: var(--panel);
transform: rotate(45deg);
}
.workflow-node-shape-gateway .workflow-node-icon svg {
transform: rotate(-45deg);
}
.workflow-node-shape-data-object,
.workflow-node-shape-data-store,
.workflow-node-shape-conversation,
.workflow-node-shape-choreography {
border-width: 2px;
}
.workflow-node.is-selected {
border-color: var(--accent);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent),
var(--shadow-xs);
}
.workflow-node[class*="workflow-node-shape-event"].is-selected,
.workflow-node-shape-gateway.is-selected {
box-shadow: none;
}
.workflow-node[class*="workflow-node-shape-event"].is-selected .workflow-node-icon,
.workflow-node-shape-gateway.is-selected .workflow-node-icon {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
.workflow-node.has-error {
border-color: var(--danger-text);
}
.workflow-node[class*="workflow-node-shape-event"].has-error .workflow-node-icon,
.workflow-node-shape-gateway.has-error .workflow-node-icon {
border-color: var(--danger-text);
}
.workflow-node-copy {
min-width: 0;
flex: 1 1 auto;
}
.workflow-node-copy strong,
.workflow-node-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-node-copy strong {
color: var(--text-strong);
font-size: 12px;
}
.workflow-node-copy small {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
}
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
.workflow-edge-bpmn-association .react-flow__edge-path,
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
stroke-width: 1.5;
}
.workflow-inspector-checkbox {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 12px;
}
.workflow-inspector-checkbox input {
width: auto;
margin: 0;
}
.workflow-inspector-fields {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
height: calc(100% - 44px);
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
padding: 12px;
scrollbar-gutter: stable;
}
.workflow-inspector-fields input,
.workflow-inspector-fields select,
.workflow-inspector-fields textarea {
margin-top: 5px;
padding: 7px 8px;
font-size: 12px;
}
.workflow-inspector-fields textarea {
min-height: 82px;
resize: vertical;
}
.workflow-json-editor {
min-height: 150px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.45;
}
.workflow-inspector-delete {
color: var(--danger-text);
}
.workflow-inspector-actions {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.workflow-inspector-actions .btn {
white-space: nowrap;
}
.workflow-diagnostics {
display: flex;
max-height: 38%;
min-height: 120px;
flex: 0 1 38%;
flex-direction: column;
border-top: var(--border-line-dark);
}
.workflow-diagnostics > div:last-child {
overflow: auto;
padding: 5px;
}
.workflow-diagnostics button {
display: block;
width: 100%;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px;
text-align: left;
}
.workflow-diagnostics button:hover {
background: var(--primary-soft);
}
.workflow-diagnostics button strong,
.workflow-diagnostics button small {
display: block;
}
.workflow-diagnostics button strong {
font-size: 11px;
}
.workflow-diagnostics button small {
margin-top: 3px;
color: var(--muted);
font-size: 9px;
}
.workflow-runs-dialog {
width: min(1120px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.workflow-runs-dialog-body {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding: 0;
}
.workflow-runs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 0 0 auto;
min-height: 56px;
border-bottom: var(--border-line);
padding: 9px 12px;
}
.workflow-runs-toolbar > span {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-runs-toolbar > span:first-child {
display: grid;
gap: 2px;
}
.workflow-runs-toolbar small {
color: var(--muted);
font-size: 11px;
}
.workflow-runs-frame,
.workflow-runs-layout,
.workflow-run-list,
.workflow-run-detail {
min-width: 0;
min-height: 0;
}
.workflow-runs-frame {
flex: 1 1 auto;
overflow: hidden;
}
.workflow-runs-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
height: 100%;
overflow: hidden;
}
.workflow-run-list {
overflow: auto;
border-right: var(--border-line);
background: var(--panel-soft);
padding: 6px;
}
.workflow-run-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 54px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.workflow-run-list > button:hover,
.workflow-run-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.workflow-run-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.workflow-run-list strong,
.workflow-run-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-list strong {
color: var(--text-strong);
font-size: 12px;
}
.workflow-run-list small {
margin-top: 4px;
color: var(--muted);
font-size: 10px;
}
.workflow-run-detail {
display: flex;
flex-direction: column;
overflow: auto;
}
.workflow-run-detail > header,
.workflow-run-handoff > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.workflow-run-detail > header {
position: sticky;
z-index: 2;
top: 0;
min-height: 56px;
border-bottom: var(--border-line);
background: var(--panel);
padding: 9px 12px;
}
.workflow-run-detail > header > span {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-run-detail > header > span:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.workflow-run-detail > header strong,
.workflow-run-detail > header small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-detail > header small {
color: var(--muted);
font-size: 10px;
}
.workflow-run-handoff,
.workflow-run-history,
.workflow-run-events {
display: grid;
gap: 10px;
border-bottom: var(--border-line);
padding: 12px;
}
.workflow-run-handoff p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.workflow-run-handoff a {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
color: var(--accent);
font-size: 12px;
}
.workflow-run-action-form textarea {
width: 100%;
resize: vertical;
}
.workflow-run-actions {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 7px;
}
.workflow-run-actions .btn {
display: inline-flex;
align-items: center;
gap: 5px;
}
.workflow-run-history h3,
.workflow-run-events h3 {
margin: 0;
color: var(--text-strong);
font-size: 12px;
}
.workflow-run-history > div,
.workflow-run-events > div {
display: grid;
}
.workflow-run-events > div > span {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
align-items: center;
gap: 9px;
min-height: 38px;
border-top: var(--border-line);
padding: 6px 2px;
}
.workflow-run-history .stage-rail {
padding: 2px 0 4px;
}
.workflow-run-history small,
.workflow-run-events small {
color: var(--muted);
font-size: 10px;
}
.workflow-run-empty {
display: grid;
min-height: 120px;
place-items: center;
color: var(--muted);
font-size: 12px;
text-align: center;
}
@media (max-width: 1280px) {
.workflow-workspace-toolbar {
align-items: stretch;
flex-direction: column;
}
.workflow-command-bar {
justify-content: flex-start;
}
.workflow-editor {
grid-template-columns: 180px minmax(0, 1fr) 270px;
}
}
@media (max-width: 900px) {
.workflow-editor {
grid-template-columns: 140px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
}
.workflow-inspector-column {
grid-column: 1 / -1;
border-top: var(--border-line);
border-left: 0;
}
}
@media (max-width: 680px) {
.workflow-page {
height: calc(100vh - 94px);
}
.workflow-identity-fields,
.workflow-command-bar {
flex-wrap: wrap;
}
.workflow-name-input,
.workflow-description-input {
max-width: none;
min-width: 180px;
}
.workflow-editor {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
}
.workflow-runs-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
}
.workflow-run-list {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-run-history > div > span,
.workflow-run-events > div > span {
grid-template-columns: minmax(0, 1fr) auto;
}
.workflow-run-history > div > span small,
.workflow-run-events > div > span small {
grid-column: 1 / -1;
}
}
@media (prefers-reduced-motion: reduce) {
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+16
View File
@@ -0,0 +1,16 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModules: PlatformWebModule[];
export { installedWebModules };
export default installedWebModules;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/production/index.d.ts"]
}
},
"include": ["src"]
}