feat: persist and edit versioned workflow definitions
This commit is contained in:
20
README.md
20
README.md
@@ -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.
|
||||
|
||||
|
||||
9
src/govoplan_workflow/backend/db/__init__.py
Normal file
9
src/govoplan_workflow/backend/db/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from govoplan_workflow.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WorkflowDefinition",
|
||||
"WorkflowDefinitionRevision",
|
||||
]
|
||||
126
src/govoplan_workflow/backend/db/models.py
Normal file
126
src/govoplan_workflow/backend/db/models.py
Normal 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",
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
1
src/govoplan_workflow/backend/migrations/__init__.py
Normal file
1
src/govoplan_workflow/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Workflow database migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Workflow Alembic revisions."""
|
||||
@@ -0,0 +1,201 @@
|
||||
"""v0.1.14 Workflow definitions
|
||||
|
||||
Revision ID: a7c4e2f9b1d3
|
||||
Revises: None
|
||||
Create Date: 2026-07-28 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a7c4e2f9b1d3"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_definitions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("name", sa.String(length=300), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("active_revision", sa.Integer(), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_workflow_definitions")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_key",
|
||||
name="uq_workflow_definition_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_created_by"),
|
||||
"workflow_definitions",
|
||||
["created_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_deleted_at"),
|
||||
"workflow_definitions",
|
||||
["deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_status"),
|
||||
"workflow_definitions",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_tenant_id"),
|
||||
"workflow_definitions",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definitions_updated_by"),
|
||||
"workflow_definitions",
|
||||
["updated_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_tenant_status",
|
||||
"workflow_definitions",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_tenant_updated",
|
||||
"workflow_definitions",
|
||||
["tenant_id", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_definition_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||
sa.Column("graph", sa.JSON(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("library_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("library_version", sa.String(length=40), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["definition_id"],
|
||||
["workflow_definitions.id"],
|
||||
name=op.f(
|
||||
"fk_workflow_definition_revisions_definition_id_workflow_definitions"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_workflow_definition_revisions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"definition_id",
|
||||
"revision",
|
||||
name="uq_workflow_definition_revision",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_created_by"),
|
||||
"workflow_definition_revisions",
|
||||
["created_by"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_definition_id"),
|
||||
"workflow_definition_revisions",
|
||||
["definition_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_definition_revisions_tenant_id"),
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definition_revisions_content_hash",
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id", "content_hash"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definition_revisions_tenant_definition",
|
||||
"workflow_definition_revisions",
|
||||
["tenant_id", "definition_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_workflow_definition_revisions_tenant_definition",
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_definition_revisions_content_hash",
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_tenant_id"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_definition_id"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definition_revisions_created_by"),
|
||||
table_name="workflow_definition_revisions",
|
||||
)
|
||||
op.drop_table("workflow_definition_revisions")
|
||||
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_tenant_updated",
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_tenant_status",
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_updated_by"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_tenant_id"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_status"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_deleted_at"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_definitions_created_by"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.drop_table("workflow_definitions")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
399
src/govoplan_workflow/backend/service.py
Normal file
399
src/govoplan_workflow/backend/service.py
Normal 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",
|
||||
]
|
||||
@@ -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
48
tests/test_migrations.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_workflow.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class WorkflowMigrationTests(unittest.TestCase):
|
||||
def test_migration_creates_definition_tables_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-workflow-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'workflow.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("workflow",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"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
225
tests/test_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow.backend.schemas import (
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowPosition,
|
||||
)
|
||||
from govoplan_workflow.backend.service import (
|
||||
WorkflowConflictError,
|
||||
WorkflowNotFoundError,
|
||||
activate_definition,
|
||||
create_definition,
|
||||
delete_definition,
|
||||
get_definition,
|
||||
list_definition_revisions,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
|
||||
|
||||
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
label="Start",
|
||||
position=WorkflowPosition(x=40, y=100),
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="activity",
|
||||
type="workflow.activity",
|
||||
label="Review",
|
||||
position=WorkflowPosition(x=280, y=100),
|
||||
config={
|
||||
"title": title,
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
label="Completed",
|
||||
position=WorkflowPosition(x=520, y=100),
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="start-activity", source="start", target="activity"),
|
||||
WorkflowEdge(id="activity-complete", source="activity", target="complete"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class WorkflowServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def _create(self, *, tenant_id: str = "tenant-1") -> WorkflowDefinition:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id=tenant_id,
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Monthly case handling",
|
||||
graph=sample_graph(),
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
return definition
|
||||
|
||||
def test_create_update_and_activate_pin_immutable_revisions(self) -> None:
|
||||
definition = self._create()
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Monthly case handling",
|
||||
graph=sample_graph(title="Review corrected request"),
|
||||
expected_revision=1,
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
revision=1,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
revisions = list_definition_revisions(
|
||||
self.session,
|
||||
definition=updated,
|
||||
)
|
||||
self.assertEqual(2, updated.current_revision)
|
||||
self.assertEqual(1, updated.active_revision)
|
||||
self.assertEqual("active", updated.status)
|
||||
self.assertEqual([2, 1], [item.revision for item in revisions])
|
||||
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
|
||||
historical = next(item for item in revisions if item.revision == 1)
|
||||
self.assertEqual(
|
||||
"Review request",
|
||||
historical.graph["nodes"][1]["config"]["title"],
|
||||
)
|
||||
|
||||
def test_metadata_update_does_not_create_graph_revision(self) -> None:
|
||||
definition = self._create()
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Renamed workflow",
|
||||
description="Updated metadata only",
|
||||
graph=sample_graph(),
|
||||
metadata={"owner": "finance"},
|
||||
expected_revision=1,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(1, updated.current_revision)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
list(
|
||||
self.session.scalars(
|
||||
select(WorkflowDefinitionRevision).where(
|
||||
WorkflowDefinitionRevision.definition_id
|
||||
== definition.id
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
|
||||
definition = self._create()
|
||||
with self.assertRaises(WorkflowConflictError):
|
||||
update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Stale",
|
||||
graph=sample_graph(),
|
||||
expected_revision=2,
|
||||
),
|
||||
)
|
||||
with self.assertRaises(WorkflowNotFoundError):
|
||||
get_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
definition_id=definition.id,
|
||||
)
|
||||
|
||||
def test_soft_delete_preserves_revisions_and_hides_definition(self) -> None:
|
||||
definition = self._create()
|
||||
delete_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual([], list_definitions(self.session, tenant_id="tenant-1"))
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
list(
|
||||
self.session.scalars(
|
||||
select(WorkflowDefinitionRevision).where(
|
||||
WorkflowDefinitionRevision.definition_id
|
||||
== definition.id
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
webui/package.json
Normal file
32
webui/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
158
webui/src/api/workflow.ts
Normal file
158
webui/src/api/workflow.ts
Normal 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 })
|
||||
});
|
||||
}
|
||||
229
webui/src/features/workflow/WorkflowCanvas.tsx
Normal file
229
webui/src/features/workflow/WorkflowCanvas.tsx
Normal 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
|
||||
)
|
||||
};
|
||||
}
|
||||
185
webui/src/features/workflow/WorkflowInspector.tsx
Normal file
185
webui/src/features/workflow/WorkflowInspector.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
FormField
|
||||
} from "@govoplan/core-webui";
|
||||
import type {
|
||||
WorkflowGraphNode,
|
||||
WorkflowNodeType
|
||||
} from "../../api/workflow";
|
||||
|
||||
export default function WorkflowInspector({
|
||||
node,
|
||||
nodeLibrary,
|
||||
readOnly,
|
||||
onChange,
|
||||
onDelete
|
||||
}: {
|
||||
node: WorkflowGraphNode | null;
|
||||
nodeLibrary: WorkflowNodeType[];
|
||||
readOnly: boolean;
|
||||
onChange: (node: WorkflowGraphNode) => void;
|
||||
onDelete: (nodeId: string) => void;
|
||||
}) {
|
||||
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
setJsonDrafts({});
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
const definition = nodeLibrary.find((item) => item.type === node.type);
|
||||
setJsonDrafts(Object.fromEntries(
|
||||
(definition?.config_fields ?? [])
|
||||
.filter((field) => field.kind === "mapping")
|
||||
.map((field) => [
|
||||
field.id,
|
||||
JSON.stringify(node.config[field.id] ?? {}, null, 2)
|
||||
])
|
||||
));
|
||||
setError("");
|
||||
}, [node?.id, nodeLibrary]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
|
||||
<div className="workflow-inspector-empty">No node selected</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const definition = nodeLibrary.find((item) => item.type === node.type);
|
||||
const updateConfig = (field: string, value: unknown) => {
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, [field]: value }
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Inspector</strong>
|
||||
<small>{definition?.label ?? node.type}</small>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="workflow-inspector-delete"
|
||||
onClick={() => onDelete(node.id)}
|
||||
disabled={readOnly}
|
||||
aria-label="Delete node"
|
||||
title="Delete node"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="workflow-inspector-fields">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<FormField label="Name">
|
||||
<input
|
||||
value={node.label}
|
||||
onChange={(event) =>
|
||||
onChange({ ...node, label: event.target.value })
|
||||
}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{(definition?.config_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
label={`${field.label}${field.required ? " *" : ""}`}
|
||||
help={field.description ?? undefined}
|
||||
>
|
||||
{field.kind === "select" ? (
|
||||
<select
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Choose</option>
|
||||
{field.options.map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "textarea" || field.kind === "expression" ? (
|
||||
<textarea
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : field.kind === "mapping" ? (
|
||||
<textarea
|
||||
className="workflow-json-editor"
|
||||
value={jsonDrafts[field.id] ?? "{}"}
|
||||
onChange={(event) => setJsonDrafts((current) => ({
|
||||
...current,
|
||||
[field.id]: event.target.value
|
||||
}))}
|
||||
onBlur={() => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
jsonDrafts[field.id] ?? "{}"
|
||||
);
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error(`${field.label} must be a JSON object.`);
|
||||
}
|
||||
setError("");
|
||||
updateConfig(field.id, parsed);
|
||||
} catch (parseError) {
|
||||
setError(
|
||||
parseError instanceof Error
|
||||
? parseError.message
|
||||
: `${field.label} is invalid.`
|
||||
);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : field.kind === "string_list" ? (
|
||||
<input
|
||||
value={stringList(node.config[field.id]).join(", ")}
|
||||
onChange={(event) => updateConfig(
|
||||
field.id,
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={textValue(node.config[field.id])}
|
||||
onChange={(event) => updateConfig(field.id, event.target.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
99
webui/src/features/workflow/WorkflowNode.tsx
Normal file
99
webui/src/features/workflow/WorkflowNode.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
CirclePlay,
|
||||
CircleX,
|
||||
ClipboardCheck,
|
||||
GitFork,
|
||||
PlugZap,
|
||||
Radio,
|
||||
Split,
|
||||
SquareCheckBig,
|
||||
Timer,
|
||||
Waypoints,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type NodeProps
|
||||
} from "@xyflow/react";
|
||||
import type { WorkflowNodeType } from "../../api/workflow";
|
||||
|
||||
export type WorkflowFlowNodeData = {
|
||||
label: string;
|
||||
workflowType: string;
|
||||
definition: WorkflowNodeType;
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowFlowNode = Node<WorkflowFlowNodeData, "workflow">;
|
||||
|
||||
const iconByName: Record<string, LucideIcon> = {
|
||||
"calendar-clock": CalendarClock,
|
||||
"circle-check-big": CheckCircle2,
|
||||
"circle-play": CirclePlay,
|
||||
"circle-x": CircleX,
|
||||
"clipboard-check": ClipboardCheck,
|
||||
"plug-zap": PlugZap,
|
||||
radio: Radio,
|
||||
split: Split,
|
||||
"square-check-big": SquareCheckBig,
|
||||
timer: Timer,
|
||||
waypoints: Waypoints
|
||||
};
|
||||
|
||||
export default function WorkflowNode({
|
||||
data,
|
||||
selected,
|
||||
isConnectable
|
||||
}: NodeProps<WorkflowFlowNode>) {
|
||||
const Icon = iconByName[data.definition.icon] ?? GitFork;
|
||||
const inputPorts = data.definition.input_ports;
|
||||
const outputPorts = data.definition.output_ports;
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"workflow-node",
|
||||
`workflow-node-${data.definition.category}`,
|
||||
selected ? "is-selected" : "",
|
||||
data.hasError ? "has-error" : ""
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{inputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="workflow-node-handle"
|
||||
style={{ top: portPosition(index, inputPorts.length) }}
|
||||
isConnectable={isConnectable}
|
||||
title={port.label}
|
||||
/>
|
||||
))}
|
||||
<span className="workflow-node-icon"><Icon size={17} /></span>
|
||||
<span className="workflow-node-copy">
|
||||
<strong>{data.label}</strong>
|
||||
<small>{data.definition.label}</small>
|
||||
</span>
|
||||
{outputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="workflow-node-handle"
|
||||
style={{ top: portPosition(index, outputPorts.length) }}
|
||||
isConnectable={isConnectable}
|
||||
title={port.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function portPosition(index: number, count: number): string {
|
||||
return `${((index + 1) / (count + 1)) * 100}%`;
|
||||
}
|
||||
703
webui/src/features/workflow/WorkflowPage.tsx
Normal file
703
webui/src/features/workflow/WorkflowPage.tsx
Normal 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;
|
||||
}
|
||||
193
webui/src/features/workflow/model.ts
Normal file
193
webui/src/features/workflow/model.ts
Normal 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
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { workflowModule as default, workflowModule } from "./module";
|
||||
export * from "./api/workflow";
|
||||
45
webui/src/module.ts
Normal file
45
webui/src/module.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./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;
|
||||
705
webui/src/styles/workflow.css
Normal file
705
webui/src/styles/workflow.css
Normal 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
16
webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_CSRF_COOKIE_NAME?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "virtual:govoplan-installed-modules" {
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
const installedWebModules: PlatformWebModule[];
|
||||
export { installedWebModules };
|
||||
export default installedWebModules;
|
||||
}
|
||||
33
webui/tsconfig.json
Normal file
33
webui/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"preserveSymlinks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
|
||||
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
|
||||
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user