feat: persist and edit versioned workflow definitions

This commit is contained in:
2026-07-28 13:48:06 +02:00
parent 85eef00913
commit 6737b60c11
25 changed files with 3913 additions and 9 deletions

View File

@@ -11,13 +11,19 @@ between modules without importing their implementations. It coordinates cases,
tasks, forms, files, templates, mail, appointments, payments, and records
through capabilities, events, commands, and DTOs.
The first executable slice exposes a workflow-specific node library and graph
validation API through `/workflow/node-types` and
`/workflow/definitions/validate`. It uses Core's domain-neutral definition graph
contract, but applies Workflow constraints: exactly one trigger, at least one
outcome, governed configuration fields, connected nodes, and permitted loops for
correction and retry paths. Dataflow uses the same graph contract with its own
acyclic transformation library.
Workflow exposes a workflow-specific node library, a full definition editor,
validation APIs, tenant-isolated definitions, and immutable graph revisions.
Activation pins the exact revision that future instances will execute. It uses
Core's domain-neutral definition graph contract, but applies Workflow
constraints: exactly one trigger, at least one outcome, governed configuration
fields, connected nodes, and permitted loops for correction and retry paths.
Dataflow uses the same graph contract with its own acyclic transformation
library.
The current slice deliberately stops before executing process instances.
Instance state, resumable transitions, human activities, retries, and event
subscriptions must build on the pinned definition and versioned module
capabilities rather than bypassing them.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.

View File

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

View File

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

View File

