feat(workflow): orchestrate resumable dataflow handoffs
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
from govoplan_workflow.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WorkflowDefinition",
|
||||
"WorkflowDefinitionRevision",
|
||||
"WorkflowInstance",
|
||||
"WorkflowInstanceEvent",
|
||||
"WorkflowInstanceStep",
|
||||
]
|
||||
|
||||
@@ -140,6 +140,11 @@ class WorkflowDefinition(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowDefinitionRevision.revision",
|
||||
)
|
||||
instances: Mapped[list["WorkflowInstance"]] = relationship(
|
||||
back_populates="definition",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstance.created_at",
|
||||
)
|
||||
|
||||
|
||||
class WorkflowDefinitionRevision(Base, TimestampMixin):
|
||||
@@ -188,8 +193,244 @@ class WorkflowDefinitionRevision(Base, TimestampMixin):
|
||||
definition: Mapped[WorkflowDefinition] = relationship(back_populates="revisions")
|
||||
|
||||
|
||||
class WorkflowInstance(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_instances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"idempotency_key",
|
||||
name="uq_workflow_instance_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
"status",
|
||||
"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_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
definition_revision_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definition_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="running",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
correlation_id: Mapped[str | None] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
current_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
input_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"input",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
context_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"context",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
output_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"output",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
authorization_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"authorization",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
cancellation_requested_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
definition: Mapped[WorkflowDefinition] = relationship(
|
||||
back_populates="instances"
|
||||
)
|
||||
steps: Mapped[list["WorkflowInstanceStep"]] = relationship(
|
||||
back_populates="instance",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstanceStep.sequence",
|
||||
)
|
||||
events: Mapped[list["WorkflowInstanceEvent"]] = relationship(
|
||||
back_populates="instance",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WorkflowInstanceEvent.sequence",
|
||||
)
|
||||
|
||||
|
||||
class WorkflowInstanceStep(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_instance_steps"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_step_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
instance_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
node_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
attempt: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
input_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"input",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
output_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"output",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
handoff: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
external_ref: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
instance: Mapped[WorkflowInstance] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
class WorkflowInstanceEvent(Base):
|
||||
__tablename__ = "workflow_instance_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_event_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
"tenant_id",
|
||||
"created_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)
|
||||
instance_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
step_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
instance: Mapped[WorkflowInstance] = relationship(back_populates="events")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkflowDefinition",
|
||||
"WorkflowDefinitionRevision",
|
||||
"WorkflowInstance",
|
||||
"WorkflowInstanceEvent",
|
||||
"WorkflowInstanceStep",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.policy import (
|
||||
PolicySourceStep,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_core.core.workflows import workflow_runtime_worker
|
||||
from govoplan_workflow.backend.db.models import WorkflowDefinition
|
||||
|
||||
|
||||
@@ -127,6 +128,7 @@ def definition_governance_payload(
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
) -> dict[str, object]:
|
||||
runtime_available = workflow_runtime_worker(registry) is not None
|
||||
actions = {
|
||||
action: definition_decision(
|
||||
definition,
|
||||
@@ -151,10 +153,11 @@ def definition_governance_payload(
|
||||
"derived_from_hash": definition.derived_from_hash,
|
||||
"derivation_provenance": dict(definition.derivation_provenance),
|
||||
"actions": actions,
|
||||
"automation_runtime_available": False,
|
||||
"automation_runtime_available": runtime_available,
|
||||
"automation_runtime_reason": (
|
||||
"Workflow start definitions are persisted and governed, but "
|
||||
"automatic instance dispatch requires the Workflow runtime."
|
||||
None
|
||||
if runtime_available
|
||||
else "Automatic reconciliation requires the Workflow runtime worker."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
@@ -28,8 +29,14 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.notifications import (
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
)
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow.backend.db import models as workflow_models
|
||||
|
||||
@@ -123,6 +130,14 @@ def _router(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _runtime_worker(context: ModuleContext):
|
||||
from govoplan_workflow.backend.instance_service import (
|
||||
SqlWorkflowRuntimeWorker,
|
||||
)
|
||||
|
||||
return SqlWorkflowRuntimeWorker(registry=context.registry)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
@@ -141,9 +156,11 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
CAPABILITY_VIEWS_RESOLVER,
|
||||
),
|
||||
@@ -151,6 +168,7 @@ manifest = ModuleManifest(
|
||||
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"),
|
||||
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -165,6 +183,18 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="auth.automation_principal",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="policy.definition_governance",
|
||||
version_min="0.1.0",
|
||||
@@ -211,12 +241,18 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
capability_factories={
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker,
|
||||
},
|
||||
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.WorkflowInstanceEvent,
|
||||
workflow_models.WorkflowInstanceStep,
|
||||
workflow_models.WorkflowInstance,
|
||||
workflow_models.WorkflowDefinitionRevision,
|
||||
workflow_models.WorkflowDefinition,
|
||||
label="Workflow",
|
||||
@@ -230,6 +266,9 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
workflow_models.WorkflowDefinition,
|
||||
workflow_models.WorkflowDefinitionRevision,
|
||||
workflow_models.WorkflowInstance,
|
||||
workflow_models.WorkflowInstanceStep,
|
||||
workflow_models.WorkflowInstanceEvent,
|
||||
label="Workflow",
|
||||
),
|
||||
),
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
"""v0.1.14 Workflow instances and resumable handoffs
|
||||
|
||||
Revision ID: d8f2a5c7e1b4
|
||||
Revises: c6d8f1a3e5b7
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f2a5c7e1b4"
|
||||
down_revision = "c6d8f1a3e5b7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_instances",
|
||||
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(
|
||||
"definition_revision_id",
|
||||
sa.String(length=36),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("correlation_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("current_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("input", sa.JSON(), nullable=False),
|
||||
sa.Column("context", sa.JSON(), nullable=False),
|
||||
sa.Column("output", sa.JSON(), nullable=False),
|
||||
sa.Column("authorization", sa.JSON(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"cancellation_requested_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
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"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["definition_revision_id"],
|
||||
["workflow_definition_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"idempotency_key",
|
||||
name="uq_workflow_instance_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"definition_id",
|
||||
"definition_revision_id",
|
||||
"status",
|
||||
"idempotency_key",
|
||||
"correlation_id",
|
||||
"current_step_id",
|
||||
"created_by",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instances_{column}"),
|
||||
"workflow_instances",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
"workflow_instances",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
"workflow_instances",
|
||||
["status", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_instance_steps",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("instance_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("node_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("input", sa.JSON(), nullable=False),
|
||||
sa.Column("output", sa.JSON(), nullable=False),
|
||||
sa.Column("handoff", sa.JSON(), nullable=False),
|
||||
sa.Column("external_ref", sa.String(length=500), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("completed_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(
|
||||
["instance_id"],
|
||||
["workflow_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_step_sequence",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"instance_id",
|
||||
"node_id",
|
||||
"node_type",
|
||||
"status",
|
||||
"external_ref",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instance_steps_{column}"),
|
||||
"workflow_instance_steps",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
"workflow_instance_steps",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workflow_instance_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("instance_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=120), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["instance_id"],
|
||||
["workflow_instances.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"instance_id",
|
||||
"sequence",
|
||||
name="uq_workflow_instance_event_sequence",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"instance_id",
|
||||
"step_id",
|
||||
"kind",
|
||||
"actor_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_instance_events_{column}"),
|
||||
"workflow_instance_events",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
"workflow_instance_events",
|
||||
["tenant_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_workflow_instance_events_tenant_created",
|
||||
table_name="workflow_instance_events",
|
||||
)
|
||||
op.drop_table("workflow_instance_events")
|
||||
op.drop_index(
|
||||
"ix_workflow_instance_steps_tenant_status",
|
||||
table_name="workflow_instance_steps",
|
||||
)
|
||||
op.drop_table("workflow_instance_steps")
|
||||
op.drop_index(
|
||||
"ix_workflow_instances_reconcile",
|
||||
table_name="workflow_instances",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_workflow_instances_tenant_status",
|
||||
table_name="workflow_instances",
|
||||
)
|
||||
op.drop_table("workflow_instances")
|
||||
@@ -243,6 +243,12 @@ WORKFLOW_NODE_TYPES = (
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="success", label="Success", required=False),
|
||||
DefinitionPort(id="warning", label="Warning", required=False),
|
||||
DefinitionPort(
|
||||
id="review_required",
|
||||
label="Review required",
|
||||
required=False,
|
||||
),
|
||||
DefinitionPort(id="failure", label="Failure", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
@@ -294,6 +300,12 @@ WORKFLOW_NODE_TYPES = (
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(
|
||||
DefinitionPort(id="success", label="Success", required=False),
|
||||
DefinitionPort(id="warning", label="Warning", required=False),
|
||||
DefinitionPort(
|
||||
id="review_required",
|
||||
label="Review required",
|
||||
required=False,
|
||||
),
|
||||
DefinitionPort(id="failure", label="Failure", required=False),
|
||||
),
|
||||
config_fields=(
|
||||
@@ -303,9 +315,59 @@ WORKFLOW_NODE_TYPES = (
|
||||
kind="dataflow",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="revision",
|
||||
label="Pinned revision",
|
||||
kind="number",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="environment",
|
||||
label="Environment",
|
||||
kind="select",
|
||||
required=True,
|
||||
options=(
|
||||
("development", "Development"),
|
||||
("staging", "Staging"),
|
||||
("production", "Production"),
|
||||
),
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="row_limit",
|
||||
label="Output row limit",
|
||||
kind="number",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="publication_target_ref",
|
||||
label="Publication datasource",
|
||||
kind="text",
|
||||
description=(
|
||||
"Optional stable Datasource target for materialized "
|
||||
"output."
|
||||
),
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="warning_policy",
|
||||
label="Warnings",
|
||||
kind="select",
|
||||
required=True,
|
||||
options=(
|
||||
("review", "Require review"),
|
||||
("continue", "Continue"),
|
||||
),
|
||||
),
|
||||
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
||||
),
|
||||
default_config={"pipeline_ref": "", "input_mapping": {}},
|
||||
default_config={
|
||||
"pipeline_ref": "",
|
||||
"revision": 1,
|
||||
"environment": "development",
|
||||
"row_limit": 500,
|
||||
"publication_target_ref": "",
|
||||
"warning_policy": "review",
|
||||
"input_mapping": {},
|
||||
},
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.end.completed",
|
||||
|
||||
@@ -24,6 +24,9 @@ from govoplan_workflow.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
INSTANCE_READ_SCOPE,
|
||||
INSTANCE_START_SCOPE,
|
||||
INSTANCE_TRANSITION_SCOPE,
|
||||
)
|
||||
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
|
||||
from govoplan_workflow.backend.schemas import (
|
||||
@@ -40,9 +43,22 @@ from govoplan_workflow.backend.schemas import (
|
||||
WorkflowDiagnosticResponse,
|
||||
WorkflowGraphValidationRequest,
|
||||
WorkflowGraphValidationResponse,
|
||||
WorkflowInstanceListResponse,
|
||||
WorkflowInstanceResponse,
|
||||
WorkflowInstanceStartRequest,
|
||||
WorkflowNodeLibraryResponse,
|
||||
WorkflowNodeTypeResponse,
|
||||
WorkflowPortResponse,
|
||||
WorkflowStepActionRequest,
|
||||
)
|
||||
from govoplan_workflow.backend.instance_service import (
|
||||
cancel_instance,
|
||||
get_instance,
|
||||
instance_response,
|
||||
list_instances,
|
||||
reconcile_instance,
|
||||
resolve_step,
|
||||
start_instance,
|
||||
)
|
||||
from govoplan_workflow.backend.runtime import get_registry
|
||||
from govoplan_workflow.backend.service import (
|
||||
@@ -159,6 +175,35 @@ def _audit(
|
||||
)
|
||||
|
||||
|
||||
def _audit_instance(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
action: str,
|
||||
instance_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_instance",
|
||||
object_id=instance_id,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _require_instance_view(instance, principal: ApiPrincipal) -> None:
|
||||
require_definition_action(
|
||||
instance.definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="view",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
|
||||
def api_node_types(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
@@ -324,6 +369,242 @@ def api_list_definitions(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/instances", response_model=WorkflowInstanceListResponse)
|
||||
def api_list_instances(
|
||||
definition_id: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceListResponse:
|
||||
_require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
instances = [
|
||||
instance
|
||||
for instance in list_instances(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
limit=limit,
|
||||
)
|
||||
if definition_decision(
|
||||
instance.definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="view",
|
||||
).allowed
|
||||
]
|
||||
return WorkflowInstanceListResponse(
|
||||
instances=[
|
||||
instance_response(session, instance)
|
||||
for instance in instances
|
||||
]
|
||||
)
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitions/{definition_id}/instances",
|
||||
response_model=WorkflowInstanceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_start_instance(
|
||||
definition_id: str,
|
||||
payload: WorkflowInstanceStartRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceResponse:
|
||||
_require_any_scope(principal, INSTANCE_START_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
instance, replayed = start_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
actor_id=_actor_id(principal),
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
payload=payload,
|
||||
)
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit_instance(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"workflow.instance.replayed"
|
||||
if replayed
|
||||
else "workflow.instance.started"
|
||||
),
|
||||
instance_id=instance.id,
|
||||
details={
|
||||
"definition_id": instance.definition_id,
|
||||
"definition_revision_id": instance.definition_revision_id,
|
||||
"idempotency_key": instance.idempotency_key,
|
||||
},
|
||||
)
|
||||
response = instance_response(session, instance, replayed=replayed)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/instances/{instance_id}",
|
||||
response_model=WorkflowInstanceResponse,
|
||||
)
|
||||
def api_get_instance(
|
||||
instance_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceResponse:
|
||||
_require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
instance = get_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
_require_instance_view(instance, principal)
|
||||
return instance_response(session, instance)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/reconcile",
|
||||
response_model=WorkflowInstanceResponse,
|
||||
)
|
||||
def api_reconcile_instance(
|
||||
instance_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceResponse:
|
||||
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
instance = get_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
for_update=True,
|
||||
)
|
||||
_require_instance_view(instance, principal)
|
||||
changed = reconcile_instance(
|
||||
session,
|
||||
instance=instance,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
actor_id=_actor_id(principal),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if changed:
|
||||
_audit_instance(
|
||||
session,
|
||||
principal,
|
||||
action="workflow.instance.reconciled",
|
||||
instance_id=instance.id,
|
||||
details={
|
||||
"status": instance.status,
|
||||
"current_step_id": instance.current_step_id,
|
||||
},
|
||||
)
|
||||
response = instance_response(session, instance)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/steps/{step_id}/actions",
|
||||
response_model=WorkflowInstanceResponse,
|
||||
)
|
||||
def api_resolve_instance_step(
|
||||
instance_id: str,
|
||||
step_id: str,
|
||||
payload: WorkflowStepActionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceResponse:
|
||||
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
existing = get_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
_require_instance_view(existing, principal)
|
||||
instance = resolve_step(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
step_id=step_id,
|
||||
actor_id=_actor_id(principal),
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
payload=payload,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit_instance(
|
||||
session,
|
||||
principal,
|
||||
action=f"workflow.instance.{payload.action}",
|
||||
instance_id=instance.id,
|
||||
details={
|
||||
"step_id": step_id,
|
||||
"status": instance.status,
|
||||
},
|
||||
)
|
||||
response = instance_response(session, instance)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/cancel",
|
||||
response_model=WorkflowInstanceResponse,
|
||||
)
|
||||
def api_cancel_instance(
|
||||
instance_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowInstanceResponse:
|
||||
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
instance = get_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
_require_instance_view(instance, principal)
|
||||
instance = cancel_instance(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
instance_id=instance_id,
|
||||
actor_id=_actor_id(principal),
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit_instance(
|
||||
session,
|
||||
principal,
|
||||
action="workflow.instance.cancelled",
|
||||
instance_id=instance.id,
|
||||
details={"status": instance.status},
|
||||
)
|
||||
response = instance_response(session, instance)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitions",
|
||||
response_model=WorkflowDefinitionResponse,
|
||||
|
||||
@@ -225,3 +225,99 @@ class WorkflowDefinitionActivateRequest(BaseModel):
|
||||
class WorkflowDefinitionDeleteResponse(BaseModel):
|
||||
deleted: bool
|
||||
definition_id: str
|
||||
|
||||
|
||||
WorkflowInstanceStatus = Literal[
|
||||
"running",
|
||||
"waiting",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]
|
||||
WorkflowStepStatus = Literal[
|
||||
"running",
|
||||
"waiting",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"superseded",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowInstanceStartRequest(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
correlation_id: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class WorkflowStepActionRequest(BaseModel):
|
||||
action: Literal[
|
||||
"complete",
|
||||
"approve",
|
||||
"changes",
|
||||
"reject",
|
||||
"resume",
|
||||
"retry",
|
||||
"cancel",
|
||||
]
|
||||
output: dict[str, Any] = Field(default_factory=dict)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
comment: str | None = Field(default=None, max_length=4_000)
|
||||
|
||||
|
||||
class WorkflowInstanceStepResponse(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
node_id: str
|
||||
node_type: str
|
||||
status: WorkflowStepStatus
|
||||
attempt: int
|
||||
input: dict[str, Any]
|
||||
output: dict[str, Any]
|
||||
handoff: dict[str, Any]
|
||||
external_ref: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
error: str | None
|
||||
completed_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkflowInstanceEventResponse(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
step_id: str | None
|
||||
kind: str
|
||||
actor_id: str | None
|
||||
payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WorkflowInstanceResponse(BaseModel):
|
||||
id: str
|
||||
definition_id: str
|
||||
definition_name: str
|
||||
definition_revision: int
|
||||
definition_hash: str
|
||||
status: WorkflowInstanceStatus
|
||||
idempotency_key: str
|
||||
correlation_id: str | None
|
||||
current_step_id: str | None
|
||||
input: dict[str, Any]
|
||||
context: dict[str, Any]
|
||||
output: dict[str, Any]
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
cancellation_requested_at: datetime | None
|
||||
error: str | None
|
||||
created_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[WorkflowInstanceStepResponse]
|
||||
events: list[WorkflowInstanceEventResponse]
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
class WorkflowInstanceListResponse(BaseModel):
|
||||
instances: list[WorkflowInstanceResponse]
|
||||
|
||||
Reference in New Issue
Block a user