feat(workflow): orchestrate resumable dataflow handoffs

This commit is contained in:
2026-07-30 03:11:56 +02:00
parent a1654e70cf
commit a6e0e89829
18 changed files with 3938 additions and 27 deletions
@@ -1,9 +1,15 @@
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowInstance,
WorkflowInstanceEvent,
WorkflowInstanceStep,
)
__all__ = [
"WorkflowDefinition",
"WorkflowDefinitionRevision",
"WorkflowInstance",
"WorkflowInstanceEvent",
"WorkflowInstanceStep",
]
+241
View File
@@ -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",
]
+6 -3
View File
@@ -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
+39
View File
@@ -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",
),
),
@@ -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")
+63 -1
View File
@@ -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",
+281
View File
@@ -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,
+96
View File
@@ -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]