@@ -1,17 +1,30 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db import models as workflow_models
MODULE_ID = "workflow"
@@ -117,14 +130,68 @@ manifest = ModuleManifest(
optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
),
provides_interfaces=(
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"),
ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="dataflow.run_lifecycle",
version_min="0.1.14",
version_max_exclusive="1.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/workflow-webui",
nav_items=(
NavItem(
path="/workflow",
label="Workflow",
icon="workflow",
required_any=(DEFINITION_READ_SCOPE, ADMIN_SCOPE),
order=74,
),
),
),
route_factory=_router,
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
workflow_models.WorkflowDefinitionRevision,
workflow_models.WorkflowDefinition,
label="Workflow",
),
retirement_notes=(
"Destructive retirement drops Workflow definitions and immutable revisions "
"after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
workflow_models.WorkflowDefinition,
workflow_models.WorkflowDefinitionRevision,
label="Workflow",
),
),
documentation=(
DocumentationTopic(
id="workflow.definition-graphs",
@@ -135,8 +202,8 @@ manifest = ModuleManifest(
"and outcome node library on top of Core's domain-neutral graph contract. "
"Unlike Dataflow, Workflow permits cycles for correction and retry paths. "
"Module actions are addressed through versioned capabilities rather than "
"implementation imports. Definition persistence and execution build on this "
"validated contract in subsequent slices."
"implementation imports. Definitions are persisted as immutable graph "
"revisions; activation pins the exact revision used by future instances."
),
layer="available",
documentation_types=("admin", "user"),

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_workflow.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
@@ -11,6 +14,14 @@ from govoplan_workflow.backend.manifest import (
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.schemas import (
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeleteResponse,
WorkflowDefinitionListResponse,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionListResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowDiagnosticResponse,
WorkflowGraphValidationRequest,
WorkflowGraphValidationResponse,
@@ -18,6 +29,23 @@ from govoplan_workflow.backend.schemas import (
WorkflowNodeTypeResponse,
WorkflowPortResponse,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
WorkflowValidationError,
activate_definition,
archive_definition,
create_definition,
definition_response,
delete_definition,
get_definition,
get_definition_revision,
list_definition_revisions,
list_definitions,
revision_response,
update_definition,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -33,6 +61,58 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
)
def _http_error(exc: WorkflowError) -> HTTPException:
if isinstance(exc, WorkflowNotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, WorkflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, WorkflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"node_id": item.node_id,
"field": item.field,
}
for item in exc.diagnostics
],
},
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _actor_id(principal: ApiPrincipal) -> str | None:
return principal.account_id or principal.membership_id or principal.identity_id
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
definition_id: str,
details: dict[str, object],
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type="workflow_definition",
object_id=definition_id,
details=details,
)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -123,4 +203,262 @@ def api_validate_definition(
)
@router.get("/definitions", response_model=WorkflowDefinitionListResponse)
def api_list_definitions(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
return WorkflowDefinitionListResponse(
definitions=[
definition_response(session, definition)
for definition in list_definitions(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post(
"/definitions",
response_model=WorkflowDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_definition(
payload: WorkflowDefinitionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
definition = create_definition(
session,
tenant_id=principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.created",
definition_id=definition.id,
details={
"key": definition.definition_key,
"revision": definition.current_revision,
},
)
response = definition_response(session, definition)
session.commit()
return response
@router.get(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_get_definition(
definition_id: str,
revision: int | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
return definition_response(session, definition, revision=revision)
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.put(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_update_definition(
definition_id: str,
payload: WorkflowDefinitionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
definition = update_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
payload=payload,
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.updated",
definition_id=definition.id,
details={
"revision": definition.current_revision,
"status": definition.status,
},
)
response = definition_response(session, definition)
session.commit()
return response
@router.get(
"/definitions/{definition_id}/revisions",
response_model=WorkflowDefinitionRevisionListResponse,
)
def api_list_definition_revisions(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
revisions = list_definition_revisions(session, definition=definition)
except WorkflowError as exc:
raise _http_error(exc) from exc
return WorkflowDefinitionRevisionListResponse(
revisions=[revision_response(item) for item in revisions]
)
@router.get(
"/definitions/{definition_id}/revisions/{revision}",
response_model=WorkflowDefinitionRevisionResponse,
)
def api_get_definition_revision(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except WorkflowError as exc:
raise _http_error(exc) from exc
return revision_response(item)
@router.post(
"/definitions/{definition_id}/activate",
response_model=WorkflowDefinitionResponse,
)
def api_activate_definition(
definition_id: str,
payload: WorkflowDefinitionActivateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
definition = activate_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
revision=payload.revision,
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.activated",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = definition_response(session, definition)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/archive",
response_model=WorkflowDefinitionResponse,
)
def api_archive_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
definition = archive_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.archived",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = definition_response(session, definition)
session.commit()
return response
@router.delete(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionDeleteResponse,
)
def api_delete_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionDeleteResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
definition = delete_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.deleted",
definition_id=definition.id,
details={"revision": definition.current_revision},
)
session.commit()
return WorkflowDefinitionDeleteResponse(
deleted=True,
definition_id=definition.id,
)
__all__ = ["router"]

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
import math
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
class WorkflowPosition(BaseModel):
x: float = 0
y: float = 0
@@ -35,6 +39,7 @@ class WorkflowEdge(BaseModel):
class WorkflowGraph(BaseModel):
schema_version: Literal[1] = 1
nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150)
edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300)
@@ -91,3 +96,70 @@ class WorkflowNodeLibraryResponse(BaseModel):
version: str
allows_cycles: bool
nodes: list[WorkflowNodeTypeResponse]
class WorkflowDefinitionRevisionResponse(BaseModel):
id: str
revision: int
schema_version: int
graph: WorkflowGraph
content_hash: str
library_id: str
library_version: str
created_by: str | None
created_at: datetime
class WorkflowDefinitionResponse(BaseModel):
id: str
tenant_id: str
key: str
name: str
description: str | None
status: WorkflowDefinitionStatus
current_revision: int
active_revision: int | None
metadata: dict[str, Any]
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
revision: WorkflowDefinitionRevisionResponse
class WorkflowDefinitionListResponse(BaseModel):
definitions: list[WorkflowDefinitionResponse]
class WorkflowDefinitionRevisionListResponse(BaseModel):
revisions: list[WorkflowDefinitionRevisionResponse]
class WorkflowDefinitionCreateRequest(BaseModel):
key: str | None = Field(
default=None,
min_length=1,
max_length=120,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
)
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
metadata: dict[str, Any] = Field(default_factory=dict)
class WorkflowDefinitionUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4_000)
graph: WorkflowGraph
metadata: dict[str, Any] = Field(default_factory=dict)
expected_revision: int = Field(ge=1)
class WorkflowDefinitionActivateRequest(BaseModel):
revision: int | None = Field(default=None, ge=1)
class WorkflowDefinitionDeleteResponse(BaseModel):
deleted: bool
definition_id: str

View File

@@ -0,0 +1,399 @@
from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.db.base import utcnow
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowGraph,
)
from govoplan_workflow.backend.validation import validate_workflow_graph
class WorkflowError(RuntimeError):
pass
class WorkflowNotFoundError(WorkflowError):
pass
class WorkflowConflictError(WorkflowError):
pass
class WorkflowValidationError(WorkflowError):
def __init__(self, diagnostics: tuple[object, ...]) -> None:
first = diagnostics[0] if diagnostics else None
super().__init__(
str(getattr(first, "message", "Workflow definition validation failed."))
)
self.diagnostics = diagnostics
def list_definitions(
session: Session,
*,
tenant_id: str,
) -> list[WorkflowDefinition]:
return list(
session.scalars(
select(WorkflowDefinition)
.where(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.deleted_at.is_(None),
)
.order_by(
WorkflowDefinition.updated_at.desc(),
WorkflowDefinition.name.asc(),
)
)
)
def get_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
) -> WorkflowDefinition:
definition = session.scalar(
select(WorkflowDefinition).where(
WorkflowDefinition.id == definition_id,
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.deleted_at.is_(None),
)
)
if definition is None:
raise WorkflowNotFoundError("Workflow definition not found.")
return definition
def get_definition_revision(
session: Session,
*,
definition: WorkflowDefinition,
revision: int | None = None,
) -> WorkflowDefinitionRevision:
revision_number = revision or definition.current_revision
item = session.scalar(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id == definition.id,
WorkflowDefinitionRevision.tenant_id == definition.tenant_id,
WorkflowDefinitionRevision.revision == revision_number,
)
)
if item is None:
raise WorkflowNotFoundError("Workflow definition revision not found.")
return item
def list_definition_revisions(
session: Session,
*,
definition: WorkflowDefinition,
) -> list[WorkflowDefinitionRevision]:
return list(
session.scalars(
select(WorkflowDefinitionRevision)
.where(
WorkflowDefinitionRevision.definition_id == definition.id,
WorkflowDefinitionRevision.tenant_id == definition.tenant_id,
)
.order_by(WorkflowDefinitionRevision.revision.desc())
)
)
def create_definition(
session: Session,
*,
tenant_id: str,
actor_id: str | None,
payload: WorkflowDefinitionCreateRequest,
) -> WorkflowDefinition:
graph = _validated_graph(payload.graph)
definition = WorkflowDefinition(
tenant_id=tenant_id,
definition_key=_available_key(
session,
tenant_id=tenant_id,
requested=payload.key,
name=payload.name,
),
name=payload.name.strip(),
description=_clean_optional(payload.description),
status="draft",
current_revision=1,
active_revision=None,
metadata_=dict(payload.metadata),
created_by=actor_id,
updated_by=actor_id,
)
definition.revisions.append(
_new_revision(
tenant_id=tenant_id,
revision=1,
graph=graph,
actor_id=actor_id,
)
)
session.add(definition)
session.flush()
return definition
def update_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
payload: WorkflowDefinitionUpdateRequest,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
if payload.expected_revision != definition.current_revision:
raise WorkflowConflictError(
"Workflow definition changed on the server; "
f"expected revision {payload.expected_revision}, "
f"current revision is {definition.current_revision}."
)
graph = _validated_graph(payload.graph)
current = get_definition_revision(session, definition=definition)
graph_hash = _content_hash(graph)
definition.name = payload.name.strip()
definition.description = _clean_optional(payload.description)
definition.metadata_ = dict(payload.metadata)
definition.updated_by = actor_id
if current.content_hash != graph_hash:
definition.current_revision += 1
definition.revisions.append(
_new_revision(
tenant_id=tenant_id,
revision=definition.current_revision,
graph=graph,
actor_id=actor_id,
)
)
if definition.status == "active":
definition.status = "draft"
session.flush()
return definition
def activate_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
revision: int | None = None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
selected = get_definition_revision(
session,
definition=definition,
revision=revision,
)
_validated_graph(WorkflowGraph.model_validate(selected.graph))
definition.active_revision = selected.revision
definition.status = "active"
definition.updated_by = actor_id
session.flush()
return definition
def archive_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
definition.status = "archived"
definition.updated_by = actor_id
session.flush()
return definition
def delete_definition(
session: Session,
*,
tenant_id: str,
definition_id: str,
actor_id: str | None,
) -> WorkflowDefinition:
definition = get_definition(
session,
tenant_id=tenant_id,
definition_id=definition_id,
)
definition.deleted_at = utcnow()
definition.updated_by = actor_id
session.flush()
return definition
def definition_response(
session: Session,
definition: WorkflowDefinition,
*,
revision: int | None = None,
) -> WorkflowDefinitionResponse:
selected = get_definition_revision(
session,
definition=definition,
revision=revision,
)
return WorkflowDefinitionResponse(
id=definition.id,
tenant_id=definition.tenant_id,
key=definition.definition_key,
name=definition.name,
description=definition.description,
status=definition.status,
current_revision=definition.current_revision,
active_revision=definition.active_revision,
metadata=dict(definition.metadata_),
created_by=definition.created_by,
updated_by=definition.updated_by,
created_at=definition.created_at,
updated_at=definition.updated_at,
revision=revision_response(selected),
)
def revision_response(
revision: WorkflowDefinitionRevision,
) -> WorkflowDefinitionRevisionResponse:
return WorkflowDefinitionRevisionResponse(
id=revision.id,
revision=revision.revision,
schema_version=revision.schema_version,
graph=WorkflowGraph.model_validate(revision.graph),
content_hash=revision.content_hash,
library_id=revision.library_id,
library_version=revision.library_version,
created_by=revision.created_by,
created_at=revision.created_at,
)
def _new_revision(
*,
tenant_id: str,
revision: int,
graph: WorkflowGraph,
actor_id: str | None,
) -> WorkflowDefinitionRevision:
return WorkflowDefinitionRevision(
tenant_id=tenant_id,
revision=revision,
schema_version=graph.schema_version,
graph=_canonical_graph(graph),
content_hash=_content_hash(graph),
library_id=WORKFLOW_GRAPH_LIBRARY.id,
library_version=WORKFLOW_GRAPH_LIBRARY.version,
created_by=actor_id,
)
def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph:
diagnostics = validate_workflow_graph(graph)
if any(item.severity == "error" for item in diagnostics):
raise WorkflowValidationError(diagnostics)
return graph
def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]:
return graph.model_dump(mode="json")
def _content_hash(graph: WorkflowGraph) -> str:
encoded = json.dumps(
_canonical_graph(graph),
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _available_key(
session: Session,
*,
tenant_id: str,
requested: str | None,
name: str,
) -> str:
base = _slug(requested or name)
candidate = base
suffix = 2
while session.scalar(
select(WorkflowDefinition.id).where(
WorkflowDefinition.tenant_id == tenant_id,
WorkflowDefinition.definition_key == candidate,
)
):
candidate = f"{base[: max(1, 120 - len(str(suffix)) - 1)]}-{suffix}"
suffix += 1
return candidate
def _slug(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
ascii_value = normalized.encode("ascii", "ignore").decode("ascii").lower()
cleaned = re.sub(r"[^a-z0-9]+", "-", ascii_value).strip("-")
return (cleaned or "workflow")[:120]
def _clean_optional(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip()
return cleaned or None
__all__ = [
"WorkflowConflictError",
"WorkflowError",
"WorkflowNotFoundError",
"WorkflowValidationError",
"activate_definition",
"archive_definition",
"create_definition",
"definition_response",
"delete_definition",
"get_definition",
"get_definition_revision",
"list_definition_revisions",
"list_definitions",
"revision_response",
"update_definition",
]

View File

@@ -26,6 +26,17 @@ class WorkflowManifestTests(unittest.TestCase):
DEFINITION_WRITE_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertEqual(
"@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None,
)
self.assertIsNotNone(manifest.migration_spec)
requirement = next(
item
for item in manifest.requires_interfaces
if item.name == "dataflow.run_lifecycle"
)
self.assertTrue(requirement.optional)
if __name__ == "__main__":

48
tests/test_migrations.py Normal file
View File

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

225
tests/test_service.py Normal file
View File

@@ -0,0 +1,225 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
create_definition,
delete_definition,
get_definition,
list_definition_revisions,
list_definitions,
update_definition,
)
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
label="Start",
position=WorkflowPosition(x=40, y=100),
config={"input_schema_ref": ""},
),
WorkflowNode(
id="activity",
type="workflow.activity",
label="Review",
position=WorkflowPosition(x=280, y=100),
config={
"title": title,
"instructions": "",
"assignee": "",
"due_after": "",
},
),
WorkflowNode(
id="complete",
type="workflow.end.completed",
label="Completed",
position=WorkflowPosition(x=520, y=100),
config={"output_mapping": {}},
),
],
edges=[
WorkflowEdge(id="start-activity", source="start", target="activity"),
WorkflowEdge(id="activity-complete", source="activity", target="complete"),
],
)
class WorkflowServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
WorkflowDefinitionRevision.__table__,
WorkflowDefinition.__table__,
],
)
self.engine.dispose()
def _create(self, *, tenant_id: str = "tenant-1") -> WorkflowDefinition:
definition = create_definition(
self.session,
tenant_id=tenant_id,
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Monthly case handling",
graph=sample_graph(),
),
)
self.session.commit()
return definition
def test_create_update_and_activate_pin_immutable_revisions(self) -> None:
definition = self._create()
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Monthly case handling",
graph=sample_graph(title="Review corrected request"),
expected_revision=1,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
revision=1,
)
self.session.commit()
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual(1, updated.active_revision)
self.assertEqual("active", updated.status)
self.assertEqual([2, 1], [item.revision for item in revisions])
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
historical = next(item for item in revisions if item.revision == 1)
self.assertEqual(
"Review request",
historical.graph["nodes"][1]["config"]["title"],
)
def test_metadata_update_does_not_create_graph_revision(self) -> None:
definition = self._create()
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Renamed workflow",
description="Updated metadata only",
graph=sample_graph(),
metadata={"owner": "finance"},
expected_revision=1,
),
)
self.session.commit()
self.assertEqual(1, updated.current_revision)
self.assertEqual(
1,
len(
list(
self.session.scalars(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id
== definition.id
)
)
)
),
)
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
definition = self._create()
with self.assertRaises(WorkflowConflictError):
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Stale",
graph=sample_graph(),
expected_revision=2,
),
)
with self.assertRaises(WorkflowNotFoundError):
get_definition(
self.session,
tenant_id="tenant-2",
definition_id=definition.id,
)
def test_soft_delete_preserves_revisions_and_hides_definition(self) -> None:
definition = self._create()
delete_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
)
self.session.commit()
self.assertEqual([], list_definitions(self.session, tenant_id="tenant-1"))
self.assertEqual(
1,
len(
list(
self.session.scalars(
select(WorkflowDefinitionRevision).where(
WorkflowDefinitionRevision.definition_id
== definition.id
)
)
)
),
)
if __name__ == "__main__":
unittest.main()

