Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b015569b5e | |||
| 63e5ce949d | |||
| 1ec8336c02 | |||
| 6737b60c11 | |||
| 85eef00913 |
30
README.md
30
README.md
@@ -4,14 +4,40 @@
|
|||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- 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
|
The module should execute configurable state machines and command handoffs
|
||||||
between modules without importing their implementations. It coordinates cases,
|
between modules without importing their implementations. It coordinates cases,
|
||||||
tasks, forms, files, templates, mail, appointments, payments, and records
|
tasks, forms, files, templates, mail, appointments, payments, and records
|
||||||
through capabilities, events, commands, and DTOs.
|
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
|
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
|
become a separate module. It is quite viable to think about workflow
|
||||||
|
|||||||
@@ -95,7 +95,17 @@ Permit-to-payment MVP:
|
|||||||
|
|
||||||
## MVP Slice
|
## MVP Slice
|
||||||
|
|
||||||
The first implementation should provide:
|
The first executable slice now provides:
|
||||||
|
|
||||||
|
- a versioned, workflow-specific graphical node library
|
||||||
|
- shared Core graph DTO and validation primitives also consumed by Dataflow
|
||||||
|
- workflow constraints that permit loops while requiring one trigger and one or
|
||||||
|
more outcomes
|
||||||
|
- trigger, activity, review, decision, wait, module-action, Dataflow, and outcome
|
||||||
|
nodes
|
||||||
|
- API discovery and validation endpoints
|
||||||
|
|
||||||
|
The next execution slices should provide:
|
||||||
|
|
||||||
- static workflow definition registration from configuration packages
|
- static workflow definition registration from configuration packages
|
||||||
- create/read/list workflow instances
|
- create/read/list workflow instances
|
||||||
|
|||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal 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"
|
||||||
3
src/govoplan_workflow/__init__.py
Normal file
3
src/govoplan_workflow/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Workflow module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.14"
|
||||||
1
src/govoplan_workflow/backend/__init__.py
Normal file
1
src/govoplan_workflow/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow backend."""
|
||||||
9
src/govoplan_workflow/backend/db/__init__.py
Normal file
9
src/govoplan_workflow/backend/db/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from govoplan_workflow.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WorkflowDefinition",
|
||||||
|
"WorkflowDefinitionRevision",
|
||||||
|
]
|
||||||
195
src/govoplan_workflow/backend/db/models.py
Normal file
195
src/govoplan_workflow/backend/db/models.py
Normal 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",
|
||||||
|
]
|
||||||
318
src/govoplan_workflow/backend/governance.py
Normal file
318
src/govoplan_workflow/backend/governance.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
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.membership_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."
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
]
|
||||||
264
src/govoplan_workflow/backend/manifest.py
Normal file
264
src/govoplan_workflow/backend/manifest.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
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_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",
|
||||||
|
]
|
||||||
1
src/govoplan_workflow/backend/migrations/__init__.py
Normal file
1
src/govoplan_workflow/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow database migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow Alembic revisions."""
|
||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
390
src/govoplan_workflow/backend/node_library.py
Normal file
390
src/govoplan_workflow/backend/node_library.py
Normal 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",
|
||||||
|
]
|
||||||
671
src/govoplan_workflow/backend/router.py
Normal file
671
src/govoplan_workflow/backend/router.py
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
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.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}
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
definition = update_definition(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
definition_id=definition_id,
|
||||||
|
actor_id=_actor_id(principal),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
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.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}
|
||||||
|
)
|
||||||
|
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"]
|
||||||
11
src/govoplan_workflow/backend/runtime.py
Normal file
11
src/govoplan_workflow/backend/runtime.py
Normal 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
|
||||||
227
src/govoplan_workflow/backend/schemas.py
Normal file
227
src/govoplan_workflow/backend/schemas.py
Normal 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
|
||||||
658
src/govoplan_workflow/backend/service.py
Normal file
658
src/govoplan_workflow/backend/service.py
Normal 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",
|
||||||
|
]
|
||||||
82
src/govoplan_workflow/backend/validation.py
Normal file
82
src/govoplan_workflow/backend/validation.py
Normal 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"]
|
||||||
1
src/govoplan_workflow/py.typed
Normal file
1
src/govoplan_workflow/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
256
tests/test_governance.py
Normal file
256
tests/test_governance.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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.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_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
43
tests/test_manifest.py
Normal 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
48
tests/test_migrations.py
Normal 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()
|
||||||
97
tests/test_node_library.py
Normal file
97
tests/test_node_library.py
Normal 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
225
tests/test_service.py
Normal 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
32
webui/package.json
Normal 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.1.1",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
webui/src/api/workflow.ts
Normal file
218
webui/src/api/workflow.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { apiFetch, type ApiSettings } 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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
237
webui/src/features/workflow/WorkflowCanvas.tsx
Normal file
237
webui/src/features/workflow/WorkflowCanvas.tsx
Normal 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
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
185
webui/src/features/workflow/WorkflowInspector.tsx
Normal file
185
webui/src/features/workflow/WorkflowInspector.tsx
Normal 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);
|
||||||
|
}
|
||||||
99
webui/src/features/workflow/WorkflowNode.tsx
Normal file
99
webui/src/features/workflow/WorkflowNode.tsx
Normal 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}%`;
|
||||||
|
}
|
||||||
1025
webui/src/features/workflow/WorkflowPage.tsx
Normal file
1025
webui/src/features/workflow/WorkflowPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
234
webui/src/features/workflow/model.ts
Normal file
234
webui/src/features/workflow/model.ts
Normal 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
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { workflowModule as default, workflowModule } from "./module";
|
||||||
|
export * from "./api/workflow";
|
||||||
45
webui/src/module.ts
Normal file
45
webui/src/module.ts
Normal 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;
|
||||||
752
webui/src/styles/workflow.css
Normal file
752
webui/src/styles/workflow.css
Normal 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
16
webui/src/vite-env.d.ts
vendored
Normal 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
33
webui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user