6 Commits
v0.1.8 ... main

37 changed files with 7018 additions and 3 deletions

View File

@@ -4,14 +4,40 @@
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-workflow` will own process orchestration for GovOPlaN.
`govoplan-workflow` owns process orchestration for GovOPlaN.
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.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
Workflow exposes a workflow-specific node library, a full definition editor,
validation APIs, tenant-isolated definitions, and immutable graph revisions.
Activation pins the exact revision that future instances will execute. It uses
Core's domain-neutral definition graph contract, but applies Workflow
constraints: exactly one trigger, at least one outcome, governed configuration
fields, connected nodes, and permitted loops for correction and retry paths.
Dataflow uses the same graph contract with its own acyclic transformation
library.
The current slice deliberately stops before executing process instances.
Instance state, resumable transitions, human activities, retries, and event
subscriptions must build on the pinned definition and versioned module
capabilities rather than bypassing them.
Definitions can be complete flows or non-runnable templates at system,
tenant, group, or user scope. Policy resolves whether a definition can be
viewed, edited, started, reused, derived, or automated and returns the ordered
source path shown in the editor. Derivation copies an immutable graph revision
and records its hash, node-library version, source scope, actor, Policy
decision, and effective ancestor limits.
The start-node library distinguishes explicit user, API, scheduled, event, and
parent-workflow starts. These are definition contracts only until the
resumable Workflow instance runtime is implemented; the UI reports that
limitation rather than presenting automation as operational.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
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

View File

@@ -95,7 +95,17 @@ 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
The next execution slices should provide:
- static workflow definition registration from configuration packages
- create/read/list workflow instances

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow"
version = "0.1.14"
description = "Governed process definitions and orchestration contracts for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[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"

View File

@@ -0,0 +1,3 @@
"""GovOPlaN Workflow module."""
__version__ = "0.1.14"

View File

@@ -0,0 +1 @@
"""Workflow backend."""

View File

@@ -0,0 +1,9 @@
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
__all__ = [
"WorkflowDefinition",
"WorkflowDefinitionRevision",
]

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class WorkflowDefinition(Base, TimestampMixin):
__tablename__ = "workflow_definitions"
__table_args__ = (
UniqueConstraint(
"scope_key",
"definition_key",
name="uq_workflow_definition_key",
),
Index("ix_workflow_definitions_tenant_status", "tenant_id", "status"),
Index("ix_workflow_definitions_tenant_updated", "tenant_id", "updated_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_type: Mapped[str] = mapped_column(
String(20),
default="tenant",
nullable=False,
index=True,
)
scope_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_key: Mapped[str] = mapped_column(
String(80),
nullable=False,
index=True,
)
definition_kind: Mapped[str] = mapped_column(
String(20),
default="flow",
nullable=False,
index=True,
)
inherit_to_lower_scopes: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
allow_start: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
allow_reuse: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
allow_automation: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
derived_from_definition_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
derived_from_revision: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
derived_from_hash: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
)
derivation_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
definition_key: Mapped[str] = mapped_column(String(120), nullable=False)
name: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(
String(32),
default="draft",
nullable=False,
index=True,
)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
active_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
updated_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
revisions: Mapped[list["WorkflowDefinitionRevision"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="WorkflowDefinitionRevision.revision",
)
class WorkflowDefinitionRevision(Base, TimestampMixin):
__tablename__ = "workflow_definition_revisions"
__table_args__ = (
UniqueConstraint(
"definition_id",
"revision",
name="uq_workflow_definition_revision",
),
Index(
"ix_workflow_definition_revisions_tenant_definition",
"tenant_id",
"definition_id",
),
Index(
"ix_workflow_definition_revisions_content_hash",
"tenant_id",
"content_hash",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
definition_id: Mapped[str] = mapped_column(
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
graph: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
library_id: Mapped[str] = mapped_column(String(100), nullable=False)
library_version: Mapped[str] = mapped_column(String(40), nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
definition: Mapped[WorkflowDefinition] = relationship(back_populates="revisions")
__all__ = [
"WorkflowDefinition",
"WorkflowDefinitionRevision",
"new_uuid",
]

View File

@@ -0,0 +1,320 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Literal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.policy import (
DefinitionGovernanceAction,
DefinitionGovernanceRequest,
DefinitionScopeRef,
PolicyDecision,
PolicySourceStep,
definition_governance_policy,
)
from govoplan_workflow.backend.db.models import WorkflowDefinition
WorkflowAction = Literal[
"view",
"edit",
"start",
"reuse",
"derive",
"automate",
]
WORKFLOW_ACTIONS: tuple[WorkflowAction, ...] = (
"view",
"edit",
"start",
"reuse",
"derive",
"automate",
)
def normalize_definition_scope(
principal: ApiPrincipal,
*,
scope_type: str,
scope_id: str | None,
administrative: bool,
) -> tuple[str | None, str, str | None, str]:
clean_type = scope_type.strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type == "system":
if not has_scope(principal, "system:governance:write"):
raise PermissionError(
"System definitions require system governance permission."
)
if clean_id is not None:
raise ValueError("System definitions do not carry a scope ID.")
return None, "system", None, "system"
if clean_type == "tenant":
if clean_id not in {None, principal.tenant_id}:
raise PermissionError(
"Definitions can only be created for the active tenant."
)
return (
principal.tenant_id,
"tenant",
principal.tenant_id,
f"tenant:{principal.tenant_id}",
)
if clean_type == "group":
if not clean_id:
raise ValueError("Group definitions require a group ID.")
if clean_id not in principal.group_ids and not administrative:
raise PermissionError(
"Definitions can only be created for one of the actor's groups."
)
return principal.tenant_id, "group", clean_id, f"group:{clean_id}"
if clean_type == "user":
clean_id = clean_id or principal.account_id
if (
clean_id not in {principal.membership_id, principal.account_id}
and not administrative
):
raise PermissionError(
"Definitions can only be created for the current user."
)
if clean_id == principal.membership_id:
clean_id = principal.account_id
return principal.tenant_id, "user", clean_id, f"user:{clean_id}"
raise ValueError(
"Definition scope must be system, tenant, group, or user."
)
def definition_decision(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
action: WorkflowAction,
) -> PolicyDecision:
policy_action: DefinitionGovernanceAction = (
"run" if action == "start" else action
)
request = DefinitionGovernanceRequest(
module_id="workflow",
definition_ref=f"workflow-definition:{definition.id}",
tenant_id=principal.tenant_id,
definition_scope=DefinitionScopeRef(
scope_type=definition.scope_type, # type: ignore[arg-type]
scope_id=definition.scope_id,
),
target_scope=_target_scope(definition, principal),
definition_kind=definition.definition_kind, # type: ignore[arg-type]
action=policy_action,
actor=principal.to_platform_principal(),
status=definition.status,
inherit_to_lower_scopes=definition.inherit_to_lower_scopes,
allow_run=definition.allow_start,
allow_reuse=definition.allow_reuse,
allow_automation=definition.allow_automation,
context=_ancestor_context(definition),
)
provider = definition_governance_policy(registry)
if provider is not None:
return provider.resolve_definition_action(request=request)
return _tenant_local_fallback(request, displayed_action=action)
def definition_governance_payload(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
) -> dict[str, object]:
actions = {
action: definition_decision(
definition,
principal=principal,
registry=registry,
action=action,
).to_dict()
for action in WORKFLOW_ACTIONS
}
return {
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
"definition_kind": definition.definition_kind,
"inherit_to_lower_scopes": definition.inherit_to_lower_scopes,
"allow_start": definition.allow_start,
"allow_reuse": definition.allow_reuse,
"allow_automation": definition.allow_automation,
"derived_from_definition_id": (
definition.derived_from_definition_id
),
"derived_from_revision": definition.derived_from_revision,
"derived_from_hash": definition.derived_from_hash,
"derivation_provenance": dict(definition.derivation_provenance),
"actions": actions,
"automation_runtime_available": False,
"automation_runtime_reason": (
"Workflow start definitions are persisted and governed, but "
"automatic instance dispatch requires the Workflow runtime."
),
}
def require_definition_action(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
action: WorkflowAction,
) -> PolicyDecision:
decision = definition_decision(
definition,
principal=principal,
registry=registry,
action=action,
)
if not decision.allowed:
raise PermissionError(
decision.reason or f"Definition action is not allowed: {action}"
)
return decision
def _target_scope(
definition: WorkflowDefinition,
principal: ApiPrincipal,
) -> DefinitionScopeRef:
if (
definition.scope_type == "group"
and definition.scope_id in principal.group_ids
):
return DefinitionScopeRef("group", definition.scope_id)
if definition.scope_type == "user" and definition.scope_id in {
principal.membership_id,
principal.account_id,
}:
return DefinitionScopeRef("user", definition.scope_id)
return DefinitionScopeRef("tenant", principal.tenant_id)
def _ancestor_context(
definition: WorkflowDefinition,
) -> dict[str, object]:
provenance = definition.derivation_provenance
limits = provenance.get("source_effective_limits")
source = provenance.get("source_scope")
context: dict[str, object] = {}
if isinstance(limits, Mapping):
context["ancestor_limits"] = {
"inherit_to_lower_scopes": bool(
limits.get("inherit_to_lower_scopes", True)
),
"allow_run": bool(limits.get("allow_start")),
"allow_reuse": bool(limits.get("allow_reuse")),
"allow_automation": bool(limits.get("allow_automation")),
}
if isinstance(source, Mapping):
context["ancestor_source"] = dict(source)
return context
def _tenant_local_fallback(
request: DefinitionGovernanceRequest,
*,
displayed_action: WorkflowAction,
) -> PolicyDecision:
ancestor = request.context.get("ancestor_limits")
ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {}
effective_limits = {
"inherit_to_lower_scopes": (
request.inherit_to_lower_scopes
and _fallback_ancestor_flag(
ancestor_limits,
"inherit_to_lower_scopes",
)
),
"allow_run": request.allow_run
and _fallback_ancestor_flag(ancestor_limits, "allow_run"),
"allow_reuse": request.allow_reuse
and _fallback_ancestor_flag(ancestor_limits, "allow_reuse"),
"allow_automation": request.allow_automation
and _fallback_ancestor_flag(
ancestor_limits,
"allow_automation",
),
}
local = (
request.definition_scope.scope_type == "tenant"
and request.definition_scope.scope_id == request.tenant_id
and request.actor.tenant_id == request.tenant_id
)
allowed = False
reason: str | None = None
if not local:
reason = (
"Inherited definitions require the Policy module; only local "
"tenant definitions are available."
)
elif request.action in {"view", "edit"}:
allowed = True
elif request.action == "run":
allowed = (
request.definition_kind == "flow"
and request.status == "active"
and effective_limits["allow_run"]
)
reason = (
None
if allowed
else "Only active local flows with starting enabled can start."
)
else:
reason = "Definition reuse and automation require the Policy module."
return PolicyDecision(
allowed=allowed,
reason=reason,
source_path=(
PolicySourceStep(
scope_type=request.definition_scope.scope_type,
scope_id=request.definition_scope.scope_id,
label="Tenant-local conservative fallback",
applied_fields=(
"definition_kind",
"status",
"allow_run",
),
policy={
"policy_module_available": False,
"definition_kind": request.definition_kind,
"status": request.status,
"allow_start": request.allow_run,
},
),
),
requirements=(
()
if allowed
else (f"workflow.definition.{displayed_action}",)
),
details={
"fallback": "tenant_local",
"action": displayed_action,
"effective_limits": effective_limits,
},
)
def _fallback_ancestor_flag(
limits: Mapping[str, object],
key: str,
) -> bool:
value = limits.get(key)
return True if value is None else value is True
__all__ = [
"WORKFLOW_ACTIONS",
"definition_decision",
"definition_governance_payload",
"normalize_definition_scope",
"require_definition_action",
]

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
)
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db import models as workflow_models
MODULE_ID = "workflow"
MODULE_NAME = "Workflow"
MODULE_VERSION = "0.1.14"
DEFINITION_READ_SCOPE = "workflow:definition:read"
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
INSTANCE_READ_SCOPE = "workflow:instance:read"
INSTANCE_START_SCOPE = "workflow:instance:start"
INSTANCE_TRANSITION_SCOPE = "workflow:instance:transition"
ADMIN_SCOPE = "workflow:instance:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Workflow",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
DEFINITION_READ_SCOPE,
"View workflow definitions",
"Read workflow graphs, versions, diagnostics, and referenced contracts.",
),
_permission(
DEFINITION_WRITE_SCOPE,
"Manage workflow definitions",
"Create, edit, validate, and publish workflow definitions.",
),
_permission(
INSTANCE_READ_SCOPE,
"View workflow instances",
"Read workflow progress, pending actions, and transition evidence.",
),
_permission(
INSTANCE_START_SCOPE,
"Start workflows",
"Start approved workflow definitions for authorized subjects.",
),
_permission(
INSTANCE_TRANSITION_SCOPE,
"Advance workflows",
"Complete activities and invoke authorized workflow transitions.",
),
_permission(
ADMIN_SCOPE,
"Administer workflows",
"Manage workflow definitions and instances across the tenant.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="workflow_designer",
name="Workflow designer",
description="Design, validate, and publish workflow definitions.",
permissions=(DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE),
),
RoleTemplate(
slug="workflow_operator",
name="Workflow operator",
description="Start and advance approved workflows.",
permissions=(
DEFINITION_READ_SCOPE,
INSTANCE_READ_SCOPE,
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
),
),
)
def _router(context: ModuleContext):
from govoplan_workflow.backend.runtime import configure_runtime
configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_workflow.backend.router import router
return router
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=(),
optional_dependencies=(
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks",
"views",
),
optional_capabilities=(
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_VIEWS_RESOLVER,
),
provides_interfaces=(
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="dataflow.run_lifecycle",
version_min="0.1.14",
version_max_exclusive="1.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name="policy.definition_governance",
version_min="0.1.0",
version_max_exclusive="1.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name="views.resolver",
version_min="0.1.0",
version_max_exclusive="1.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
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,
),
),
),
route_factory=_router,
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
workflow_models.WorkflowDefinitionRevision,
workflow_models.WorkflowDefinition,
label="Workflow",
),
retirement_notes=(
"Destructive retirement drops Workflow definitions and immutable revisions "
"after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
workflow_models.WorkflowDefinition,
workflow_models.WorkflowDefinitionRevision,
label="Workflow",
),
),
documentation=(
DocumentationTopic(
id="workflow.definition-graphs",
title="Workflow definition graphs",
summary="Governed process graphs built on the shared definition editor contract.",
body=(
"Workflow provides its own trigger, activity, decision, wait, integration, "
"and outcome node library on top of Core's domain-neutral graph contract. "
"Unlike Dataflow, Workflow permits cycles for correction and retry paths. "
"Module actions are addressed through versioned capabilities rather than "
"implementation imports. Definitions are persisted as immutable graph "
"revisions; activation pins the exact revision used by future instances."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "power_user", "product_owner"),
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
order=76,
),
),
)
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",
]

View File

@@ -0,0 +1 @@
"""Workflow database migrations."""

View File

@@ -0,0 +1 @@
"""Workflow Alembic revisions."""

View File

@@ -0,0 +1,201 @@
"""v0.1.14 Workflow definitions
Revision ID: a7c4e2f9b1d3
Revises: None
Create Date: 2026-07-28 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7c4e2f9b1d3"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"workflow_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("definition_key", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=300), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("active_revision", sa.Integer(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_workflow_definitions")),
sa.UniqueConstraint(
"tenant_id",
"definition_key",
name="uq_workflow_definition_key",
),
)
op.create_index(
op.f("ix_workflow_definitions_created_by"),
"workflow_definitions",
["created_by"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definitions_deleted_at"),
"workflow_definitions",
["deleted_at"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definitions_status"),
"workflow_definitions",
["status"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definitions_tenant_id"),
"workflow_definitions",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definitions_updated_by"),
"workflow_definitions",
["updated_by"],
unique=False,
)
op.create_index(
"ix_workflow_definitions_tenant_status",
"workflow_definitions",
["tenant_id", "status"],
unique=False,
)
op.create_index(
"ix_workflow_definitions_tenant_updated",
"workflow_definitions",
["tenant_id", "updated_at"],
unique=False,
)
op.create_table(
"workflow_definition_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("definition_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("schema_version", sa.Integer(), nullable=False),
sa.Column("graph", sa.JSON(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("library_id", sa.String(length=100), nullable=False),
sa.Column("library_version", sa.String(length=40), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["definition_id"],
["workflow_definitions.id"],
name=op.f(
"fk_workflow_definition_revisions_definition_id_workflow_definitions"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_workflow_definition_revisions"),
),
sa.UniqueConstraint(
"definition_id",
"revision",
name="uq_workflow_definition_revision",
),
)
op.create_index(
op.f("ix_workflow_definition_revisions_created_by"),
"workflow_definition_revisions",
["created_by"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definition_revisions_definition_id"),
"workflow_definition_revisions",
["definition_id"],
unique=False,
)
op.create_index(
op.f("ix_workflow_definition_revisions_tenant_id"),
"workflow_definition_revisions",
["tenant_id"],
unique=False,
)
op.create_index(
"ix_workflow_definition_revisions_content_hash",
"workflow_definition_revisions",
["tenant_id", "content_hash"],
unique=False,
)
op.create_index(
"ix_workflow_definition_revisions_tenant_definition",
"workflow_definition_revisions",
["tenant_id", "definition_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_workflow_definition_revisions_tenant_definition",
table_name="workflow_definition_revisions",
)
op.drop_index(
"ix_workflow_definition_revisions_content_hash",
table_name="workflow_definition_revisions",
)
op.drop_index(
op.f("ix_workflow_definition_revisions_tenant_id"),
table_name="workflow_definition_revisions",
)
op.drop_index(
op.f("ix_workflow_definition_revisions_definition_id"),
table_name="workflow_definition_revisions",
)
op.drop_index(
op.f("ix_workflow_definition_revisions_created_by"),
table_name="workflow_definition_revisions",
)
op.drop_table("workflow_definition_revisions")
op.drop_index(
"ix_workflow_definitions_tenant_updated",
table_name="workflow_definitions",
)
op.drop_index(
"ix_workflow_definitions_tenant_status",
table_name="workflow_definitions",
)
op.drop_index(
op.f("ix_workflow_definitions_updated_by"),
table_name="workflow_definitions",
)
op.drop_index(
op.f("ix_workflow_definitions_tenant_id"),
table_name="workflow_definitions",
)
op.drop_index(
op.f("ix_workflow_definitions_status"),
table_name="workflow_definitions",
)
op.drop_index(
op.f("ix_workflow_definitions_deleted_at"),
table_name="workflow_definitions",
)
op.drop_index(
op.f("ix_workflow_definitions_created_by"),
table_name="workflow_definitions",
)
op.drop_table("workflow_definitions")

View File

@@ -0,0 +1,209 @@
"""v0.1.14 governed Workflow definitions
Revision ID: c6d8f1a3e5b7
Revises: a7c4e2f9b1d3
Create Date: 2026-07-28 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c6d8f1a3e5b7"
down_revision = "a7c4e2f9b1d3"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("workflow_definitions") as batch_op:
batch_op.drop_constraint(
"uq_workflow_definition_key",
type_="unique",
)
batch_op.alter_column(
"tenant_id",
existing_type=sa.String(length=36),
nullable=True,
)
batch_op.add_column(
sa.Column(
"scope_type",
sa.String(length=20),
nullable=False,
server_default="tenant",
)
)
batch_op.add_column(
sa.Column("scope_id", sa.String(length=36), nullable=True)
)
batch_op.add_column(
sa.Column(
"scope_key",
sa.String(length=80),
nullable=False,
server_default="tenant:legacy",
)
)
batch_op.add_column(
sa.Column(
"definition_kind",
sa.String(length=20),
nullable=False,
server_default="flow",
)
)
batch_op.add_column(
sa.Column(
"inherit_to_lower_scopes",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.add_column(
sa.Column(
"allow_start",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
)
)
batch_op.add_column(
sa.Column(
"allow_reuse",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.add_column(
sa.Column(
"allow_automation",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.add_column(
sa.Column(
"derived_from_definition_id",
sa.String(length=36),
nullable=True,
)
)
batch_op.add_column(
sa.Column("derived_from_revision", sa.Integer(), nullable=True)
)
batch_op.add_column(
sa.Column(
"derived_from_hash",
sa.String(length=64),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"derivation_provenance",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
op.execute(
sa.text(
"UPDATE workflow_definitions "
"SET scope_id = tenant_id, "
"scope_key = 'tenant:' || tenant_id "
"WHERE tenant_id IS NOT NULL"
)
)
with op.batch_alter_table("workflow_definitions") as batch_op:
batch_op.create_unique_constraint(
"uq_workflow_definition_key",
["scope_key", "definition_key"],
)
for column in (
"scope_type",
"scope_id",
"scope_key",
"definition_kind",
"derived_from_definition_id",
):
op.create_index(
op.f(f"ix_workflow_definitions_{column}"),
"workflow_definitions",
[column],
unique=False,
)
with op.batch_alter_table(
"workflow_definition_revisions"
) as batch_op:
batch_op.alter_column(
"tenant_id",
existing_type=sa.String(length=36),
nullable=True,
)
def downgrade() -> None:
op.execute(
sa.text(
"UPDATE workflow_definition_revisions "
"SET tenant_id = COALESCE(tenant_id, 'system')"
)
)
with op.batch_alter_table(
"workflow_definition_revisions"
) as batch_op:
batch_op.alter_column(
"tenant_id",
existing_type=sa.String(length=36),
nullable=False,
)
for column in (
"derived_from_definition_id",
"definition_kind",
"scope_key",
"scope_id",
"scope_type",
):
op.drop_index(
op.f(f"ix_workflow_definitions_{column}"),
table_name="workflow_definitions",
)
op.execute(
sa.text(
"UPDATE workflow_definitions "
"SET tenant_id = COALESCE(tenant_id, 'system')"
)
)
with op.batch_alter_table("workflow_definitions") as batch_op:
batch_op.drop_constraint(
"uq_workflow_definition_key",
type_="unique",
)
batch_op.create_unique_constraint(
"uq_workflow_definition_key",
["tenant_id", "definition_key"],
)
batch_op.drop_column("derivation_provenance")
batch_op.drop_column("derived_from_hash")
batch_op.drop_column("derived_from_revision")
batch_op.drop_column("derived_from_definition_id")
batch_op.drop_column("allow_automation")
batch_op.drop_column("allow_reuse")
batch_op.drop_column("allow_start")
batch_op.drop_column("inherit_to_lower_scopes")
batch_op.drop_column("definition_kind")
batch_op.drop_column("scope_key")
batch_op.drop_column("scope_id")
batch_op.drop_column("scope_type")
batch_op.alter_column(
"tenant_id",
existing_type=sa.String(length=36),
nullable=False,
)

View File

@@ -0,0 +1,390 @@
from __future__ import annotations
from govoplan_core.core.definition_graphs import (
DefinitionConfigField,
DefinitionGraphConstraints,
DefinitionGraphLibrary,
DefinitionNodeCountConstraint,
DefinitionNodeType,
DefinitionPort,
)
CATEGORY_LABELS = {
"trigger": "Start",
"activity": "Activities",
"decision": "Decisions",
"wait": "Wait",
"integration": "Integrations",
"outcome": "Outcomes",
}
WORKFLOW_NODE_TYPES = (
DefinitionNodeType(
type="workflow.start.manual",
category="trigger",
label="Manual start",
description="Start an instance through an explicit user action.",
icon="circle-play",
default_config={"input_schema_ref": ""},
config_fields=(
DefinitionConfigField(
id="input_schema_ref",
label="Input schema",
kind="text",
description="Optional schema contract for instance input.",
),
),
),
DefinitionNodeType(
type="workflow.start.api",
category="trigger",
label="API start",
description=(
"Start through an authenticated API request with an explicit "
"input contract."
),
icon="braces",
default_config={
"input_schema_ref": "",
"authorization_policy_ref": "",
},
config_fields=(
DefinitionConfigField(
id="input_schema_ref",
label="Input schema",
kind="text",
required=True,
),
DefinitionConfigField(
id="authorization_policy_ref",
label="Authorization policy",
kind="text",
required=True,
),
),
),
DefinitionNodeType(
type="workflow.start.workflow",
category="trigger",
label="Parent workflow",
description=(
"Start as a pinned child or dependency of another Workflow "
"instance."
),
icon="git-branch",
default_config={
"parent_definition_ref": "",
"parent_outcome": "completed",
"input_mapping": {},
},
config_fields=(
DefinitionConfigField(
id="parent_definition_ref",
label="Parent definition",
kind="text",
required=True,
),
DefinitionConfigField(
id="parent_outcome",
label="Parent outcome",
kind="text",
required=True,
),
DefinitionConfigField(
id="input_mapping",
label="Input mapping",
kind="mapping",
),
),
),
DefinitionNodeType(
type="workflow.start.event",
category="trigger",
label="Event start",
description="Start when a matching platform event is received.",
icon="radio",
config_fields=(
DefinitionConfigField(
id="event_type",
label="Event type",
kind="text",
required=True,
),
DefinitionConfigField(
id="filter",
label="Event filter",
kind="expression",
description="A constrained expression evaluated against the event envelope.",
),
),
default_config={"event_type": "", "filter": ""},
),
DefinitionNodeType(
type="workflow.start.schedule",
category="trigger",
label="Scheduled start",
description="Start according to a governed schedule.",
icon="calendar-clock",
config_fields=(
DefinitionConfigField(
id="schedule",
label="Schedule",
kind="schedule",
required=True,
),
DefinitionConfigField(
id="timezone",
label="Time zone",
kind="timezone",
required=True,
),
),
default_config={"schedule": "", "timezone": "Europe/Berlin"},
),
DefinitionNodeType(
type="workflow.activity",
category="activity",
label="Activity",
description="Record and complete a governed unit of work.",
icon="square-check-big",
input_ports=(DefinitionPort(id="input", label="Input"),),
config_fields=(
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
),
default_config={
"title": "",
"instructions": "",
"assignee": "",
"due_after": "",
},
),
DefinitionNodeType(
type="workflow.review",
category="activity",
label="Review",
description="Pause for a human review with explicit outcomes.",
icon="clipboard-check",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="approved", label="Approved", required=False),
DefinitionPort(id="changes", label="Changes requested", required=False),
DefinitionPort(id="rejected", label="Rejected", required=False),
),
config_fields=(
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"),
DefinitionConfigField(
id="required_evidence",
label="Required evidence",
kind="string_list",
),
),
default_config={"title": "", "reviewer": "", "required_evidence": []},
),
DefinitionNodeType(
type="workflow.decision",
category="decision",
label="Decision",
description="Choose a path using a constrained expression.",
icon="split",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="true", label="Matches", required=False),
DefinitionPort(id="false", label="Does not match", required=False),
),
config_fields=(
DefinitionConfigField(
id="expression",
label="Expression",
kind="expression",
required=True,
),
),
default_config={"expression": ""},
),
DefinitionNodeType(
type="workflow.wait",
category="wait",
label="Wait",
description="Wait for a duration, deadline, event, or explicit resume action.",
icon="timer",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="resumed", label="Resumed", required=False),
DefinitionPort(id="timed_out", label="Timed out", required=False),
),
config_fields=(
DefinitionConfigField(
id="mode",
label="Wait for",
kind="select",
required=True,
options=(
("duration", "Duration"),
("deadline", "Deadline"),
("event", "Event"),
("manual", "Manual resume"),
),
),
DefinitionConfigField(id="value", label="Value", kind="text"),
),
default_config={"mode": "manual", "value": ""},
),
DefinitionNodeType(
type="workflow.capability",
category="integration",
label="Module action",
description="Invoke a versioned module capability without importing its implementation.",
icon="plug-zap",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="success", label="Success", required=False),
DefinitionPort(id="failure", label="Failure", required=False),
),
config_fields=(
DefinitionConfigField(
id="capability",
label="Capability",
kind="capability",
required=True,
),
DefinitionConfigField(
id="operation",
label="Operation",
kind="text",
required=True,
),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
DefinitionConfigField(
id="idempotency_key",
label="Idempotency key",
kind="expression",
required=True,
),
DefinitionConfigField(
id="failure_policy",
label="On failure",
kind="select",
required=True,
options=(
("retry", "Retry"),
("manual", "Require intervention"),
("continue", "Continue on failure"),
),
),
),
default_config={
"capability": "",
"operation": "",
"input_mapping": {},
"idempotency_key": "",
"failure_policy": "manual",
},
),
DefinitionNodeType(
type="workflow.dataflow",
category="integration",
label="Run dataflow",
description="Run a published Dataflow pipeline and retain its output reference.",
icon="waypoints",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="success", label="Success", required=False),
DefinitionPort(id="failure", label="Failure", required=False),
),
config_fields=(
DefinitionConfigField(
id="pipeline_ref",
label="Pipeline",
kind="dataflow",
required=True,
),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
),
default_config={"pipeline_ref": "", "input_mapping": {}},
),
DefinitionNodeType(
type="workflow.end.completed",
category="outcome",
label="Completed",
description="Complete the workflow successfully.",
icon="circle-check-big",
input_ports=(
DefinitionPort(
id="input",
label="Input",
multiple=True,
minimum_connections=1,
),
),
output_ports=(),
config_fields=(
DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"),
),
default_config={"output_mapping": {}},
),
DefinitionNodeType(
type="workflow.end.cancelled",
category="outcome",
label="Cancelled",
description="End the workflow without a successful outcome.",
icon="circle-x",
input_ports=(
DefinitionPort(
id="input",
label="Input",
multiple=True,
minimum_connections=1,
),
),
output_ports=(),
config_fields=(
DefinitionConfigField(id="reason", label="Reason", kind="text"),
),
default_config={"reason": ""},
),
)
WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary(
id="workflow",
version="0.1.0",
category_labels=CATEGORY_LABELS,
node_types=WORKFLOW_NODE_TYPES,
constraints=DefinitionGraphConstraints(
max_nodes=150,
max_edges=300,
allow_cycles=True,
require_connected=True,
node_counts=(
DefinitionNodeCountConstraint(
code="graph.trigger_count",
label="start",
minimum=1,
maximum=1,
categories=("trigger",),
),
DefinitionNodeCountConstraint(
code="graph.outcome_count",
label="outcome",
minimum=1,
categories=("outcome",),
),
),
),
)
WORKFLOW_NODE_TYPES_BY_ID = {
definition.type: definition for definition in WORKFLOW_NODE_TYPES
}
__all__ = [
"CATEGORY_LABELS",
"WORKFLOW_GRAPH_LIBRARY",
"WORKFLOW_NODE_TYPES",
"WORKFLOW_NODE_TYPES_BY_ID",
]

View File

@@ -0,0 +1,750 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.references import (
access_scope_reference_options,
access_scope_reference_provider_available,
validate_access_scope_reference,
)
from govoplan_core.db.session import get_session
from govoplan_workflow.backend.governance import (
definition_decision,
normalize_definition_scope,
require_definition_action,
)
from govoplan_workflow.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.schemas import (
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeleteResponse,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionListResponse,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionListResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowDiagnosticResponse,
WorkflowGraphValidationRequest,
WorkflowGraphValidationResponse,
WorkflowNodeLibraryResponse,
WorkflowNodeTypeResponse,
WorkflowPortResponse,
)
from govoplan_workflow.backend.runtime import get_registry
from govoplan_workflow.backend.service import (
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
WorkflowValidationError,
activate_definition,
archive_definition,
create_definition,
definition_response,
delete_definition,
derive_definition,
get_definition,
get_definition_revision,
list_definition_revisions,
list_definitions,
revision_response,
update_definition,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
router = APIRouter(prefix="/workflow", tags=["workflow"])
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing one of the required scopes: {', '.join(scopes)}",
)
def _http_error(exc: WorkflowError) -> HTTPException:
if isinstance(exc, WorkflowNotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, WorkflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, WorkflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"node_id": item.node_id,
"field": item.field,
}
for item in exc.diagnostics
],
},
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _governance_http_error(
exc: PermissionError | ValueError,
) -> HTTPException:
return HTTPException(
status_code=(
status.HTTP_403_FORBIDDEN
if isinstance(exc, PermissionError)
else status.HTTP_422_UNPROCESSABLE_CONTENT
),
detail=str(exc),
)
def _definition_response(
session: Session,
definition,
principal: ApiPrincipal,
*,
revision: int | None = None,
) -> WorkflowDefinitionResponse:
return definition_response(
session,
definition,
principal=principal,
registry=get_registry(),
revision=revision,
)
def _actor_id(principal: ApiPrincipal) -> str | None:
return principal.account_id or principal.membership_id or principal.identity_id
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
definition_id: str,
details: dict[str, object],
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type="workflow_definition",
object_id=definition_id,
details=details,
)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowNodeLibraryResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return WorkflowNodeLibraryResponse(
id=WORKFLOW_GRAPH_LIBRARY.id,
version=WORKFLOW_GRAPH_LIBRARY.version,
allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles,
nodes=[
WorkflowNodeTypeResponse(
type=definition.type,
category=definition.category,
category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category],
label=definition.label,
description=definition.description,
icon=definition.icon,
input_ports=[
WorkflowPortResponse(
id=port.id,
label=port.label,
required=port.required,
multiple=port.multiple,
minimum_connections=port.minimum_connections,
)
for port in definition.input_ports
],
output_ports=[
WorkflowPortResponse(
id=port.id,
label=port.label,
required=port.required,
multiple=port.multiple,
minimum_connections=port.minimum_connections,
)
for port in definition.output_ports
],
config_fields=[
WorkflowConfigFieldResponse(
id=field.id,
label=field.label,
kind=field.kind,
required=field.required,
description=field.description,
options=list(field.options),
)
for field in definition.config_fields
],
default_config=dict(definition.default_config),
)
for definition in WORKFLOW_GRAPH_LIBRARY.node_types
],
)
@router.get("/scope-targets", response_model=ReferenceOptionListResponse)
def api_scope_targets(
scope_type: str,
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
registry = get_registry()
try:
options = access_scope_reference_options(
registry,
principal,
scope_type=scope_type,
query=q,
selected_values=selected,
limit=limit,
administrative=has_scope(principal, ADMIN_SCOPE),
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in options],
provider_available=access_scope_reference_provider_available(registry),
)
@router.post(
"/definitions/validate",
response_model=WorkflowGraphValidationResponse,
)
def api_validate_definition(
payload: WorkflowGraphValidationRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowGraphValidationResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
diagnostics = validate_workflow_graph(payload.graph)
return WorkflowGraphValidationResponse(
valid=not any(item.severity == "error" for item in diagnostics),
diagnostics=[
WorkflowDiagnosticResponse(
severity=item.severity,
code=item.code,
message=item.message,
node_id=item.node_id,
field=item.field,
)
for item in diagnostics
],
)
@router.get("/definitions", response_model=WorkflowDefinitionListResponse)
def api_list_definitions(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
registry = get_registry()
definitions = [
definition
for definition in list_definitions(
session,
tenant_id=principal.tenant_id,
)
if definition_decision(
definition,
principal=principal,
registry=registry,
action="view",
).allowed
]
return WorkflowDefinitionListResponse(
definitions=[
definition_response(
session,
definition,
principal=principal,
registry=registry,
)
for definition in definitions
]
)
@router.post(
"/definitions",
response_model=WorkflowDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_definition(
payload: WorkflowDefinitionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
canonical_scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
payload = payload.model_copy(update={"scope_id": canonical_scope_id})
definition = create_definition(
session,
tenant_id=tenant_id or principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.created",
definition_id=definition.id,
details={
"key": definition.definition_key,
"revision": definition.current_revision,
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
"definition_kind": definition.definition_kind,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.get(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_get_definition(
definition_id: str,
revision: int | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
return _definition_response(
session,
definition,
principal,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.put(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_update_definition(
definition_id: str,
payload: WorkflowDefinitionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
preserve_existing=(
existing.scope_id
if existing.scope_type == scope_type
else None
),
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
definition = update_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.updated",
definition_id=definition.id,
details={
"revision": definition.current_revision,
"status": definition.status,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/derive",
response_model=WorkflowDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_derive_definition(
definition_id: str,
payload: WorkflowDefinitionDeriveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
canonical_scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
payload = payload.model_copy(update={"scope_id": canonical_scope_id})
definition = derive_definition(
session,
tenant_id=tenant_id or principal.tenant_id,
actor_id=_actor_id(principal),
principal=principal,
registry=get_registry(),
source_definition_id=definition_id,
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.derived",
definition_id=definition.id,
details={
"source_definition_id": (
definition.derived_from_definition_id
),
"source_revision": definition.derived_from_revision,
"source_hash": definition.derived_from_hash,
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.get(
"/definitions/{definition_id}/revisions",
response_model=WorkflowDefinitionRevisionListResponse,
)
def api_list_definition_revisions(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
revisions = list_definition_revisions(session, definition=definition)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
return WorkflowDefinitionRevisionListResponse(
revisions=[revision_response(item) for item in revisions]
)
@router.get(
"/definitions/{definition_id}/revisions/{revision}",
response_model=WorkflowDefinitionRevisionResponse,
)
def api_get_definition_revision(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
return revision_response(item)
@router.post(
"/definitions/{definition_id}/activate",
response_model=WorkflowDefinitionResponse,
)
def api_activate_definition(
definition_id: str,
payload: WorkflowDefinitionActivateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = activate_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
revision=payload.revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.activated",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/archive",
response_model=WorkflowDefinitionResponse,
)
def api_archive_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = archive_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.archived",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.delete(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionDeleteResponse,
)
def api_delete_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionDeleteResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = delete_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.deleted",
definition_id=definition.id,
details={"revision": definition.current_revision},
)
session.commit()
return WorkflowDefinitionDeleteResponse(
deleted=True,
definition_id=definition.id,
)
__all__ = ["router"]

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from govoplan_core.core.runtime import ModuleRuntimeState
_runtime = ModuleRuntimeState("Workflow")
configure_runtime = _runtime.configure_runtime
get_registry = _runtime.get_registry
get_settings = _runtime.get_settings
settings = _runtime.settings

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
import math
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
class WorkflowPosition(BaseModel):
x: float = 0
y: float = 0
@field_validator("x", "y")
@classmethod
def finite_coordinate(cls, value: float) -> float:
if not math.isfinite(value):
raise ValueError("Graph coordinates must be finite.")
return value
class WorkflowNode(BaseModel):
id: str = Field(min_length=1, max_length=120)
type: str = Field(min_length=1, max_length=120)
label: str = Field(default="", max_length=300)
position: WorkflowPosition = Field(default_factory=WorkflowPosition)
config: dict[str, Any] = Field(default_factory=dict)
class WorkflowEdge(BaseModel):
id: str = Field(min_length=1, max_length=120)
source: str = Field(min_length=1, max_length=120)
target: str = Field(min_length=1, max_length=120)
source_port: str = Field(default="output", min_length=1, max_length=120)
target_port: str = Field(default="input", min_length=1, max_length=120)
class WorkflowGraph(BaseModel):
schema_version: Literal[1] = 1
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
class WorkflowGraphValidationRequest(BaseModel):
graph: WorkflowGraph
class WorkflowDiagnosticResponse(BaseModel):
severity: Literal["error", "warning"]
code: str
message: str
node_id: str | None = None
field: str | None = None
class WorkflowGraphValidationResponse(BaseModel):
valid: bool
diagnostics: list[WorkflowDiagnosticResponse]
class WorkflowPortResponse(BaseModel):
id: str
label: str
required: bool
multiple: bool
minimum_connections: int
class WorkflowConfigFieldResponse(BaseModel):
id: str
label: str
kind: str
required: bool
description: str | None
options: list[tuple[str, str]]
class WorkflowNodeTypeResponse(BaseModel):
type: str
category: str
category_label: str
label: str
description: str
icon: str
input_ports: list[WorkflowPortResponse]
output_ports: list[WorkflowPortResponse]
config_fields: list[WorkflowConfigFieldResponse]
default_config: dict[str, Any]
class WorkflowNodeLibraryResponse(BaseModel):
id: str
version: str
allows_cycles: bool
nodes: list[WorkflowNodeTypeResponse]
class WorkflowDefinitionRevisionResponse(BaseModel):
id: str
revision: int
schema_version: int
graph: WorkflowGraph
content_hash: str
library_id: str
library_version: str
created_by: str | None
created_at: datetime
class WorkflowActionDecisionResponse(BaseModel):
allowed: bool
reason: str | None = None
source_path: list[dict[str, Any]] = Field(default_factory=list)
requirements: list[str] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict)
class WorkflowGovernanceResponse(BaseModel):
scope_type: DefinitionScopeType
scope_id: str | None
definition_kind: DefinitionKind
inherit_to_lower_scopes: bool
allow_start: bool
allow_reuse: bool
allow_automation: bool
derived_from_definition_id: str | None
derived_from_revision: int | None
derived_from_hash: str | None
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
actions: dict[str, WorkflowActionDecisionResponse]
automation_runtime_available: bool = False
automation_runtime_reason: str | None = None
class WorkflowDefinitionResponse(BaseModel):
id: str
tenant_id: str | None
key: str
name: str
description: str | None
status: WorkflowDefinitionStatus
current_revision: int
active_revision: int | None
metadata: dict[str, Any]
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
revision: WorkflowDefinitionRevisionResponse
governance: WorkflowGovernanceResponse
class WorkflowDefinitionListResponse(BaseModel):
definitions: list[WorkflowDefinitionResponse]
class WorkflowDefinitionRevisionListResponse(BaseModel):
revisions: list[WorkflowDefinitionRevisionResponse]
class WorkflowDefinitionCreateRequest(BaseModel):
key: str | None = Field(
default=None,
min_length=1,
max_length=120,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
)
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
metadata: dict[str, Any] = Field(default_factory=dict)
scope_type: DefinitionScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
definition_kind: DefinitionKind = "flow"
inherit_to_lower_scopes: bool = False
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
class WorkflowDefinitionUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
metadata: dict[str, Any] = Field(default_factory=dict)
expected_revision: int = Field(ge=1)
scope_type: DefinitionScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
definition_kind: DefinitionKind = "flow"
inherit_to_lower_scopes: bool = False
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
class WorkflowDefinitionDeriveRequest(BaseModel):
key: str | None = Field(
default=None,
min_length=1,
max_length=120,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
)
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
source_revision: int | None = Field(default=None, ge=1)
metadata: dict[str, Any] = Field(default_factory=dict)
scope_type: DefinitionScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
definition_kind: DefinitionKind = "flow"
inherit_to_lower_scopes: bool = False
allow_start: bool = True
allow_reuse: bool = False
allow_automation: bool = False
class WorkflowDefinitionActivateRequest(BaseModel):
revision: int | None = Field(default=None, ge=1)
class WorkflowDefinitionDeleteResponse(BaseModel):
deleted: bool
definition_id: str

View File

@@ -0,0 +1,658 @@
from __future__ import annotations
from collections.abc import Mapping
import hashlib
import json
import re
import unicodedata
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.governance import (
definition_governance_payload,
require_definition_action,
)
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowGraph,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
class WorkflowError(RuntimeError):
pass
class WorkflowNotFoundError(WorkflowError):
pass
class WorkflowConflictError(WorkflowError):
pass
class WorkflowValidationError(WorkflowError):
def __init__(self, diagnostics: tuple[object, ...]) -> None:
first = diagnostics[0] if diagnostics else None
super().__init__(
str(getattr(first, "message", "Workflow definition validation failed."))
)
self.diagnostics = diagnostics
def list_definitions(
session: Session,
*,
tenant_id: str,
) -> list[WorkflowDefinition]:
return list(
session.scalars(
select(WorkflowDefinition)
.where(
or_(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
WorkflowDefinition.deleted_at.is_(None),
)
.order_by(
WorkflowDefinition.updated_at.desc(),
WorkflowDefinition.name.asc(),
)
)
)
def get_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
) -> WorkflowDefinition:
definition = session.scalar(
select(WorkflowDefinition).where(
WorkflowDefinition.id == definition_id,
or_(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
WorkflowDefinition.deleted_at.is_(None),
)
)
if definition is None:
raise WorkflowNotFoundError("Workflow definition not found.")
return definition
def get_definition_revision(
session: Session,
*,
definition: WorkflowDefinition,
revision: int | None = None,
) -> WorkflowDefinitionRevision:
revision_number = revision or definition.current_revision
item = session.scalar(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id == definition.id,
WorkflowDefinitionRevision.tenant_id == definition.tenant_id,
WorkflowDefinitionRevision.revision == revision_number,
)
)
if item is None:
raise WorkflowNotFoundError("Workflow definition revision not found.")
return item
def list_definition_revisions(
session: Session,
*,
definition: WorkflowDefinition,
) -> list[WorkflowDefinitionRevision]:
return list(
session.scalars(
select(WorkflowDefinitionRevision)
.where(
WorkflowDefinitionRevision.definition_id == definition.id,
WorkflowDefinitionRevision.tenant_id == definition.tenant_id,
)
.order_by(WorkflowDefinitionRevision.revision.desc())
)
)
def create_definition(
session: Session,
*,
tenant_id: str,
actor_id: str | None,
payload: WorkflowDefinitionCreateRequest,
) -> WorkflowDefinition:
graph = _validated_graph(payload.graph)
stored_tenant_id = (
None if payload.scope_type == "system" else tenant_id
)
scope_id = (
None
if payload.scope_type == "system"
else tenant_id
if payload.scope_type == "tenant"
else payload.scope_id
)
scope_key = (
"system"
if payload.scope_type == "system"
else f"{payload.scope_type}:{scope_id}"
)
definition = WorkflowDefinition(
tenant_id=stored_tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
scope_key=scope_key,
definition_kind=payload.definition_kind,
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
allow_start=payload.allow_start,
allow_reuse=payload.allow_reuse,
allow_automation=payload.allow_automation,
definition_key=_available_key(
session,
scope_key=scope_key,
requested=payload.key,
name=payload.name,
),
name=payload.name.strip(),
description=_clean_optional(payload.description),
status="draft",
current_revision=1,
active_revision=None,
metadata_=dict(payload.metadata),
created_by=actor_id,
updated_by=actor_id,
)
definition.revisions.append(
_new_revision(
tenant_id=stored_tenant_id,
revision=1,
graph=graph,
actor_id=actor_id,
)
)
session.add(definition)
session.flush()
return definition
def update_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
payload: WorkflowDefinitionUpdateRequest,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
if payload.expected_revision != definition.current_revision:
raise WorkflowConflictError(
"Workflow definition changed on the server; "
f"expected revision {payload.expected_revision}, "
f"current revision is {definition.current_revision}."
)
if (
payload.scope_type != definition.scope_type
or payload.scope_id != definition.scope_id
and not (
definition.scope_type == "tenant"
and payload.scope_id in {None, definition.scope_id}
)
):
raise WorkflowConflictError(
"Definition scope is immutable; derive a scoped copy instead."
)
if payload.definition_kind != definition.definition_kind:
raise WorkflowConflictError(
"Definition kind is immutable; derive a flow or template instead."
)
graph = _validated_graph(payload.graph)
current = get_definition_revision(session, definition=definition)
graph_hash = _content_hash(graph)
definition.name = payload.name.strip()
definition.description = _clean_optional(payload.description)
definition.metadata_ = dict(payload.metadata)
ancestor_limits = _ancestor_governance_limits(
definition.derivation_provenance
)
definition.inherit_to_lower_scopes = (
payload.inherit_to_lower_scopes
and ancestor_limits["inherit_to_lower_scopes"]
)
definition.allow_start = (
payload.allow_start and ancestor_limits["allow_start"]
)
definition.allow_reuse = (
payload.allow_reuse and ancestor_limits["allow_reuse"]
)
definition.allow_automation = (
payload.allow_automation and ancestor_limits["allow_automation"]
)
definition.updated_by = actor_id
if current.content_hash != graph_hash:
definition.current_revision += 1
definition.revisions.append(
_new_revision(
tenant_id=definition.tenant_id,
revision=definition.current_revision,
graph=graph,
actor_id=actor_id,
)
)
if definition.status == "active":
definition.status = "draft"
session.flush()
return definition
def derive_definition(
session: Session,
*,
tenant_id: str,
actor_id: str | None,
principal: ApiPrincipal,
registry: object | None,
source_definition_id: str,
payload: WorkflowDefinitionDeriveRequest,
) -> WorkflowDefinition:
source = get_definition(
session,
tenant_id=tenant_id,
definition_id=source_definition_id,
)
decision = require_definition_action(
source,
principal=principal,
registry=registry,
action="derive",
)
source_revision = get_definition_revision(
session,
definition=source,
revision=payload.source_revision,
)
stored_tenant_id = (
None if payload.scope_type == "system" else tenant_id
)
scope_id = (
None
if payload.scope_type == "system"
else tenant_id
if payload.scope_type == "tenant"
else payload.scope_id
)
scope_key = (
"system"
if payload.scope_type == "system"
else f"{payload.scope_type}:{scope_id}"
)
source_limits = _effective_governance_limits(
source,
decision_details=decision.details,
)
limits = {
"inherit_to_lower_scopes": (
source_limits["inherit_to_lower_scopes"]
and payload.inherit_to_lower_scopes
),
"allow_start": (
source_limits["allow_start"] and payload.allow_start
),
"allow_reuse": (
source_limits["allow_reuse"] and payload.allow_reuse
),
"allow_automation": (
source_limits["allow_automation"]
and payload.allow_automation
),
}
provenance = {
"source_ref": f"workflow-definition:{source.id}",
"source_scope": {
"scope_type": source.scope_type,
"scope_id": source.scope_id,
},
"source_definition_kind": source.definition_kind,
"source_revision": source_revision.revision,
"source_hash": source_revision.content_hash,
"source_effective_limits": limits,
"policy_decision": decision.to_dict(),
"derived_by": actor_id,
"derived_at": utcnow().isoformat(),
}
definition = WorkflowDefinition(
tenant_id=stored_tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
scope_key=scope_key,
definition_kind=payload.definition_kind,
inherit_to_lower_scopes=limits["inherit_to_lower_scopes"],
allow_start=limits["allow_start"],
allow_reuse=limits["allow_reuse"],
allow_automation=limits["allow_automation"],
derived_from_definition_id=source.id,
derived_from_revision=source_revision.revision,
derived_from_hash=source_revision.content_hash,
derivation_provenance=provenance,
definition_key=_available_key(
session,
scope_key=scope_key,
requested=payload.key,
name=payload.name,
),
name=payload.name.strip(),
description=_clean_optional(payload.description),
status="draft",
current_revision=1,
active_revision=None,
metadata_=dict(payload.metadata),
created_by=actor_id,
updated_by=actor_id,
)
definition.revisions.append(
WorkflowDefinitionRevision(
tenant_id=stored_tenant_id,
revision=1,
schema_version=source_revision.schema_version,
graph=dict(source_revision.graph),
content_hash=source_revision.content_hash,
library_id=source_revision.library_id,
library_version=source_revision.library_version,
created_by=actor_id,
)
)
session.add(definition)
session.flush()
return definition
def activate_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
revision: int | None = None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
selected = get_definition_revision(
session,
definition=definition,
revision=revision,
)
if definition.definition_kind == "template":
raise WorkflowConflictError(
"Workflow templates cannot be activated or started."
)
_validated_graph(WorkflowGraph.model_validate(selected.graph))
definition.active_revision = selected.revision
definition.status = "active"
definition.updated_by = actor_id
session.flush()
return definition
def archive_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
definition.status = "archived"
definition.updated_by = actor_id
session.flush()
return definition
def delete_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
definition.deleted_at = utcnow()
definition.updated_by = actor_id
session.flush()
return definition
def definition_response(
session: Session,
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
revision: int | None = None,
) -> WorkflowDefinitionResponse:
selected = get_definition_revision(
session,
definition=definition,
revision=revision,
)
return WorkflowDefinitionResponse(
id=definition.id,
tenant_id=definition.tenant_id,
key=definition.definition_key,
name=definition.name,
description=definition.description,
status=definition.status,
current_revision=definition.current_revision,
active_revision=definition.active_revision,
metadata=dict(definition.metadata_),
created_by=definition.created_by,
updated_by=definition.updated_by,
created_at=definition.created_at,
updated_at=definition.updated_at,
revision=revision_response(selected),
governance=definition_governance_payload(
definition,
principal=principal,
registry=registry,
),
)
def revision_response(
revision: WorkflowDefinitionRevision,
) -> WorkflowDefinitionRevisionResponse:
return WorkflowDefinitionRevisionResponse(
id=revision.id,
revision=revision.revision,
schema_version=revision.schema_version,
graph=WorkflowGraph.model_validate(revision.graph),
content_hash=revision.content_hash,
library_id=revision.library_id,
library_version=revision.library_version,
created_by=revision.created_by,
created_at=revision.created_at,
)
def _new_revision(
*,
tenant_id: str | None,
revision: int,
graph: WorkflowGraph,
actor_id: str | None,
) -> WorkflowDefinitionRevision:
return WorkflowDefinitionRevision(
tenant_id=tenant_id,
revision=revision,
schema_version=graph.schema_version,
graph=_canonical_graph(graph),
content_hash=_content_hash(graph),
library_id=WORKFLOW_GRAPH_LIBRARY.id,
library_version=WORKFLOW_GRAPH_LIBRARY.version,
created_by=actor_id,
)
def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph:
diagnostics = validate_workflow_graph(graph)
if any(item.severity == "error" for item in diagnostics):
raise WorkflowValidationError(diagnostics)
return graph
def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]:
return graph.model_dump(mode="json")
def _content_hash(graph: WorkflowGraph) -> str:
encoded = json.dumps(
_canonical_graph(graph),
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _available_key(
session: Session,
*,
scope_key: str,
requested: str | None,
name: str,
) -> str:
base = _slug(requested or name)
candidate = base
suffix = 2
while session.scalar(
select(WorkflowDefinition.id).where(
WorkflowDefinition.scope_key == scope_key,
WorkflowDefinition.definition_key == candidate,
)
):
candidate = f"{base[: max(1, 120 - len(str(suffix)) - 1)]}-{suffix}"
suffix += 1
return candidate
def _slug(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
ascii_value = normalized.encode("ascii", "ignore").decode("ascii").lower()
cleaned = re.sub(r"[^a-z0-9]+", "-", ascii_value).strip("-")
return (cleaned or "workflow")[:120]
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip()
return cleaned or None
def _ancestor_governance_limits(
provenance: Mapping[str, object],
) -> dict[str, bool]:
raw = provenance.get("source_effective_limits")
limits = raw if isinstance(raw, Mapping) else {}
return {
key: value if isinstance((value := limits.get(key)), bool) else True
for key in (
"inherit_to_lower_scopes",
"allow_start",
"allow_reuse",
"allow_automation",
)
}
def _effective_governance_limits(
definition: WorkflowDefinition,
*,
decision_details: Mapping[str, object] | None = None,
) -> dict[str, bool]:
ancestor = _ancestor_governance_limits(
definition.derivation_provenance
)
effective = {
"inherit_to_lower_scopes": (
definition.inherit_to_lower_scopes
and ancestor["inherit_to_lower_scopes"]
),
"allow_start": (
definition.allow_start and ancestor["allow_start"]
),
"allow_reuse": (
definition.allow_reuse and ancestor["allow_reuse"]
),
"allow_automation": (
definition.allow_automation
and ancestor["allow_automation"]
),
}
policy_limits = (
decision_details.get("effective_limits")
if decision_details is not None
else None
)
if isinstance(policy_limits, Mapping):
policy_key_by_local_key = {
"inherit_to_lower_scopes": "inherit_to_lower_scopes",
"allow_start": "allow_run",
"allow_reuse": "allow_reuse",
"allow_automation": "allow_automation",
}
for local_key, policy_key in policy_key_by_local_key.items():
value = policy_limits.get(policy_key)
if isinstance(value, bool):
effective[local_key] = effective[local_key] and value
return effective
__all__ = [
"WorkflowConflictError",
"WorkflowError",
"WorkflowNotFoundError",
"WorkflowValidationError",
"activate_definition",
"archive_definition",
"create_definition",
"derive_definition",
"definition_response",
"delete_definition",
"get_definition",
"get_definition_revision",
"list_definition_revisions",
"list_definitions",
"revision_response",
"update_definition",
]

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from govoplan_core.core.definition_graphs import (
DefinitionDiagnostic,
DefinitionEdge,
DefinitionNode,
validate_definition_graph,
)
from govoplan_workflow.backend.node_library import (
WORKFLOW_GRAPH_LIBRARY,
WORKFLOW_NODE_TYPES_BY_ID,
)
from govoplan_workflow.backend.schemas import WorkflowGraph
def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ...]:
diagnostics = list(
validate_definition_graph(
WORKFLOW_GRAPH_LIBRARY,
nodes=tuple(DefinitionNode(id=node.id, type=node.type) for node in graph.nodes),
edges=tuple(
DefinitionEdge(
id=edge.id,
source=edge.source,
target=edge.target,
source_port=edge.source_port,
target_port=edge.target_port,
)
for edge in graph.edges
),
)
)
outgoing = {node.id: 0 for node in graph.nodes}
for edge in graph.edges:
if edge.source in outgoing:
outgoing[edge.source] += 1
for node in graph.nodes:
definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type)
if definition is None:
continue
for field in definition.config_fields:
if field.required and _empty(node.config.get(field.id)):
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="node.config_required",
message=f"{definition.label} requires {field.label.lower()}.",
node_id=node.id,
field=field.id,
)
)
if definition.output_ports and outgoing[node.id] == 0:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="node.outgoing_required",
message=f"{definition.label} must lead to another workflow step.",
node_id=node.id,
)
)
return _deduplicate(diagnostics)
def _empty(value: object) -> bool:
return value is None or value == "" or value == [] or value == {}
def _deduplicate(
diagnostics: list[DefinitionDiagnostic],
) -> tuple[DefinitionDiagnostic, ...]:
seen: set[tuple[str, str | None, str | None]] = set()
result: list[DefinitionDiagnostic] = []
for item in diagnostics:
key = (item.code, item.node_id, item.field)
if key in seen:
continue
seen.add(key)
result.append(item)
return tuple(result)
__all__ = ["validate_workflow_graph"]

View File

@@ -0,0 +1 @@

270
tests/test_governance.py Normal file
View File

@@ -0,0 +1,270 @@
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,
)
from 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()

43
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.manifest import (
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
get_manifest,
)
class WorkflowManifestTests(unittest.TestCase):
def test_manifest_exposes_definition_contracts(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "workflow")
self.assertIn(
"workflow.definition_graph",
{item.name for item in manifest.provides_interfaces},
)
self.assertIn(
DEFINITION_READ_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertIn(
DEFINITION_WRITE_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertEqual(
"@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None,
)
self.assertIsNotNone(manifest.migration_spec)
requirement = next(
item
for item in manifest.requires_interfaces
if item.name == "dataflow.run_lifecycle"
)
self.assertTrue(requirement.optional)
if __name__ == "__main__":
unittest.main()

48
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_workflow.backend.manifest import get_manifest
class WorkflowMigrationTests(unittest.TestCase):
def test_migration_creates_definition_tables_and_head(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-workflow-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'workflow.db'}"
migrate_database(
database_url=url,
enabled_modules=("workflow",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"c6d8f1a3e5b7",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
{
"workflow_definition_revisions",
"workflow_definitions",
},
{
name
for name in inspect(connection).get_table_names()
if name.startswith("workflow_")
},
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import unittest
from govoplan_workflow.backend.node_library import 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")
if __name__ == "__main__":
unittest.main()

225
tests/test_service.py Normal file
View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import unittest
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 (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
create_definition,
delete_definition,
get_definition,
list_definition_revisions,
list_definitions,
update_definition,
)
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_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
)
)
)
),
)
if __name__ == "__main__":
unittest.main()

32
webui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/workflow-webui",
"version": "0.1.14",
"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"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

234
webui/src/api/workflow.ts Normal file
View File

@@ -0,0 +1,234 @@
import {
apiFetch,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type {
DefinitionGraph,
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 WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
schema_version: 1;
nodes: WorkflowGraphNode[];
};
export type WorkflowDiagnostic = {
severity: "error" | "warning";
code: string;
message: string;
node_id?: string | null;
field?: string | null;
};
export type WorkflowNodeType = DefinitionGraphNodeType;
export type WorkflowRevision = {
id: string;
revision: number;
schema_version: number;
graph: WorkflowGraph;
content_hash: string;
library_id: string;
library_version: string;
created_by?: string | null;
created_at: string;
};
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;
};
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 WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
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;
};
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;
}
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
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 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 }
);
}

View File

@@ -0,0 +1,237 @@
import { useMemo, useState, type DragEvent } from "react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
MiniMap,
ReactFlow,
type Connection,
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDiagnostic,
WorkflowGraph,
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,
readOnly,
allowsCycles,
onGraphChange,
onSelectNode
}: {
graph: WorkflowGraph;
diagnostics: WorkflowDiagnostic[];
nodeLibrary: WorkflowNodeType[];
selectedNodeId: string | null;
readOnly: boolean;
allowsCycles: boolean;
onGraphChange: (graph: WorkflowGraph) => void;
onSelectNode: (nodeId: string | null) => void;
}) {
const [instance, setInstance] = useState<
ReactFlowInstance<WorkflowFlowNode, Edge> | 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: 190,
initialHeight: 56,
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",
className: "workflow-edge"
})),
[graph.edges]
);
const updateNodes = (nextNodes: WorkflowFlowNode[]) => {
const ids = new Set(nextNodes.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)
)
});
};
const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({
...graph,
edges: nextEdges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
source_port: edge.sourceHandle ?? "output",
target_port: edge.targetHandle ?? "input"
}))
});
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
},
{ allowCycles: allowsCycles }
) === null;
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (readOnly || !instance) return;
const type = event.dataTransfer.getData(
"application/x-govoplan-workflow-node"
);
if (!type) return;
const node = newWorkflowNode(
type,
instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
nodeLibrary
);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectNode(node.id);
};
return (
<div
className="workflow-canvas"
onDragOver={(event) => {
if (
event.dataTransfer.types.includes(
"application/x-govoplan-workflow-node"
)
) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={onDrop}
>
<ReactFlow<WorkflowFlowNode, Edge>
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => {
if (readOnly) return;
const graphChanges = changes.filter(
(change) => change.type !== "dimensions"
);
if (graphChanges.length) {
updateNodes(applyNodeChanges(graphChanges, nodes));
}
}}
onEdgesChange={(changes) => {
if (!readOnly) updateEdges(applyEdgeChanges(changes, edges));
}}
onConnect={(connection) => {
if (!isValidConnection(connection)) return;
updateEdges(addEdge({
...connection,
id: `edge-${crypto.randomUUID()}`,
type: "smoothstep"
}, edges));
}}
isValidConnection={isValidConnection}
onNodeClick={(_event, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }}
connectionRadius={32}
fitView
fitViewOptions={{ padding: 0.22, maxZoom: 1.25 }}
minZoom={0.25}
maxZoom={1.8}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.3} />
<MiniMap
pannable
zoomable
nodeStrokeWidth={2}
nodeColor={(node) =>
node.data.hasError ? "var(--danger)" : "var(--accent)"
}
/>
<Controls showInteractive={false} />
</ReactFlow>
{!graph.nodes.length ? (
<div className="workflow-canvas-empty">Drop a start node here</div>
) : null}
</div>
);
}
export function updateWorkflowGraphNode(
graph: WorkflowGraph,
updatedNode: WorkflowGraphNode
): WorkflowGraph {
return {
...graph,
nodes: graph.nodes.map((node) =>
node.id === updatedNode.id ? updatedNode : node
)
};
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DismissibleAlert,
FormField
} from "@govoplan/core-webui";
import type {
WorkflowGraphNode,
WorkflowNodeType
} from "../../api/workflow";
export default function WorkflowInspector({
node,
nodeLibrary,
readOnly,
onChange,
onDelete
}: {
node: WorkflowGraphNode | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
}) {
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState("");
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 (!node) {
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
<div className="workflow-inspector-empty">No node selected</div>
</aside>
);
}
const definition = nodeLibrary.find((item) => item.type === node.type);
const updateConfig = (field: string, value: unknown) => {
onChange({
...node,
config: { ...node.config, [field]: value }
});
};
return (
<aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading">
<span>
<strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small>
</span>
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</div>
<div className="workflow-inspector-fields">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<FormField label="Name">
<input
value={node.label}
onChange={(event) =>
onChange({ ...node, label: event.target.value })
}
disabled={readOnly}
/>
</FormField>
{(definition?.config_fields ?? []).map((field) => (
<FormField
key={field.id}
label={`${field.label}${field.required ? " *" : ""}`}
help={field.description ?? undefined}
>
{field.kind === "select" ? (
<select
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
>
<option value="">Choose</option>
{field.options.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
) : field.kind === "textarea" || field.kind === "expression" ? (
<textarea
value={textValue(node.config[field.id])}
onChange={(event) => updateConfig(field.id, event.target.value)}
disabled={readOnly}
/>
) : field.kind === "mapping" ? (
<textarea
className="workflow-json-editor"
value={jsonDrafts[field.id] ?? "{}"}
onChange={(event) => setJsonDrafts((current) => ({
...current,
[field.id]: event.target.value
}))}
onBlur={() => {
try {
const parsed: unknown = JSON.parse(
jsonDrafts[field.id] ?? "{}"
);
if (!isRecord(parsed)) {
throw new Error(`${field.label} must be a JSON object.`);
}
setError("");
updateConfig(field.id, parsed);
} catch (parseError) {
setError(
parseError instanceof Error
? parseError.message
: `${field.label} is invalid.`
);
}
}}
spellCheck={false}
disabled={readOnly}
/>
) : field.kind === "string_list" ? (
<input
value={stringList(node.config[field.id]).join(", ")}
onChange={(event) => updateConfig(
field.id,
event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
)}
disabled={readOnly}
/>
) : (
<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);
}

View File

@@ -0,0 +1,99 @@
import {
CalendarClock,
CheckCircle2,
CirclePlay,
CircleX,
ClipboardCheck,
GitFork,
PlugZap,
Radio,
Split,
SquareCheckBig,
Timer,
Waypoints,
type LucideIcon
} from "lucide-react";
import {
Handle,
Position,
type Node,
type NodeProps
} from "@xyflow/react";
import type { WorkflowNodeType } from "../../api/workflow";
export type WorkflowFlowNodeData = {
label: string;
workflowType: string;
definition: WorkflowNodeType;
hasError: boolean;
};
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
const iconByName: Record<string, LucideIcon> = {
"calendar-clock": CalendarClock,
"circle-check-big": CheckCircle2,
"circle-play": CirclePlay,
"circle-x": CircleX,
"clipboard-check": ClipboardCheck,
"plug-zap": PlugZap,
radio: Radio,
split: Split,
"square-check-big": SquareCheckBig,
timer: Timer,
waypoints: Waypoints
};
export default function WorkflowNode({
data,
selected,
isConnectable
}: NodeProps<WorkflowFlowNode>) {
const Icon = iconByName[data.definition.icon] ?? GitFork;
const inputPorts = data.definition.input_ports;
const outputPorts = data.definition.output_ports;
return (
<div
className={[
"workflow-node",
`workflow-node-${data.definition.category}`,
selected ? "is-selected" : "",
data.hasError ? "has-error" : ""
].filter(Boolean).join(" ")}
>
{inputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="target"
position={Position.Left}
className="workflow-node-handle"
style={{ top: portPosition(index, inputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
<span className="workflow-node-icon"><Icon size={17} /></span>
<span className="workflow-node-copy">
<strong>{data.label}</strong>
<small>{data.definition.label}</small>
</span>
{outputPorts.map((port, index) => (
<Handle
key={port.id}
id={port.id}
type="source"
position={Position.Right}
className="workflow-node-handle"
style={{ top: portPosition(index, outputPorts.length) }}
isConnectable={isConnectable}
title={port.label}
/>
))}
</div>
);
}
function portPosition(index: number, count: number): string {
return `${((index + 1) / (count + 1)) * 100}%`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
DefinitionKind,
DefinitionScopeType,
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType,
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;
governance: WorkflowGovernance | null;
};
const inputPort = [{
id: "input",
label: "Input",
required: true,
multiple: false,
minimum_connections: 1
}];
const outputPort = [{
id: "output",
label: "Output",
required: true,
multiple: false,
minimum_connections: 1
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "workflow.start.manual",
category: "trigger",
category_label: "Start",
label: "Manual start",
description: "Start through an explicit action.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: { input_schema_ref: "" }
},
{
type: "workflow.activity",
category: "activity",
category_label: "Activities",
label: "Activity",
description: "A governed unit of work.",
icon: "square-check-big",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
{
id: "title",
label: "Title",
kind: "text",
required: true,
options: []
}
],
default_config: {
title: "",
instructions: "",
assignee: "",
due_after: ""
}
},
{
type: "workflow.end.completed",
category: "outcome",
category_label: "Outcomes",
label: "Completed",
description: "Complete successfully.",
icon: "circle-check-big",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: { output_mapping: {} }
}
];
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,
governance: null,
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "workflow.start.manual",
label: "Start",
position: { x: 60, y: 150 },
config: { input_schema_ref: "" }
},
{
id: "activity",
type: "workflow.activity",
label: "Activity",
position: { x: 320, y: 150 },
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: ""
}
},
{
id: "complete",
type: "workflow.end.completed",
label: "Completed",
position: { x: 580, y: 150 },
config: { output_mapping: {} }
}
],
edges: [
{
id: "start-activity",
source: "start",
target: "activity",
source_port: "output",
target_port: "input"
},
{
id: "activity-complete",
source: "activity",
target: "complete",
source_port: "output",
target_port: "input"
}
]
}
};
}
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,
governance: definition.governance
};
}
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
};
}
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
});
}
export function newWorkflowNode(
type: string,
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
return createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
}

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { workflowModule as default, workflowModule } from "./module";
export * from "./api/workflow";

45
webui/src/module.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "@xyflow/react/dist/style.css";
import "./styles/workflow.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
export const workflowModule: PlatformWebModule = {
id: "workflow",
label: "Workflow",
version: "0.1.14",
optionalDependencies: [
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks"
],
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 })
}
]
};
export default workflowModule;

View File

@@ -0,0 +1,752 @@
.workflow-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.workflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
.workflow-definition-fields {
display: grid;
gap: 12px;
}
.workflow-definition-fields input,
.workflow-definition-fields select {
width: 100%;
}
.workflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.workflow-provenance {
display: grid;
gap: 6px;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.workflow-provenance span,
.workflow-provenance small {
color: var(--muted);
font-size: 12px;
}
.workflow-provenance code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.workflow-definition-toggles {
grid-template-columns: 1fr;
}
}
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {
box-sizing: border-box;
}
.workflow-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.workflow-definition-panel,
.workflow-workspace,
.workflow-editor,
.workflow-editor-surface,
.workflow-canvas,
.workflow-definition-list-frame,
.workflow-inspector-column {
min-width: 0;
min-height: 0;
}
.workflow-definition-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.workflow-panel-toolbar,
.workflow-workspace-toolbar,
.workflow-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.workflow-panel-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.workflow-toolbar-actions,
.workflow-command-bar,
.workflow-identity-fields {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-definition-search {
padding: 10px;
border-bottom: var(--border-line);
}
.workflow-definition-search input {
min-height: 34px;
padding: 7px 9px;
}
.workflow-definition-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.workflow-definition-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.workflow-definition-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.workflow-definition-list > button:hover,
.workflow-definition-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.workflow-definition-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.workflow-definition-list > button > span:first-child {
min-width: 0;
}
.workflow-definition-list strong,
.workflow-definition-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-definition-list strong {
color: var(--text-strong);
font-size: 13px;
}
.workflow-definition-list small {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.workflow-definition-empty,
.workflow-inspector-empty {
display: grid;
min-height: 100px;
place-items: center;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.workflow-workspace {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.workflow-workspace-toolbar {
min-height: 58px;
padding: 8px 10px;
}
.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-palette,
.workflow-inspector,
.workflow-inspector-column {
overflow: hidden;
background: var(--panel-soft);
}
.workflow-palette {
border-right: var(--border-line);
}
.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 {
min-height: 44px;
padding: 8px 10px 8px 12px;
}
.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-palette-items {
display: grid;
height: calc(100% - 44px);
gap: 4px;
overflow: auto;
padding: 8px;
}
.workflow-palette-group {
display: grid;
gap: 2px;
}
.workflow-palette-group + .workflow-palette-group {
margin-top: 6px;
padding-top: 8px;
border-top: var(--border-line);
}
.workflow-palette-group h3 {
margin: 0;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
padding: 3px 8px;
text-transform: uppercase;
}
.workflow-palette-items button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 14px;
align-items: center;
gap: 7px;
min-height: 38px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: grab;
font: inherit;
font-size: 12px;
padding: 7px 8px;
text-align: left;
}
.workflow-palette-items button:hover:not(:disabled),
.workflow-palette-items button:focus-visible:not(:disabled) {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.workflow-palette-items button:disabled {
cursor: default;
opacity: .42;
}
.workflow-palette-add {
color: var(--accent);
}
.workflow-editor-surface {
position: relative;
overflow: hidden;
background: var(--bg);
}
.workflow-canvas {
position: absolute;
inset: 0;
}
.workflow-canvas .react-flow {
background: var(--bg);
}
.workflow-canvas .react-flow__background {
color: var(--line-dark);
}
.workflow-canvas .react-flow__controls,
.workflow-canvas .react-flow__minimap {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-xs);
}
.workflow-canvas .react-flow__controls-button {
border-bottom: var(--border-line);
background: var(--panel);
color: var(--text);
}
.workflow-canvas .react-flow__minimap-mask {
fill: color-mix(in srgb, var(--bg) 76%, transparent);
}
.workflow-canvas .react-flow__edge-path {
stroke: var(--line-dark);
stroke-width: 2;
}
.workflow-canvas .react-flow__edge.selected .react-flow__edge-path,
.workflow-canvas .react-flow__edge:hover .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.workflow-canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
}
.workflow-node {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 190px;
min-height: 56px;
border: 1px solid var(--line-dark);
border-left: 4px solid #3d6f9e;
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow-xs);
padding: 8px 10px;
}
.workflow-node-trigger {
border-left-color: #2f7d6d;
}
.workflow-node-activity {
border-left-color: #3d6f9e;
}
.workflow-node-decision {
border-left-color: #b7791f;
}
.workflow-node-wait {
border-left-color: #8a6d3b;
}
.workflow-node-integration {
border-left-color: #76569b;
}
.workflow-node-outcome {
border-left-color: #9d4e63;
}
.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.has-error {
border-color: var(--danger-text);
}
.workflow-node-icon {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
border-radius: 5px;
background: var(--panel-soft);
color: var(--text-strong);
}
.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-node-handle {
width: 13px;
height: 13px;
border: 3px solid var(--panel);
background: var(--line-dark);
}
.workflow-node-handle:hover,
.workflow-node-handle.connectingto,
.workflow-node-handle.valid {
width: 17px;
height: 17px;
background: var(--accent);
}
.workflow-inspector-fields {
display: grid;
gap: 12px;
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 12px;
}
.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-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-workspace-empty {
display: grid;
height: 100%;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--muted);
}
.workflow-workspace-empty strong {
color: var(--text-strong);
}
.workflow-workspace-empty .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.workflow-working-indicator {
position: absolute;
z-index: 20;
right: 12px;
bottom: 10px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-popover);
color: var(--text-strong);
font-size: 12px;
padding: 7px 10px;
}
@media (max-width: 1180px) {
.workflow-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.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-shell {
grid-template-columns: 210px minmax(0, 1fr);
}
.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-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
}
.workflow-definition-panel {
border-right: 0;
border-bottom: var(--border-line);
}
.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-palette {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-palette .workflow-panel-heading {
display: none;
}
.workflow-palette-items {
display: flex;
overflow-x: auto;
}
.workflow-palette-group {
display: flex;
flex: 0 0 auto;
}
.workflow-palette-group h3 {
display: none;
}
.workflow-palette-items button {
min-width: 128px;
}
}

16
webui/src/vite-env.d.ts vendored Normal file
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;
}

33
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"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"]
}
},
"include": ["src"]
}