32
webui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/workflow-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

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

@@ -0,0 +1,158 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import type {
DefinitionGraph,
DefinitionGraphNode,
DefinitionGraphNodeType
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
schema_version: 1;
nodes: WorkflowGraphNode[];
};
export type WorkflowDiagnostic = {
severity: "error" | "warning";
code: string;
message: string;
node_id?: string | null;
field?: string | null;
};
export type WorkflowNodeType = DefinitionGraphNodeType;
export type WorkflowRevision = {
id: string;
revision: number;
schema_version: number;
graph: WorkflowGraph;
content_hash: string;
library_id: string;
library_version: string;
created_by?: string | null;
created_at: string;
};
export type WorkflowDefinition = {
id: string;
tenant_id: string;
key: string;
name: string;
description?: string | null;
status: WorkflowStatus;
current_revision: number;
active_revision?: number | null;
metadata: Record<string, unknown>;
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
revision: WorkflowRevision;
};
export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
graph: WorkflowGraph;
metadata: Record<string, unknown>;
};
export async function listWorkflowNodeTypes(
settings: ApiSettings
): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> {
return apiFetch(settings, "/api/v1/workflow/node-types");
}
export async function listWorkflowDefinitions(
settings: ApiSettings
): Promise<WorkflowDefinition[]> {
const response = await apiFetch<{ definitions: WorkflowDefinition[] }>(
settings,
"/api/v1/workflow/definitions"
);
return response.definitions;
}
export function createWorkflowDefinition(
settings: ApiSettings,
payload: WorkflowDefinitionPayload
): Promise<WorkflowDefinition> {
return apiFetch(settings, "/api/v1/workflow/definitions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: WorkflowDefinitionPayload & { expected_revision: number }
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{
method: "PUT",
body: JSON.stringify(payload)
}
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowRevision[]> {
const response = await apiFetch<{ revisions: WorkflowRevision[] }>(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions`
);
return response.revisions;
}
export function activateWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
revision?: number
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/activate`,
{
method: "POST",
body: JSON.stringify({ revision })
}
);
}
export function archiveWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/archive`,
{ method: "POST" }
);
}
export function deleteWorkflowDefinition(
settings: ApiSettings,
definitionId: string
): Promise<{ deleted: boolean; definition_id: string }> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}`,
{ method: "DELETE" }
);
}
export function validateWorkflowDefinition(
settings: ApiSettings,
graph: WorkflowGraph
): Promise<{ valid: boolean; diagnostics: WorkflowDiagnostic[] }> {
return apiFetch(settings, "/api/v1/workflow/definitions/validate", {
method: "POST",
body: JSON.stringify({ graph })
});
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,703 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type DragEvent
} from "react";
import {
Archive,
CheckCircle2,
GitFork,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import {
Button,
ConfirmDialog,
DismissibleAlert,
IconButton,
LoadingFrame,
StatusBadge,
hasScope,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
activateWorkflowDefinition,
archiveWorkflowDefinition,
createWorkflowDefinition,
deleteWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
updateWorkflowDefinition,
validateWorkflowDefinition,
type WorkflowDefinition,
type WorkflowDiagnostic,
type WorkflowNodeType,
type WorkflowRevision
} from "../../api/workflow";
import WorkflowCanvas, {
updateWorkflowGraphNode
} from "./WorkflowCanvas";
import WorkflowInspector from "./WorkflowInspector";
import {
FALLBACK_WORKFLOW_LIBRARY,
draftFromDefinition,
sampleWorkflowDraft,
workflowFingerprint,
workflowPayload,
type WorkflowDraft
} from "./model";
const CATEGORY_ORDER = [
"trigger",
"activity",
"decision",
"wait",
"integration",
"outcome"
];
export default function WorkflowPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const { requestNavigation } = useUnsavedChanges();
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
const [savedDraft, setSavedDraft] = useState<WorkflowDraft | null>(null);
const [revisions, setRevisions] = useState<WorkflowRevision[]>([]);
const [historicalRevision, setHistoricalRevision] =
useState<WorkflowRevision | null>(null);
const [nodeLibrary, setNodeLibrary] = useState<WorkflowNodeType[]>(
FALLBACK_WORKFLOW_LIBRARY
);
const [allowsCycles, setAllowsCycles] = useState(true);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
const [deleteOpen, setDeleteOpen] = useState(false);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canWrite || historicalRevision !== null;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
);
const visibleDefinitions = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return definitions;
return definitions.filter((definition) =>
`${definition.name} ${definition.key} ${definition.description ?? ""} ${definition.status}`
.toLocaleLowerCase()
.includes(query)
);
}, [definitions, search]);
const paletteGroups = useMemo(
() => CATEGORY_ORDER.map((category) => ({
category,
label: nodeLibrary.find((item) => item.category === category)
?.category_label ?? category,
nodes: nodeLibrary.filter((item) => item.category === category)
})).filter((group) => group.nodes.length),
[nodeLibrary]
);
const applyDefinition = useCallback((definition: WorkflowDefinition) => {
const next = draftFromDefinition(definition);
setDraft(next);
setSavedDraft(structuredClone(next));
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
}, []);
const reload = useCallback(async (preferredId?: string | null) => {
setLoading(true);
setError("");
try {
const items = await listWorkflowDefinitions(settings);
setDefinitions(items);
const selected = items.find((item) => item.id === preferredId) ?? items[0];
if (selected) {
applyDefinition(selected);
} else {
setDraft(null);
setSavedDraft(null);
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(null);
}
} catch (loadError) {
setError(apiErrorMessage(loadError));
} finally {
setLoading(false);
}
}, [applyDefinition, settings]);
useEffect(() => {
void reload();
let cancelled = false;
void listWorkflowNodeTypes(settings)
.then((library) => {
if (cancelled) return;
if (library.nodes.length) setNodeLibrary(library.nodes);
setAllowsCycles(library.allows_cycles);
})
.catch(() => {
if (!cancelled) setNodeLibrary(FALLBACK_WORKFLOW_LIBRARY);
});
return () => {
cancelled = true;
};
}, [reload, settings]);
useEffect(() => {
if (!draft?.id) {
setRevisions([]);
return;
}
let cancelled = false;
void listWorkflowRevisions(settings, draft.id)
.then((items) => {
if (!cancelled) setRevisions(items);
})
.catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
});
return () => {
cancelled = true;
};
}, [draft?.id, draft?.currentRevision, settings]);
const discardDraft = useCallback(() => {
if (!savedDraft) {
setDraft(null);
return;
}
const next = structuredClone(savedDraft);
setDraft(next);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
setError("");
setSuccess("");
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canWrite || !draft.name.trim()) {
setError(
!draft?.name.trim()
? "Workflow name is required."
: "You cannot save this workflow."
);
return false;
}
setWorking(true);
setError("");
setSuccess("");
try {
const saved = draft.id && draft.currentRevision
? await updateWorkflowDefinition(settings, draft.id, {
...workflowPayload(draft),
expected_revision: draft.currentRevision
})
: await createWorkflowDefinition(settings, workflowPayload(draft));
applyDefinition(saved);
setDefinitions((current) => [
saved,
...current.filter((item) => item.id !== saved.id)
]);
setSuccess(`Saved revision ${saved.current_revision}.`);
return true;
} catch (saveError) {
setError(apiErrorMessage(saveError));
return false;
} finally {
setWorking(false);
}
}, [applyDefinition, canWrite, draft, settings]);
useUnsavedDraftGuard({
dirty,
title: "Unsaved workflow",
message: "Save or discard the workflow changes before leaving this workspace.",
onSave: saveDraft,
onDiscard: discardDraft
});
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty]);
const selectDefinition = (definition: WorkflowDefinition) => {
requestNavigation(() => {
applyDefinition(definition);
setError("");
setSuccess("");
});
};
const createNew = () => {
requestNavigation(() => {
const next = sampleWorkflowDraft();
setDraft(next);
setSavedDraft(null);
setRevisions([]);
setHistoricalRevision(null);
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
setError("");
setSuccess("");
});
};
const validate = async () => {
if (!displayedGraph) return;
setWorking(true);
setError("");
try {
const result = await validateWorkflowDefinition(settings, displayedGraph);
setDiagnostics(result.diagnostics);
setSuccess(result.valid ? "Workflow definition is valid." : "");
} catch (validationError) {
setError(apiErrorMessage(validationError));
} finally {
setWorking(false);
}
};
const activate = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
setError("");
try {
const saved = await activateWorkflowDefinition(
settings,
draft.id,
draft.currentRevision
);
applyDefinition(saved);
setDefinitions((current) => current.map((item) =>
item.id === saved.id ? saved : item
));
setSuccess(`Activated revision ${saved.active_revision}.`);
} catch (activationError) {
setError(apiErrorMessage(activationError));
} finally {
setWorking(false);
}
};
const archiveDefinition = async () => {
if (!draft?.id || dirty) return;
setWorking(true);
setError("");
try {
const saved = await archiveWorkflowDefinition(settings, draft.id);
applyDefinition(saved);
setDefinitions((current) => current.map((item) =>
item.id === saved.id ? saved : item
));
setSuccess("Workflow archived.");
} catch (archiveError) {
setError(apiErrorMessage(archiveError));
} finally {
setWorking(false);
}
};
const removeDefinition = async () => {
if (!draft?.id) return;
setWorking(true);
setError("");
try {
await deleteWorkflowDefinition(settings, draft.id);
setDeleteOpen(false);
await reload();
} catch (deleteError) {
setError(apiErrorMessage(deleteError));
} finally {
setWorking(false);
}
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (readOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
setDiagnostics([]);
setSuccess("");
};
const removeNode = (nodeId: string) => {
if (!draft || readOnly) return;
updateGraph({
...draft.graph,
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
edges: draft.graph.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
)
});
setSelectedNodeId(null);
};
return (
<main className="workflow-page">
<div className="workflow-shell">
<aside className="workflow-definition-panel">
<div className="workflow-panel-toolbar">
<strong>Workflows</strong>
<span className="workflow-toolbar-actions">
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
variant="ghost"
onClick={() => void reload(draft?.id)}
disabled={loading || working}
/>
<IconButton
label="New workflow"
icon={<Plus size={17} />}
variant="primary"
onClick={createNew}
disabled={!canWrite}
/>
</span>
</div>
<div className="workflow-definition-search">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows"
aria-label="Search workflows"
/>
</div>
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
<div className="workflow-definition-list">
{visibleDefinitions.map((definition) => (
<button
key={definition.id}
type="button"
className={definition.id === draft?.id ? "is-selected" : ""}
onClick={() => selectDefinition(definition)}
>
<span>
<strong>{definition.name}</strong>
<small>
{definition.key} · revision {definition.current_revision}
</small>
</span>
<StatusBadge
status={definition.status}
label={definition.status}
/>
</button>
))}
{!visibleDefinitions.length ? (
<div className="workflow-definition-empty">
No workflow definitions
</div>
) : null}
</div>
</LoadingFrame>
</aside>
<section className="workflow-workspace">
{draft && displayedGraph ? (
<>
<div className="workflow-workspace-toolbar">
<span className="workflow-identity-fields">
<input
className="workflow-name-input"
value={draft.name}
onChange={(event) => setDraft({
...draft,
name: event.target.value
})}
disabled={readOnly}
aria-label="Workflow name"
/>
<input
className="workflow-description-input"
value={draft.description}
onChange={(event) => setDraft({
...draft,
description: event.target.value
})}
disabled={readOnly}
placeholder="Description"
aria-label="Workflow description"
/>
</span>
<span className="workflow-command-bar">
{draft.id ? (
<select
className="workflow-revision-select"
value={
historicalRevision?.revision
?? draft.currentRevision
?? 1
}
onChange={(event) => {
const revision = Number(event.target.value);
const historical = revisions.find(
(item) => item.revision === revision
) ?? null;
setHistoricalRevision(
revision === draft.currentRevision
? null
: historical
);
setSelectedNodeId(
(historical?.graph ?? draft.graph).nodes[0]?.id ?? null
);
setDiagnostics([]);
}}
aria-label="Workflow revision"
>
{revisions.map((revision) => (
<option key={revision.id} value={revision.revision}>
Revision {revision.revision}
{revision.revision === draft.activeRevision
? " (active)"
: ""}
</option>
))}
</select>
) : null}
{historicalRevision ? (
<Button
onClick={() => {
setDraft({
...draft,
graph: structuredClone(historicalRevision.graph)
});
setHistoricalRevision(null);
setDiagnostics([]);
}}
disabled={!canWrite}
>
<RotateCcw size={16} /> Restore
</Button>
) : null}
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
{draft.id && draft.status !== "archived" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canWrite || dirty || working || readOnly}
>
<Archive size={16} /> Archive
</Button>
) : null}
{draft.id && draft.status !== "active" ? (
<Button
variant="primary"
onClick={() => void activate()}
disabled={!canWrite || dirty || working || readOnly}
>
<CheckCircle2 size={16} /> Activate
</Button>
) : null}
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canWrite || !dirty || working || readOnly}
>
<Save size={16} /> Save
</Button>
{draft.id ? (
<IconButton
label="Delete workflow"
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canWrite || working}
/>
) : null}
</span>
</div>
<div className="workflow-alerts">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
{success ? (
<DismissibleAlert tone="success" resetKey={success}>
{success}
</DismissibleAlert>
) : null}
</div>
<div className="workflow-editor">
<aside className="workflow-palette">
<div className="workflow-panel-heading">
<span>
<strong>Node library</strong>
<small>Drag nodes onto the canvas</small>
</span>
</div>
<div className="workflow-palette-items">
{paletteGroups.map((group) => (
<section key={group.category} className="workflow-palette-group">
<h3>{group.label}</h3>
{group.nodes.map((nodeType) => (
<button
key={nodeType.type}
type="button"
draggable={!readOnly}
disabled={readOnly}
onDragStart={(event) =>
startPaletteDrag(event, nodeType.type)
}
title={nodeType.description}
>
<GitFork size={16} />
<span>{nodeType.label}</span>
<Plus size={13} className="workflow-palette-add" />
</button>
))}
</section>
))}
</div>
</aside>
<div className="workflow-editor-surface">
<ReactFlowProvider>
<WorkflowCanvas
graph={displayedGraph}
diagnostics={diagnostics}
nodeLibrary={nodeLibrary}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowsCycles={allowsCycles}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
/>
</ReactFlowProvider>
</div>
<div className="workflow-inspector-column">
<WorkflowInspector
node={selectedNode}
nodeLibrary={nodeLibrary}
readOnly={readOnly}
onChange={(node) => {
if (!draft || readOnly) return;
updateGraph(updateWorkflowGraphNode(draft.graph, node));
}}
onDelete={removeNode}
/>
{diagnostics.length ? (
<div className="workflow-diagnostics">
<div className="workflow-panel-heading">
<strong>Diagnostics</strong>
<StatusBadge
status={
diagnostics.some((item) => item.severity === "error")
? "error"
: "warning"
}
label={String(diagnostics.length)}
/>
</div>
<div>
{diagnostics.map((item, index) => (
<button
key={`${item.code}-${item.node_id ?? "graph"}-${index}`}
type="button"
onClick={() => {
if (item.node_id) setSelectedNodeId(item.node_id);
}}
>
<strong>{item.message}</strong>
<small>{item.code}</small>
</button>
))}
</div>
</div>
) : null}
</div>
</div>
{working ? (
<div className="workflow-working-indicator" role="status">
Working...
</div>
) : null}
</>
) : (
<div className="workflow-workspace-empty">
<GitFork size={34} />
<strong>No workflow selected</strong>
{canWrite ? (
<Button variant="primary" onClick={createNew}>
<Plus size={16} /> New workflow
</Button>
) : null}
</div>
)}
</section>
</div>
<ConfirmDialog
open={deleteOpen}
title="Delete workflow"
message={`Delete ${draft?.name ?? "this workflow"} and all of its definition revisions?`}
confirmLabel="Delete"
tone="danger"
busy={working}
onCancel={() => setDeleteOpen(false)}
onConfirm={() => void removeDefinition()}
/>
</main>
);
}
function startPaletteDrag(
event: DragEvent<HTMLButtonElement>,
type: string
) {
event.dataTransfer.setData("application/x-govoplan-workflow-node", type);
event.dataTransfer.effectAllowed = "copy";
}
function apiErrorMessage(error: unknown): string {
if (!isApiError(error)) {
return error instanceof Error ? error.message : "The request failed.";
}
try {
const parsed = JSON.parse(error.body) as {
detail?: string | { message?: string };
};
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail?.message) return parsed.detail.message;
} catch {
// Fall through to the API error message.
}
return error.message;
}

