Add governed reusable workflow definitions
This commit is contained in:
12
README.md
12
README.md
@@ -25,6 +25,18 @@ Instance state, resumable transitions, human activities, retries, and event
|
|||||||
subscriptions must build on the pinned definition and versioned module
|
subscriptions must build on the pinned definition and versioned module
|
||||||
capabilities rather than bypassing them.
|
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.
|
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
@@ -27,7 +28,7 @@ class WorkflowDefinition(Base, TimestampMixin):
|
|||||||
__tablename__ = "workflow_definitions"
|
__tablename__ = "workflow_definitions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"tenant_id",
|
"scope_key",
|
||||||
"definition_key",
|
"definition_key",
|
||||||
name="uq_workflow_definition_key",
|
name="uq_workflow_definition_key",
|
||||||
),
|
),
|
||||||
@@ -36,7 +37,71 @@ class WorkflowDefinition(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
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)
|
definition_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -98,7 +163,11 @@ class WorkflowDefinitionRevision(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
tenant_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
definition_id: Mapped[str] = mapped_column(
|
definition_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
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",
|
||||||
|
]
|
||||||
@@ -23,6 +23,9 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_workflow.backend.db import models as workflow_models
|
from govoplan_workflow.backend.db import models as workflow_models
|
||||||
|
|
||||||
@@ -107,7 +110,10 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _router(_context: ModuleContext):
|
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
|
from govoplan_workflow.backend.router import router
|
||||||
|
|
||||||
return router
|
return router
|
||||||
@@ -131,6 +137,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
|
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
|
||||||
@@ -144,6 +151,12 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="1.0.0",
|
version_max_exclusive="1.0.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="policy.definition_governance",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="1.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -24,7 +24,7 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
type="workflow.start.manual",
|
type="workflow.start.manual",
|
||||||
category="trigger",
|
category="trigger",
|
||||||
label="Manual start",
|
label="Manual start",
|
||||||
description="Start an instance through an explicit user or API action.",
|
description="Start an instance through an explicit user action.",
|
||||||
icon="circle-play",
|
icon="circle-play",
|
||||||
default_config={"input_schema_ref": ""},
|
default_config={"input_schema_ref": ""},
|
||||||
config_fields=(
|
config_fields=(
|
||||||
@@ -36,6 +36,68 @@ WORKFLOW_NODE_TYPES = (
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
DefinitionNodeType(
|
||||||
type="workflow.start.event",
|
type="workflow.start.event",
|
||||||
category="trigger",
|
category="trigger",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
from govoplan_core.db.session import get_session
|
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 (
|
from govoplan_workflow.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
DEFINITION_READ_SCOPE,
|
DEFINITION_READ_SCOPE,
|
||||||
@@ -17,6 +22,7 @@ from govoplan_workflow.backend.schemas import (
|
|||||||
WorkflowDefinitionActivateRequest,
|
WorkflowDefinitionActivateRequest,
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
WorkflowDefinitionDeleteResponse,
|
WorkflowDefinitionDeleteResponse,
|
||||||
|
WorkflowDefinitionDeriveRequest,
|
||||||
WorkflowDefinitionListResponse,
|
WorkflowDefinitionListResponse,
|
||||||
WorkflowDefinitionResponse,
|
WorkflowDefinitionResponse,
|
||||||
WorkflowDefinitionRevisionListResponse,
|
WorkflowDefinitionRevisionListResponse,
|
||||||
@@ -29,6 +35,7 @@ from govoplan_workflow.backend.schemas import (
|
|||||||
WorkflowNodeTypeResponse,
|
WorkflowNodeTypeResponse,
|
||||||
WorkflowPortResponse,
|
WorkflowPortResponse,
|
||||||
)
|
)
|
||||||
|
from govoplan_workflow.backend.runtime import get_registry
|
||||||
from govoplan_workflow.backend.service import (
|
from govoplan_workflow.backend.service import (
|
||||||
WorkflowConflictError,
|
WorkflowConflictError,
|
||||||
WorkflowError,
|
WorkflowError,
|
||||||
@@ -39,6 +46,7 @@ from govoplan_workflow.backend.service import (
|
|||||||
create_definition,
|
create_definition,
|
||||||
definition_response,
|
definition_response,
|
||||||
delete_definition,
|
delete_definition,
|
||||||
|
derive_definition,
|
||||||
get_definition,
|
get_definition,
|
||||||
get_definition_revision,
|
get_definition_revision,
|
||||||
list_definition_revisions,
|
list_definition_revisions,
|
||||||
@@ -89,6 +97,35 @@ def _http_error(exc: WorkflowError) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _actor_id(principal: ApiPrincipal) -> str | None:
|
||||||
return principal.account_id or principal.membership_id or principal.identity_id
|
return principal.account_id or principal.membership_id or principal.identity_id
|
||||||
|
|
||||||
@@ -209,13 +246,29 @@ def api_list_definitions(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> WorkflowDefinitionListResponse:
|
) -> WorkflowDefinitionListResponse:
|
||||||
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
|
||||||
return WorkflowDefinitionListResponse(
|
registry = get_registry()
|
||||||
definitions=[
|
definitions = [
|
||||||
definition_response(session, definition)
|
definition
|
||||||
for definition in list_definitions(
|
for definition in list_definitions(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
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
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,12 +285,25 @@ def api_create_definition(
|
|||||||
) -> WorkflowDefinitionResponse:
|
) -> WorkflowDefinitionResponse:
|
||||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
try:
|
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(
|
definition = create_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=tenant_id or principal.tenant_id,
|
||||||
actor_id=_actor_id(principal),
|
actor_id=_actor_id(principal),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
except (PermissionError, ValueError) as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
_audit(
|
_audit(
|
||||||
@@ -248,9 +314,12 @@ def api_create_definition(
|
|||||||
details={
|
details={
|
||||||
"key": definition.definition_key,
|
"key": definition.definition_key,
|
||||||
"revision": definition.current_revision,
|
"revision": definition.current_revision,
|
||||||
|
"scope_type": definition.scope_type,
|
||||||
|
"scope_id": definition.scope_id,
|
||||||
|
"definition_kind": definition.definition_kind,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = definition_response(session, definition)
|
response = _definition_response(session, definition, principal)
|
||||||
session.commit()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -272,7 +341,20 @@ def api_get_definition(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
definition_id=definition_id,
|
definition_id=definition_id,
|
||||||
)
|
)
|
||||||
return definition_response(session, definition, revision=revision)
|
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:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
@@ -289,6 +371,17 @@ def api_update_definition(
|
|||||||
) -> WorkflowDefinitionResponse:
|
) -> WorkflowDefinitionResponse:
|
||||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
try:
|
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(
|
definition = update_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -296,6 +389,8 @@ def api_update_definition(
|
|||||||
actor_id=_actor_id(principal),
|
actor_id=_actor_id(principal),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
_audit(
|
_audit(
|
||||||
@@ -308,7 +403,64 @@ def api_update_definition(
|
|||||||
"status": definition.status,
|
"status": definition.status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = definition_response(session, definition)
|
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()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -329,7 +481,15 @@ def api_list_definition_revisions(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
definition_id=definition_id,
|
definition_id=definition_id,
|
||||||
)
|
)
|
||||||
|
require_definition_action(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
revisions = list_definition_revisions(session, definition=definition)
|
revisions = list_definition_revisions(session, definition=definition)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
return WorkflowDefinitionRevisionListResponse(
|
return WorkflowDefinitionRevisionListResponse(
|
||||||
@@ -354,11 +514,19 @@ def api_get_definition_revision(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
definition_id=definition_id,
|
definition_id=definition_id,
|
||||||
)
|
)
|
||||||
|
require_definition_action(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
item = get_definition_revision(
|
item = get_definition_revision(
|
||||||
session,
|
session,
|
||||||
definition=definition,
|
definition=definition,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
)
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
return revision_response(item)
|
return revision_response(item)
|
||||||
@@ -376,6 +544,17 @@ def api_activate_definition(
|
|||||||
) -> WorkflowDefinitionResponse:
|
) -> WorkflowDefinitionResponse:
|
||||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
try:
|
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(
|
definition = activate_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -383,6 +562,8 @@ def api_activate_definition(
|
|||||||
actor_id=_actor_id(principal),
|
actor_id=_actor_id(principal),
|
||||||
revision=payload.revision,
|
revision=payload.revision,
|
||||||
)
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
_audit(
|
_audit(
|
||||||
@@ -392,7 +573,7 @@ def api_activate_definition(
|
|||||||
definition_id=definition.id,
|
definition_id=definition.id,
|
||||||
details={"active_revision": definition.active_revision},
|
details={"active_revision": definition.active_revision},
|
||||||
)
|
)
|
||||||
response = definition_response(session, definition)
|
response = _definition_response(session, definition, principal)
|
||||||
session.commit()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -408,12 +589,25 @@ def api_archive_definition(
|
|||||||
) -> WorkflowDefinitionResponse:
|
) -> WorkflowDefinitionResponse:
|
||||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
try:
|
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(
|
definition = archive_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
definition_id=definition_id,
|
definition_id=definition_id,
|
||||||
actor_id=_actor_id(principal),
|
actor_id=_actor_id(principal),
|
||||||
)
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
_audit(
|
_audit(
|
||||||
@@ -423,7 +617,7 @@ def api_archive_definition(
|
|||||||
definition_id=definition.id,
|
definition_id=definition.id,
|
||||||
details={"active_revision": definition.active_revision},
|
details={"active_revision": definition.active_revision},
|
||||||
)
|
)
|
||||||
response = definition_response(session, definition)
|
response = _definition_response(session, definition, principal)
|
||||||
session.commit()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -439,12 +633,25 @@ def api_delete_definition(
|
|||||||
) -> WorkflowDefinitionDeleteResponse:
|
) -> WorkflowDefinitionDeleteResponse:
|
||||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
try:
|
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(
|
definition = delete_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
definition_id=definition_id,
|
definition_id=definition_id,
|
||||||
actor_id=_actor_id(principal),
|
actor_id=_actor_id(principal),
|
||||||
)
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
except WorkflowError as exc:
|
except WorkflowError as exc:
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
_audit(
|
_audit(
|
||||||
|
|||||||
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
|
||||||
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
|
|
||||||
|
|
||||||
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
|
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
|
||||||
|
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||||
|
DefinitionKind = Literal["flow", "template"]
|
||||||
|
|
||||||
|
|
||||||
class WorkflowPosition(BaseModel):
|
class WorkflowPosition(BaseModel):
|
||||||
@@ -110,9 +112,34 @@ class WorkflowDefinitionRevisionResponse(BaseModel):
|
|||||||
created_at: datetime
|
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):
|
class WorkflowDefinitionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str | None
|
||||||
key: str
|
key: str
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
@@ -125,6 +152,7 @@ class WorkflowDefinitionResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
revision: WorkflowDefinitionRevisionResponse
|
revision: WorkflowDefinitionRevisionResponse
|
||||||
|
governance: WorkflowGovernanceResponse
|
||||||
|
|
||||||
|
|
||||||
class WorkflowDefinitionListResponse(BaseModel):
|
class WorkflowDefinitionListResponse(BaseModel):
|
||||||
@@ -146,6 +174,13 @@ class WorkflowDefinitionCreateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=4_000)
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
graph: WorkflowGraph
|
graph: WorkflowGraph
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class WorkflowDefinitionUpdateRequest(BaseModel):
|
||||||
@@ -154,6 +189,33 @@ class WorkflowDefinitionUpdateRequest(BaseModel):
|
|||||||
graph: WorkflowGraph
|
graph: WorkflowGraph
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
expected_revision: int = Field(ge=1)
|
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):
|
class WorkflowDefinitionActivateRequest(BaseModel):
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_workflow.backend.db.models import (
|
from govoplan_workflow.backend.db.models import (
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowDefinitionRevision,
|
WorkflowDefinitionRevision,
|
||||||
)
|
)
|
||||||
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
|
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 (
|
from govoplan_workflow.backend.schemas import (
|
||||||
WorkflowDefinitionCreateRequest,
|
WorkflowDefinitionCreateRequest,
|
||||||
|
WorkflowDefinitionDeriveRequest,
|
||||||
WorkflowDefinitionResponse,
|
WorkflowDefinitionResponse,
|
||||||
WorkflowDefinitionRevisionResponse,
|
WorkflowDefinitionRevisionResponse,
|
||||||
WorkflowDefinitionUpdateRequest,
|
WorkflowDefinitionUpdateRequest,
|
||||||
@@ -54,7 +61,10 @@ def list_definitions(
|
|||||||
session.scalars(
|
session.scalars(
|
||||||
select(WorkflowDefinition)
|
select(WorkflowDefinition)
|
||||||
.where(
|
.where(
|
||||||
|
or_(
|
||||||
WorkflowDefinition.tenant_id == tenant_id,
|
WorkflowDefinition.tenant_id == tenant_id,
|
||||||
|
WorkflowDefinition.tenant_id.is_(None),
|
||||||
|
),
|
||||||
WorkflowDefinition.deleted_at.is_(None),
|
WorkflowDefinition.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
@@ -74,7 +84,10 @@ def get_definition(
|
|||||||
definition = session.scalar(
|
definition = session.scalar(
|
||||||
select(WorkflowDefinition).where(
|
select(WorkflowDefinition).where(
|
||||||
WorkflowDefinition.id == definition_id,
|
WorkflowDefinition.id == definition_id,
|
||||||
|
or_(
|
||||||
WorkflowDefinition.tenant_id == tenant_id,
|
WorkflowDefinition.tenant_id == tenant_id,
|
||||||
|
WorkflowDefinition.tenant_id.is_(None),
|
||||||
|
),
|
||||||
WorkflowDefinition.deleted_at.is_(None),
|
WorkflowDefinition.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -127,11 +140,34 @@ def create_definition(
|
|||||||
payload: WorkflowDefinitionCreateRequest,
|
payload: WorkflowDefinitionCreateRequest,
|
||||||
) -> WorkflowDefinition:
|
) -> WorkflowDefinition:
|
||||||
graph = _validated_graph(payload.graph)
|
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(
|
definition = WorkflowDefinition(
|
||||||
tenant_id=tenant_id,
|
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(
|
definition_key=_available_key(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
scope_key=scope_key,
|
||||||
requested=payload.key,
|
requested=payload.key,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
),
|
),
|
||||||
@@ -146,7 +182,7 @@ def create_definition(
|
|||||||
)
|
)
|
||||||
definition.revisions.append(
|
definition.revisions.append(
|
||||||
_new_revision(
|
_new_revision(
|
||||||
tenant_id=tenant_id,
|
tenant_id=stored_tenant_id,
|
||||||
revision=1,
|
revision=1,
|
||||||
graph=graph,
|
graph=graph,
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
@@ -176,18 +212,49 @@ def update_definition(
|
|||||||
f"expected revision {payload.expected_revision}, "
|
f"expected revision {payload.expected_revision}, "
|
||||||
f"current revision is {definition.current_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)
|
graph = _validated_graph(payload.graph)
|
||||||
current = get_definition_revision(session, definition=definition)
|
current = get_definition_revision(session, definition=definition)
|
||||||
graph_hash = _content_hash(graph)
|
graph_hash = _content_hash(graph)
|
||||||
definition.name = payload.name.strip()
|
definition.name = payload.name.strip()
|
||||||
definition.description = _clean_optional(payload.description)
|
definition.description = _clean_optional(payload.description)
|
||||||
definition.metadata_ = dict(payload.metadata)
|
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
|
definition.updated_by = actor_id
|
||||||
if current.content_hash != graph_hash:
|
if current.content_hash != graph_hash:
|
||||||
definition.current_revision += 1
|
definition.current_revision += 1
|
||||||
definition.revisions.append(
|
definition.revisions.append(
|
||||||
_new_revision(
|
_new_revision(
|
||||||
tenant_id=tenant_id,
|
tenant_id=definition.tenant_id,
|
||||||
revision=definition.current_revision,
|
revision=definition.current_revision,
|
||||||
graph=graph,
|
graph=graph,
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
@@ -199,6 +266,127 @@ def update_definition(
|
|||||||
return definition
|
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(
|
def activate_definition(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -217,6 +405,10 @@ def activate_definition(
|
|||||||
definition=definition,
|
definition=definition,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
)
|
)
|
||||||
|
if definition.definition_kind == "template":
|
||||||
|
raise WorkflowConflictError(
|
||||||
|
"Workflow templates cannot be activated or started."
|
||||||
|
)
|
||||||
_validated_graph(WorkflowGraph.model_validate(selected.graph))
|
_validated_graph(WorkflowGraph.model_validate(selected.graph))
|
||||||
definition.active_revision = selected.revision
|
definition.active_revision = selected.revision
|
||||||
definition.status = "active"
|
definition.status = "active"
|
||||||
@@ -265,6 +457,8 @@ def definition_response(
|
|||||||
session: Session,
|
session: Session,
|
||||||
definition: WorkflowDefinition,
|
definition: WorkflowDefinition,
|
||||||
*,
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
revision: int | None = None,
|
revision: int | None = None,
|
||||||
) -> WorkflowDefinitionResponse:
|
) -> WorkflowDefinitionResponse:
|
||||||
selected = get_definition_revision(
|
selected = get_definition_revision(
|
||||||
@@ -287,6 +481,11 @@ def definition_response(
|
|||||||
created_at=definition.created_at,
|
created_at=definition.created_at,
|
||||||
updated_at=definition.updated_at,
|
updated_at=definition.updated_at,
|
||||||
revision=revision_response(selected),
|
revision=revision_response(selected),
|
||||||
|
governance=definition_governance_payload(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=registry,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -308,7 +507,7 @@ def revision_response(
|
|||||||
|
|
||||||
def _new_revision(
|
def _new_revision(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str | None,
|
||||||
revision: int,
|
revision: int,
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
@@ -348,7 +547,7 @@ def _content_hash(graph: WorkflowGraph) -> str:
|
|||||||
def _available_key(
|
def _available_key(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
scope_key: str,
|
||||||
requested: str | None,
|
requested: str | None,
|
||||||
name: str,
|
name: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -357,7 +556,7 @@ def _available_key(
|
|||||||
suffix = 2
|
suffix = 2
|
||||||
while session.scalar(
|
while session.scalar(
|
||||||
select(WorkflowDefinition.id).where(
|
select(WorkflowDefinition.id).where(
|
||||||
WorkflowDefinition.tenant_id == tenant_id,
|
WorkflowDefinition.scope_key == scope_key,
|
||||||
WorkflowDefinition.definition_key == candidate,
|
WorkflowDefinition.definition_key == candidate,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -380,6 +579,65 @@ def _clean_optional(value: str | None) -> str | None:
|
|||||||
return cleaned or None
|
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__ = [
|
__all__ = [
|
||||||
"WorkflowConflictError",
|
"WorkflowConflictError",
|
||||||
"WorkflowError",
|
"WorkflowError",
|
||||||
@@ -388,6 +646,7 @@ __all__ = [
|
|||||||
"activate_definition",
|
"activate_definition",
|
||||||
"archive_definition",
|
"archive_definition",
|
||||||
"create_definition",
|
"create_definition",
|
||||||
|
"derive_definition",
|
||||||
"definition_response",
|
"definition_response",
|
||||||
"delete_definition",
|
"delete_definition",
|
||||||
"get_definition",
|
"get_definition",
|
||||||
|
|||||||
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()
|
||||||
@@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"a7c4e2f9b1d3",
|
"c6d8f1a3e5b7",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
} from "@govoplan/core-webui/definition-graph";
|
} from "@govoplan/core-webui/definition-graph";
|
||||||
|
|
||||||
export type WorkflowStatus = "draft" | "active" | "archived";
|
export type WorkflowStatus = "draft" | "active" | "archived";
|
||||||
|
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
|
||||||
|
export type DefinitionKind = "flow" | "template";
|
||||||
export type WorkflowGraphNode = DefinitionGraphNode;
|
export type WorkflowGraphNode = DefinitionGraphNode;
|
||||||
export type WorkflowGraph = DefinitionGraph & {
|
export type WorkflowGraph = DefinitionGraph & {
|
||||||
schema_version: 1;
|
schema_version: 1;
|
||||||
@@ -36,7 +38,7 @@ export type WorkflowRevision = {
|
|||||||
|
|
||||||
export type WorkflowDefinition = {
|
export type WorkflowDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string | null;
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -49,6 +51,32 @@ export type WorkflowDefinition = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
revision: WorkflowRevision;
|
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 = {
|
export type WorkflowDefinitionPayload = {
|
||||||
@@ -56,6 +84,13 @@ export type WorkflowDefinitionPayload = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
graph: WorkflowGraph;
|
graph: WorkflowGraph;
|
||||||
metadata: Record<string, unknown>;
|
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(
|
export async function listWorkflowNodeTypes(
|
||||||
@@ -99,6 +134,31 @@ export function updateWorkflowDefinition(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function listWorkflowRevisions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
definitionId: string
|
definitionId: string
|
||||||
|
|||||||
@@ -8,21 +8,26 @@ import {
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
CopyPlus,
|
||||||
GitFork,
|
GitFork,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
|
Settings2,
|
||||||
Trash2
|
Trash2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ReactFlowProvider } from "@xyflow/react";
|
import { ReactFlowProvider } from "@xyflow/react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
isApiError,
|
isApiError,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -35,6 +40,7 @@ import {
|
|||||||
archiveWorkflowDefinition,
|
archiveWorkflowDefinition,
|
||||||
createWorkflowDefinition,
|
createWorkflowDefinition,
|
||||||
deleteWorkflowDefinition,
|
deleteWorkflowDefinition,
|
||||||
|
deriveWorkflowDefinition,
|
||||||
listWorkflowDefinitions,
|
listWorkflowDefinitions,
|
||||||
listWorkflowNodeTypes,
|
listWorkflowNodeTypes,
|
||||||
listWorkflowRevisions,
|
listWorkflowRevisions,
|
||||||
@@ -93,13 +99,23 @@ export default function WorkflowPage({
|
|||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
|
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
||||||
|
const [deriveOpen, setDeriveOpen] = useState(false);
|
||||||
|
|
||||||
const canWrite = hasScope(auth, "workflow:definition:write")
|
const canWrite = hasScope(auth, "workflow:definition:write")
|
||||||
|| hasScope(auth, "workflow:instance:admin");
|
|| hasScope(auth, "workflow:instance:admin");
|
||||||
|
const canEdit = canWrite && (
|
||||||
|
!draft?.id || draft.governance?.actions.edit?.allowed !== false
|
||||||
|
);
|
||||||
|
const canReuse = Boolean(
|
||||||
|
draft?.id
|
||||||
|
&& canWrite
|
||||||
|
&& draft.governance?.actions.derive?.allowed
|
||||||
|
);
|
||||||
const dirty = Boolean(draft)
|
const dirty = Boolean(draft)
|
||||||
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
||||||
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
||||||
const readOnly = !canWrite || historicalRevision !== null;
|
const readOnly = !canEdit || historicalRevision !== null;
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||||
[displayedGraph, selectedNodeId]
|
[displayedGraph, selectedNodeId]
|
||||||
@@ -205,7 +221,7 @@ export default function WorkflowPage({
|
|||||||
}, [savedDraft]);
|
}, [savedDraft]);
|
||||||
|
|
||||||
const saveDraft = useCallback(async (): Promise<boolean> => {
|
const saveDraft = useCallback(async (): Promise<boolean> => {
|
||||||
if (!draft || !canWrite || !draft.name.trim()) {
|
if (!draft || !canEdit || !draft.name.trim()) {
|
||||||
setError(
|
setError(
|
||||||
!draft?.name.trim()
|
!draft?.name.trim()
|
||||||
? "Workflow name is required."
|
? "Workflow name is required."
|
||||||
@@ -236,7 +252,7 @@ export default function WorkflowPage({
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(false);
|
setWorking(false);
|
||||||
}
|
}
|
||||||
}, [applyDefinition, canWrite, draft, settings]);
|
}, [applyDefinition, canEdit, draft, settings]);
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
dirty,
|
dirty,
|
||||||
@@ -413,6 +429,9 @@ export default function WorkflowPage({
|
|||||||
<small>
|
<small>
|
||||||
{definition.key} · revision {definition.current_revision}
|
{definition.key} · revision {definition.current_revision}
|
||||||
</small>
|
</small>
|
||||||
|
<small>
|
||||||
|
{definition.governance.scope_type} · {definition.governance.definition_kind}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={definition.status}
|
status={definition.status}
|
||||||
@@ -502,7 +521,7 @@ export default function WorkflowPage({
|
|||||||
setHistoricalRevision(null);
|
setHistoricalRevision(null);
|
||||||
setDiagnostics([]);
|
setDiagnostics([]);
|
||||||
}}
|
}}
|
||||||
disabled={!canWrite}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} /> Restore
|
<RotateCcw size={16} /> Restore
|
||||||
</Button>
|
</Button>
|
||||||
@@ -510,10 +529,25 @@ export default function WorkflowPage({
|
|||||||
<Button onClick={() => void validate()} disabled={working}>
|
<Button onClick={() => void validate()} disabled={working}>
|
||||||
<CheckCircle2 size={16} /> Validate
|
<CheckCircle2 size={16} /> Validate
|
||||||
</Button>
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
label="Definition settings"
|
||||||
|
icon={<Settings2 size={16} />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDefinitionSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
{draft.id ? (
|
||||||
|
<IconButton
|
||||||
|
label="Reuse as scoped copy"
|
||||||
|
icon={<CopyPlus size={16} />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeriveOpen(true)}
|
||||||
|
disabled={!canReuse}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{draft.id && draft.status !== "archived" ? (
|
{draft.id && draft.status !== "archived" ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void archiveDefinition()}
|
onClick={() => void archiveDefinition()}
|
||||||
disabled={!canWrite || dirty || working || readOnly}
|
disabled={!canEdit || dirty || working || readOnly}
|
||||||
>
|
>
|
||||||
<Archive size={16} /> Archive
|
<Archive size={16} /> Archive
|
||||||
</Button>
|
</Button>
|
||||||
@@ -522,7 +556,18 @@ export default function WorkflowPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void activate()}
|
onClick={() => void activate()}
|
||||||
disabled={!canWrite || dirty || working || readOnly}
|
disabled={
|
||||||
|
!canEdit
|
||||||
|
|| dirty
|
||||||
|
|| working
|
||||||
|
|| readOnly
|
||||||
|
|| draft.definitionKind === "template"
|
||||||
|
}
|
||||||
|
disabledReason={
|
||||||
|
draft.definitionKind === "template"
|
||||||
|
? "Templates cannot be activated or started."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CheckCircle2 size={16} /> Activate
|
<CheckCircle2 size={16} /> Activate
|
||||||
</Button>
|
</Button>
|
||||||
@@ -530,7 +575,7 @@ export default function WorkflowPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void saveDraft()}
|
onClick={() => void saveDraft()}
|
||||||
disabled={!canWrite || !dirty || working || readOnly}
|
disabled={!canEdit || !dirty || working || readOnly}
|
||||||
>
|
>
|
||||||
<Save size={16} /> Save
|
<Save size={16} /> Save
|
||||||
</Button>
|
</Button>
|
||||||
@@ -540,7 +585,7 @@ export default function WorkflowPage({
|
|||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
disabled={!canWrite || working}
|
disabled={!canEdit || working}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
@@ -674,10 +719,287 @@ export default function WorkflowPage({
|
|||||||
onCancel={() => setDeleteOpen(false)}
|
onCancel={() => setDeleteOpen(false)}
|
||||||
onConfirm={() => void removeDefinition()}
|
onConfirm={() => void removeDefinition()}
|
||||||
/>
|
/>
|
||||||
|
<WorkflowDefinitionSettingsDialog
|
||||||
|
open={definitionSettingsOpen}
|
||||||
|
draft={draft}
|
||||||
|
editable={canEdit}
|
||||||
|
onChange={(patch) => setDraft((current) => current ? { ...current, ...patch } : current)}
|
||||||
|
onClose={() => setDefinitionSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
<DeriveWorkflowDialog
|
||||||
|
open={deriveOpen}
|
||||||
|
settings={settings}
|
||||||
|
definition={draft?.id ? {
|
||||||
|
id: draft.id,
|
||||||
|
name: draft.name,
|
||||||
|
revision: draft.currentRevision ?? 1
|
||||||
|
} : null}
|
||||||
|
onClose={() => setDeriveOpen(false)}
|
||||||
|
onDerived={(definition) => {
|
||||||
|
applyDefinition(definition);
|
||||||
|
setDefinitions((current) => [
|
||||||
|
definition,
|
||||||
|
...current.filter((item) => item.id !== definition.id)
|
||||||
|
]);
|
||||||
|
setDeriveOpen(false);
|
||||||
|
setSuccess("Created a pinned scoped copy.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkflowDefinitionSettingsDialog({
|
||||||
|
open,
|
||||||
|
draft,
|
||||||
|
editable,
|
||||||
|
onChange,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
draft: WorkflowDraft | null;
|
||||||
|
editable: boolean;
|
||||||
|
onChange: (patch: Partial<WorkflowDraft>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Definition settings"
|
||||||
|
className="workflow-definition-dialog"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
|
>
|
||||||
|
{draft ? (
|
||||||
|
<div className="workflow-definition-fields">
|
||||||
|
<FormField
|
||||||
|
label="Scope"
|
||||||
|
help="The scope determines ownership, visibility, and the Policy inheritance path."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={draft.scopeType}
|
||||||
|
disabled={!editable || Boolean(draft.id)}
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
scopeType: event.target.value as WorkflowDraft["scopeType"],
|
||||||
|
scopeId: ""
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="tenant">Tenant</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Scope ID">
|
||||||
|
<input
|
||||||
|
value={draft.scopeId}
|
||||||
|
disabled={
|
||||||
|
!editable
|
||||||
|
|| Boolean(draft.id)
|
||||||
|
|| draft.scopeType === "system"
|
||||||
|
|| draft.scopeType === "tenant"
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
draft.scopeType === "user"
|
||||||
|
? "Current user when empty"
|
||||||
|
: "Required for group"
|
||||||
|
}
|
||||||
|
onChange={(event) => onChange({ scopeId: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="Definition kind"
|
||||||
|
help="Templates can be reused or derived, but cannot be activated or started."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={draft.definitionKind}
|
||||||
|
disabled={!editable || Boolean(draft.id)}
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
definitionKind: event.target.value as WorkflowDraft["definitionKind"],
|
||||||
|
status: "draft"
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="flow">Complete flow</option>
|
||||||
|
<option value="template">Template</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<div className="workflow-definition-toggles">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Visible to lower scopes"
|
||||||
|
checked={draft.inheritToLowerScopes}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(value) => onChange({ inheritToLowerScopes: value })}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Allow starts"
|
||||||
|
checked={draft.allowStart}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(value) => onChange({ allowStart: value })}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Allow reuse"
|
||||||
|
checked={draft.allowReuse}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(value) => onChange({ allowReuse: value })}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Allow automation"
|
||||||
|
checked={draft.allowAutomation}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(value) => onChange({ allowAutomation: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{draft.governance?.automation_runtime_reason ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
resetKey={draft.governance.automation_runtime_reason}
|
||||||
|
>
|
||||||
|
{draft.governance.automation_runtime_reason}
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
{draft.governance?.derived_from_definition_id ? (
|
||||||
|
<section className="workflow-provenance">
|
||||||
|
<strong>Derived from</strong>
|
||||||
|
<span>
|
||||||
|
{draft.governance.derived_from_definition_id}
|
||||||
|
{" · revision "}
|
||||||
|
{draft.governance.derived_from_revision}
|
||||||
|
</span>
|
||||||
|
<code>{draft.governance.derived_from_hash}</code>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{provenance.length ? (
|
||||||
|
<section className="workflow-provenance">
|
||||||
|
<strong>Effective Policy path</strong>
|
||||||
|
{provenance.map((item, index) => (
|
||||||
|
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
||||||
|
{String(item.label ?? item.path ?? item.scope_type)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!editable && draft.governance?.actions.edit?.reason ? (
|
||||||
|
<small>{draft.governance.actions.edit.reason}</small>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeriveWorkflowDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
definition,
|
||||||
|
onClose,
|
||||||
|
onDerived
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: ApiSettings;
|
||||||
|
definition: { id: string; name: string; revision: number } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDerived: (definition: WorkflowDefinition) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [kind, setKind] = useState<"flow" | "template">("flow");
|
||||||
|
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
|
||||||
|
const [scopeId, setScopeId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setName(`${definition?.name ?? "Workflow"} copy`);
|
||||||
|
setKind("flow");
|
||||||
|
setScopeType("tenant");
|
||||||
|
setScopeId("");
|
||||||
|
setError("");
|
||||||
|
}, [open, definition?.id]);
|
||||||
|
|
||||||
|
const derive = async () => {
|
||||||
|
if (!definition || !name.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
onDerived(await deriveWorkflowDefinition(settings, definition.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
source_revision: definition.revision,
|
||||||
|
metadata: {},
|
||||||
|
scope_type: scopeType,
|
||||||
|
scope_id: scopeId.trim() || null,
|
||||||
|
definition_kind: kind,
|
||||||
|
inherit_to_lower_scopes: false,
|
||||||
|
allow_start: true,
|
||||||
|
allow_reuse: false,
|
||||||
|
allow_automation: false
|
||||||
|
}));
|
||||||
|
} catch (deriveError) {
|
||||||
|
setError(apiErrorMessage(deriveError));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Reuse as scoped copy"
|
||||||
|
className="workflow-definition-dialog"
|
||||||
|
closeDisabled={busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void derive()}
|
||||||
|
disabled={busy || !definition || !name.trim()}
|
||||||
|
>
|
||||||
|
<CopyPlus size={16} /> Create copy
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="workflow-definition-fields">
|
||||||
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
|
<FormField label="Name">
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Target scope">
|
||||||
|
<select
|
||||||
|
value={scopeType}
|
||||||
|
onChange={(event) => setScopeType(event.target.value as typeof scopeType)}
|
||||||
|
>
|
||||||
|
<option value="tenant">Tenant</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{scopeType !== "tenant" ? (
|
||||||
|
<FormField label="Scope ID">
|
||||||
|
<input
|
||||||
|
value={scopeId}
|
||||||
|
placeholder={scopeType === "user" ? "Current user when empty" : "Group ID"}
|
||||||
|
onChange={(event) => setScopeId(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
<FormField label="Copy kind">
|
||||||
|
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
|
||||||
|
<option value="flow">Complete flow</option>
|
||||||
|
<option value="template">Template</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<small>
|
||||||
|
The source graph revision, node-library version, content hash, and
|
||||||
|
effective ancestor limits are retained as provenance.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function startPaletteDrag(
|
function startPaletteDrag(
|
||||||
event: DragEvent<HTMLButtonElement>,
|
event: DragEvent<HTMLButtonElement>,
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
|
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
|
||||||
import type {
|
import type {
|
||||||
|
DefinitionKind,
|
||||||
|
DefinitionScopeType,
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowDefinitionPayload,
|
WorkflowDefinitionPayload,
|
||||||
|
WorkflowGovernance,
|
||||||
WorkflowGraph,
|
WorkflowGraph,
|
||||||
WorkflowGraphNode,
|
WorkflowGraphNode,
|
||||||
WorkflowNodeType,
|
WorkflowNodeType,
|
||||||
@@ -17,6 +20,14 @@ export type WorkflowDraft = {
|
|||||||
activeRevision?: number | null;
|
activeRevision?: number | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
graph: WorkflowGraph;
|
graph: WorkflowGraph;
|
||||||
|
scopeType: DefinitionScopeType;
|
||||||
|
scopeId: string;
|
||||||
|
definitionKind: DefinitionKind;
|
||||||
|
inheritToLowerScopes: boolean;
|
||||||
|
allowStart: boolean;
|
||||||
|
allowReuse: boolean;
|
||||||
|
allowAutomation: boolean;
|
||||||
|
governance: WorkflowGovernance | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputPort = [{
|
const inputPort = [{
|
||||||
@@ -92,6 +103,14 @@ export function sampleWorkflowDraft(): WorkflowDraft {
|
|||||||
description: "",
|
description: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
scopeType: "tenant",
|
||||||
|
scopeId: "",
|
||||||
|
definitionKind: "flow",
|
||||||
|
inheritToLowerScopes: false,
|
||||||
|
allowStart: true,
|
||||||
|
allowReuse: false,
|
||||||
|
allowAutomation: false,
|
||||||
|
governance: null,
|
||||||
graph: {
|
graph: {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -153,7 +172,15 @@ export function draftFromDefinition(
|
|||||||
currentRevision: definition.current_revision,
|
currentRevision: definition.current_revision,
|
||||||
activeRevision: definition.active_revision,
|
activeRevision: definition.active_revision,
|
||||||
metadata: structuredClone(definition.metadata),
|
metadata: structuredClone(definition.metadata),
|
||||||
graph: structuredClone(definition.revision.graph)
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +191,14 @@ export function workflowPayload(
|
|||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
description: draft.description.trim() || null,
|
description: draft.description.trim() || null,
|
||||||
graph: draft.graph,
|
graph: draft.graph,
|
||||||
metadata: draft.metadata
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +210,14 @@ export function workflowFingerprint(
|
|||||||
name: draft.name,
|
name: draft.name,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
graph: draft.graph,
|
graph: draft.graph,
|
||||||
metadata: draft.metadata
|
metadata: draft.metadata,
|
||||||
|
scopeType: draft.scopeType,
|
||||||
|
scopeId: draft.scopeId,
|
||||||
|
definitionKind: draft.definitionKind,
|
||||||
|
inheritToLowerScopes: draft.inheritToLowerScopes,
|
||||||
|
allowStart: draft.allowStart,
|
||||||
|
allowReuse: draft.allowReuse,
|
||||||
|
allowAutomation: draft.allowAutomation
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,53 @@
|
|||||||
background: var(--bg);
|
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 *,
|
||||||
.workflow-page *::before,
|
.workflow-page *::before,
|
||||||
.workflow-page *::after {
|
.workflow-page *::after {
|
||||||
|
|||||||
Reference in New Issue
Block a user