View File

@@ -0,0 +1,193 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType,
WorkflowStatus
} from "../../api/workflow";
export type WorkflowDraft = {
id?: string;
name: string;
description: string;
status: WorkflowStatus;
currentRevision?: number;
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
};
const inputPort = [{
id: "input",
label: "Input",
required: true,
multiple: false,
minimum_connections: 1
}];
const outputPort = [{
id: "output",
label: "Output",
required: true,
multiple: false,
minimum_connections: 1
}];
export const FALLBACK_WORKFLOW_LIBRARY: WorkflowNodeType[] = [
{
type: "workflow.start.manual",
category: "trigger",
category_label: "Start",
label: "Manual start",
description: "Start through an explicit action.",
icon: "circle-play",
input_ports: [],
output_ports: outputPort,
config_fields: [],
default_config: { input_schema_ref: "" }
},
{
type: "workflow.activity",
category: "activity",
category_label: "Activities",
label: "Activity",
description: "A governed unit of work.",
icon: "square-check-big",
input_ports: inputPort,
output_ports: outputPort,
config_fields: [
{
id: "title",
label: "Title",
kind: "text",
required: true,
options: []
}
],
default_config: {
title: "",
instructions: "",
assignee: "",
due_after: ""
}
},
{
type: "workflow.end.completed",
category: "outcome",
category_label: "Outcomes",
label: "Completed",
description: "Complete successfully.",
icon: "circle-check-big",
input_ports: [{ ...inputPort[0], multiple: true }],
output_ports: [],
config_fields: [],
default_config: { output_mapping: {} }
}
];
export function sampleWorkflowDraft(): WorkflowDraft {
return {
name: "New workflow",
description: "",
status: "draft",
metadata: {},
graph: {
schema_version: 1,
nodes: [
{
id: "start",
type: "workflow.start.manual",
label: "Start",
position: { x: 60, y: 150 },
config: { input_schema_ref: "" }
},
{
id: "activity",
type: "workflow.activity",
label: "Activity",
position: { x: 320, y: 150 },
config: {
title: "Complete activity",
instructions: "",
assignee: "",
due_after: ""
}
},
{
id: "complete",
type: "workflow.end.completed",
label: "Completed",
position: { x: 580, y: 150 },
config: { output_mapping: {} }
}
],
edges: [
{
id: "start-activity",
source: "start",
target: "activity",
source_port: "output",
target_port: "input"
},
{
id: "activity-complete",
source: "activity",
target: "complete",
source_port: "output",
target_port: "input"
}
]
}
};
}
export function draftFromDefinition(
definition: WorkflowDefinition
): WorkflowDraft {
return {
id: definition.id,
name: definition.name,
description: definition.description ?? "",
status: definition.status,
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph)
};
}
export function workflowPayload(
draft: WorkflowDraft
): WorkflowDefinitionPayload {
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata
};
}
export function workflowFingerprint(
draft: WorkflowDraft | null
): string {
if (!draft) return "";
return JSON.stringify({
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata
});
}
export function newWorkflowNode(
type: string,
position: { x: number; y: number },
library: WorkflowNodeType[]
): WorkflowGraphNode {
return createDefinitionGraphNode<WorkflowGraphNode>(
type,
position,
library
);
}

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

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

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

@@ -0,0 +1,45 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/workflow.css";
import "@xyflow/react/dist/style.css";
const WorkflowPage = lazy(() => import("./features/workflow/WorkflowPage"));
const readScopes = [
"workflow:definition:read",
"workflow:instance:admin"
];
export const workflowModule: PlatformWebModule = {
id: "workflow",
label: "Workflow",
version: "0.1.14",
optionalDependencies: [
"access",
"audit",
"dataflow",
"datasources",
"notifications",
"policy",
"tasks"
],
navItems: [
{
to: "/workflow",
label: "Workflow",
iconName: "workflow",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/workflow",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(WorkflowPage, { settings, auth })
}
]
};
export default workflowModule;

View File

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

16
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_CSRF_COOKIE_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModules: PlatformWebModule[];
export { installedWebModules };
export default installedWebModules;
}

33
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}