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
+11 -7
View File
@@ -20,10 +20,13 @@ 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.
The executable runtime persists revision-pinned instances, append-only
transition evidence, resumable human handoffs, retries, cancellation, and
stable external output references. Dataflow nodes enqueue work through
Dataflow's lifecycle capability; Workflow never imports Dataflow internals.
Core's periodic worker reconciles linked runs after re-resolving the stored
automation principal, while the operator surface exposes progress, review
actions, evidence references, and direct navigation to Dataflow results.
Definitions can be complete flows or non-runnable templates at system,
tenant, group, or user scope. Policy resolves whether a definition can be
@@ -33,9 +36,10 @@ and records its hash, node-library version, source scope, actor, Policy
decision, and effective ancestor limits.
The start-node library distinguishes explicit user, API, scheduled, event, and
parent-workflow starts. These are definition contracts only until the
resumable Workflow instance runtime is implemented; the UI reports that
limitation rather than presenting automation as operational.
parent-workflow starts. Manual starts and Dataflow/human handoffs are
operational. The other trigger and generic capability nodes remain explicit
definition contracts until their event/schedule dispatchers and versioned
operation providers are implemented.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
+21 -15
View File
@@ -104,19 +104,23 @@ The first executable slice now provides:
- trigger, activity, review, decision, wait, module-action, Dataflow, and outcome
nodes
- API discovery and validation endpoints
- revision-pinned, idempotent Workflow instances
- persisted steps and append-only transition evidence
- durable Dataflow handoff, progress reconciliation, output references,
retries, cancellation, and warning/review paths
- manual activity, review, and wait handoffs with comments and evidence
- a worker capability with current-authorization rechecks
- an operator dialog for starting, inspecting, and advancing instances
The next execution slices should provide:
- static workflow definition registration from configuration packages
- create/read/list workflow instances
- transition execution with permission checks
- event, API, schedule, and parent-workflow start dispatchers
- guard hooks implemented through capability calls
- command execution records with retry/manual-resolution state
- registry-driven generic module-action execution records
- action/effect previews for transitions that call other modules
- idempotency keys for command execution
- explicit blocked, retryable, quarantined, manual-required, and
compensation-required states
- basic WebUI instance detail and definition viewer
- dashboard summary provider
- event emission and audit integration
@@ -149,12 +153,16 @@ details.
## Data Model Sketch
Candidate tables:
Current tables:
- `workflow_definitions`
- `workflow_definition_versions`
- `workflow_definition_revisions`
- `workflow_instances`
- `workflow_transition_history`
- `workflow_instance_steps`
- `workflow_instance_events`
Future generic action execution and timers may add:
- `workflow_command_records`
- `workflow_timers`
@@ -163,16 +171,14 @@ reference the exact version used at start.
## WebUI
Initial route contributions:
Current route contribution:
- `/workflow`
- `/workflow/instances/:instanceId`
- `/workflow/definitions/:definitionId`
The UI should show current state, available transitions, pending commands,
failed handoffs, audit trace, and linked subject records. It should not import
case/task/template components directly; panels are contributed through core UI
extension points.
The route combines the definition editor and a fixed run dialog showing current
state, available transitions, failed handoffs, comments/evidence, immutable
event history, and linked Dataflow results. It does not import Dataflow or other
domain UI components.
## Tests
@@ -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]
+482
View File
@@ -0,0 +1,482 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
PrincipalRef,
)
from govoplan_core.core.automation import AutomationPrincipalResolution
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
DataflowRunDescriptor,
)
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowInstance,
WorkflowInstanceEvent,
WorkflowInstanceStep,
)
from govoplan_workflow.backend.instance_service import (
SqlWorkflowRuntimeWorker,
cancel_instance,
instance_response,
reconcile_instance,
resolve_step,
start_instance,
)
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowInstanceStartRequest,
WorkflowNode,
WorkflowStepActionRequest,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
)
def principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(
{
"workflow:definition:read",
"workflow:instance:read",
"workflow:instance:start",
"workflow:instance:transition",
"dataflow:pipeline:run",
}
),
),
account=object(),
user=object(),
)
def runtime_graph() -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
label="Start",
config={"input_schema_ref": ""},
),
WorkflowNode(
id="flow",
type="workflow.dataflow",
label="Prepare evidence",
config={
"pipeline_ref": "pipeline:pipeline-1",
"revision": 3,
"environment": "development",
"row_limit": 250,
"publication_target_ref": "",
"warning_policy": "review",
"input_mapping": {},
},
),
WorkflowNode(
id="complete",
type="workflow.end.completed",
label="Complete",
config={"output_mapping": {}},
),
WorkflowNode(
id="cancelled",
type="workflow.end.cancelled",
label="Rejected",
config={"reason": "Rejected during review"},
),
],
edges=[
WorkflowEdge(
id="start-flow",
source="start",
target="flow",
),
WorkflowEdge(
id="flow-complete",
source="flow",
source_port="success",
target="complete",
),
WorkflowEdge(
id="flow-warning",
source="flow",
source_port="warning",
target="complete",
),
WorkflowEdge(
id="flow-review",
source="flow",
source_port="review_required",
target="complete",
),
WorkflowEdge(
id="flow-failure",
source="flow",
source_port="failure",
target="cancelled",
),
],
)
class FakeDataflowLifecycle:
def __init__(self) -> None:
self.runs: dict[str, DataflowRunDescriptor] = {}
self.requests = []
self.cancelled: list[str] = []
def start_run(self, _session, _principal, *, request):
self.requests.append(request)
run_ref = f"run:{len(self.requests)}"
descriptor = DataflowRunDescriptor(
ref=run_ref,
pipeline_ref=request.pipeline_ref,
revision=request.revision,
status="queued",
definition_hash="definition-hash",
executor_version="test",
metadata={"progress_percent": 0, "progress_phase": "queued"},
)
self.runs[run_ref] = descriptor
return descriptor
def get_run(self, _session, _principal, *, run_ref):
return self.runs.get(run_ref)
def cancel_run(self, _session, _principal, *, run_ref):
descriptor = self.runs[run_ref]
descriptor = replace(descriptor, status="cancelled")
self.runs[run_ref] = descriptor
self.cancelled.append(run_ref)
return descriptor
def finish(
self,
run_ref: str,
*,
diagnostics: list[dict[str, object]] | None = None,
) -> None:
self.runs[run_ref] = replace(
self.runs[run_ref],
status="succeeded",
output_publication_ref="publication:1",
output_datasource_ref="datasource:1",
output_materialization_ref="materialization:1",
input_row_count=12,
output_row_count=10,
metadata={"diagnostics": diagnostics or []},
)
def fail(self, run_ref: str) -> None:
self.runs[run_ref] = replace(
self.runs[run_ref],
status="failed",
error="Data quality gate failed.",
)
class FakeAutomationProvider:
def __init__(self) -> None:
self.requests = []
def resolve_automation_principal(self, _session, *, request):
self.requests.append(request)
return AutomationPrincipalResolution(
allowed=True,
principal=principal(),
granted_scopes=request.grant_scopes,
provenance={"status": "rechecked"},
)
class Registry:
def __init__(
self,
dataflow: FakeDataflowLifecycle,
automation: FakeAutomationProvider | None = None,
) -> None:
self.dataflow = dataflow
self.automation = automation
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
and self.automation is not None
)
def capability(self, name: str):
if name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE:
return self.dataflow
if (
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
and self.automation is not None
):
return self.automation
raise KeyError(name)
class WorkflowInstanceServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
WorkflowInstance.__table__,
WorkflowInstanceStep.__table__,
WorkflowInstanceEvent.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
self.dataflow = FakeDataflowLifecycle()
self.registry = Registry(self.dataflow)
self.definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Monthly governed processing",
graph=runtime_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="account-1",
revision=1,
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
WorkflowInstanceEvent.__table__,
WorkflowInstanceStep.__table__,
WorkflowInstance.__table__,
WorkflowDefinitionRevision.__table__,
WorkflowDefinition.__table__,
],
)
self.engine.dispose()
def _start(self, key: str = "request-1") -> WorkflowInstance:
instance, replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key=key,
input={"case_id": "case-1"},
correlation_id="correlation-1",
),
)
self.assertFalse(replayed)
return instance
def test_start_pins_revision_and_replays_idempotently(self) -> None:
instance = self._start()
replayed, was_replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="request-1",
input={"case_id": "case-1"},
correlation_id="correlation-1",
),
)
self.assertTrue(was_replayed)
self.assertEqual(instance.id, replayed.id)
self.assertEqual("waiting", instance.status)
self.assertEqual(1, len(self.dataflow.requests))
response = instance_response(self.session, instance)
self.assertEqual([1, 2], [step.sequence for step in response.steps])
self.assertEqual("run:1", response.steps[-1].external_ref)
self.assertGreaterEqual(len(response.events), 4)
with self.assertRaises(WorkflowConflictError):
start_instance(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="request-1",
input={"case_id": "another-case"},
correlation_id="correlation-1",
),
)
def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None:
instance = self._start()
self.dataflow.finish("run:1")
changed = reconcile_instance(
self.session,
instance=instance,
principal=principal(),
registry=self.registry,
actor_id="account-1",
)
response = instance_response(self.session, instance)
self.assertTrue(changed)
self.assertEqual("completed", response.status)
flow_output = response.context["steps"]["flow"]
self.assertEqual("publication:1", flow_output["output_publication_ref"])
self.assertEqual("datasource:1", flow_output["output_datasource_ref"])
self.assertEqual(
"materialization:1",
flow_output["output_materialization_ref"],
)
self.assertEqual("workflow.instance.completed", response.events[-1].kind)
def test_warning_requires_review_and_approve_resumes(self) -> None:
instance = self._start()
self.dataflow.finish(
"run:1",
diagnostics=[
{
"severity": "warning",
"code": "review.required",
"message": "Verify unmatched records.",
}
],
)
reconcile_instance(
self.session,
instance=instance,
principal=principal(),
registry=self.registry,
)
step_id = str(instance.current_step_id)
self.assertEqual(
"review_required",
instance_response(self.session, instance).steps[-1].handoff["state"],
)
resolved = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step_id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(
action="approve",
comment="Evidence verified.",
evidence=["publication:1"],
),
)
self.assertEqual("completed", resolved.status)
def test_failure_can_retry_and_reject_invalid_actions(self) -> None:
instance = self._start()
self.dataflow.fail("run:1")
reconcile_instance(
self.session,
instance=instance,
principal=principal(),
registry=self.registry,
)
step_id = str(instance.current_step_id)
with self.assertRaises(WorkflowConflictError):
resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step_id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(action="approve"),
)
retried = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step_id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(action="retry"),
)
self.assertEqual("waiting", retried.status)
self.assertEqual(2, len(self.dataflow.requests))
self.assertEqual("run:2", retried.steps[-1].external_ref)
self.assertEqual("superseded", retried.steps[-2].status)
def test_cancel_propagates_to_linked_dataflow(self) -> None:
instance = self._start()
cancelled = cancel_instance(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
)
self.assertEqual("cancelled", cancelled.status)
self.assertEqual(["run:1"], self.dataflow.cancelled)
def test_worker_rechecks_authorization_before_reconciling(self) -> None:
instance = self._start()
self.session.commit()
self.dataflow.finish("run:1")
automation = FakeAutomationProvider()
worker = SqlWorkflowRuntimeWorker(
registry=Registry(self.dataflow, automation),
)
summary = worker.reconcile_pending(self.session)
self.assertEqual(1, summary["advanced"])
self.assertEqual("completed", instance.status)
self.assertEqual(1, len(automation.requests))
self.assertEqual(
"rechecked",
instance.authorization_["last_resolution"]["status"],
)
if __name__ == "__main__":
unittest.main()
+16
View File
@@ -5,8 +5,12 @@ import unittest
from govoplan_workflow.backend.manifest import (
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
INSTANCE_START_SCOPE,
get_manifest,
)
from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
)
class WorkflowManifestTests(unittest.TestCase):
@@ -26,6 +30,18 @@ class WorkflowManifestTests(unittest.TestCase):
DEFINITION_WRITE_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertIn(
INSTANCE_START_SCOPE,
{item.scope for item in manifest.permissions},
)
self.assertIn(
"workflow.runtime_worker",
{item.name for item in manifest.provides_interfaces},
)
self.assertIn(
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
manifest.capability_factories,
)
self.assertEqual(
"@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None,
+4 -1
View File
@@ -26,13 +26,16 @@ class WorkflowMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"c6d8f1a3e5b7",
"d8f2a5c7e1b4",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
{
"workflow_definition_revisions",
"workflow_definitions",
"workflow_instance_events",
"workflow_instance_steps",
"workflow_instances",
},
{
name
+155
View File
@@ -84,6 +84,75 @@ export type WorkflowGovernance = {
automation_runtime_reason?: string | null;
};
export type WorkflowInstanceStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled";
export type WorkflowStepStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled"
| "superseded";
export type WorkflowInstanceStep = {
id: string;
sequence: number;
node_id: string;
node_type: string;
status: WorkflowStepStatus;
attempt: number;
input: Record<string, unknown>;
output: Record<string, unknown>;
handoff: Record<string, unknown>;
external_ref?: string | null;
started_at?: string | null;
finished_at?: string | null;
error?: string | null;
completed_by?: string | null;
created_at: string;
updated_at: string;
};
export type WorkflowInstanceEvent = {
id: string;
sequence: number;
step_id?: string | null;
kind: string;
actor_id?: string | null;
payload: Record<string, unknown>;
created_at: string;
};
export type WorkflowInstance = {
id: string;
definition_id: string;
definition_name: string;
definition_revision: number;
definition_hash: string;
status: WorkflowInstanceStatus;
idempotency_key: string;
correlation_id?: string | null;
current_step_id?: string | null;
input: Record<string, unknown>;
context: Record<string, unknown>;
output: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
cancellation_requested_at?: string | null;
error?: string | null;
created_by?: string | null;
created_at: string;
updated_at: string;
steps: WorkflowInstanceStep[];
events: WorkflowInstanceEvent[];
replayed: boolean;
};
export type WorkflowDefinitionPayload = {
name: string;
description?: string | null;
@@ -232,3 +301,89 @@ export function workflowScopeReferenceProvider(
{ scope_type: scopeType }
);
}
export async function listWorkflowInstances(
settings: ApiSettings,
definitionId?: string | null
): Promise<WorkflowInstance[]> {
const params = new URLSearchParams();
if (definitionId) params.set("definition_id", definitionId);
const query = params.size ? `?${params.toString()}` : "";
const response = await apiFetch<{ instances: WorkflowInstance[] }>(
settings,
`/api/v1/workflow/instances${query}`
);
return response.instances;
}
export function startWorkflowInstance(
settings: ApiSettings,
definitionId: string,
payload: {
idempotency_key: string;
input?: Record<string, unknown>;
correlation_id?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/instances`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function getWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}`
);
}
export function reconcileWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/reconcile`,
{ method: "POST" }
);
}
export function resolveWorkflowStep(
settings: ApiSettings,
instanceId: string,
stepId: string,
payload: {
action: "complete" | "approve" | "changes" | "reject" | "resume" | "retry" | "cancel";
output?: Record<string, unknown>;
evidence?: string[];
comment?: string | null;
}
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/steps/${encodeURIComponent(stepId)}/actions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function cancelWorkflowInstance(
settings: ApiSettings,
instanceId: string
): Promise<WorkflowInstance> {
return apiFetch(
settings,
`/api/v1/workflow/instances/${encodeURIComponent(instanceId)}/cancel`,
{ method: "POST" }
);
}
@@ -10,6 +10,7 @@ import {
CheckCircle2,
CopyPlus,
GitFork,
ListChecks,
Plus,
RefreshCw,
RotateCcw,
@@ -57,6 +58,7 @@ import WorkflowCanvas, {
updateWorkflowGraphNode
} from "./WorkflowCanvas";
import WorkflowInspector from "./WorkflowInspector";
import WorkflowRunsDialog from "./WorkflowRunsDialog";
import {
FALLBACK_WORKFLOW_LIBRARY,
draftFromDefinition,
@@ -103,6 +105,7 @@ export default function WorkflowPage({
const [deleteOpen, setDeleteOpen] = useState(false);
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
@@ -114,6 +117,18 @@ export default function WorkflowPage({
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const canStart = Boolean(
draft?.id
&& (hasScope(auth, "workflow:instance:start")
|| hasScope(auth, "workflow:instance:admin"))
&& draft.governance?.actions.start?.allowed !== false
);
const canTransition = hasScope(auth, "workflow:instance:transition")
|| hasScope(auth, "workflow:instance:admin");
const selectedDefinition = useMemo(
() => definitions.find((item) => item.id === draft?.id) ?? null,
[definitions, draft?.id]
);
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
@@ -531,6 +546,14 @@ export default function WorkflowPage({
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
{draft.id ? (
<Button
onClick={() => setRunsOpen(true)}
disabled={dirty || historicalRevision !== null}
>
<ListChecks size={16} /> Runs
</Button>
) : null}
<IconButton
label="Definition settings"
icon={<Settings2 size={16} />}
@@ -748,6 +771,14 @@ export default function WorkflowPage({
setSuccess("Created a pinned scoped copy.");
}}
/>
<WorkflowRunsDialog
open={runsOpen}
settings={settings}
definition={selectedDefinition}
canStart={canStart}
canTransition={canTransition}
onClose={() => setRunsOpen(false)}
/>
</main>
);
}
@@ -0,0 +1,516 @@
import {
useCallback,
useEffect,
useMemo,
useState
} from "react";
import {
ExternalLink,
Play,
RefreshCw,
RotateCcw,
XCircle
} from "lucide-react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingFrame,
StatusBadge,
type ApiSettings
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
listWorkflowInstances,
reconcileWorkflowInstance,
resolveWorkflowStep,
startWorkflowInstance,
type WorkflowDefinition,
type WorkflowInstance,
type WorkflowInstanceStep
} from "../../api/workflow";
type WorkflowAction =
| "complete"
| "approve"
| "changes"
| "reject"
| "resume"
| "retry"
| "cancel";
export default function WorkflowRunsDialog({
open,
settings,
definition,
canStart,
canTransition,
onClose
}: {
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
}) {
const [instances, setInstances] = useState<WorkflowInstance[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
[instances, selectedId]
);
const currentStep = useMemo(
() => currentInstanceStep(selected),
[selected]
);
const allowedActions = useMemo(
() => handoffActions(currentStep),
[currentStep]
);
const mergeInstance = useCallback((instance: WorkflowInstance) => {
setInstances((current) => [
instance,
...current.filter((item) => item.id !== instance.id)
]);
setSelectedId(instance.id);
}, []);
const load = useCallback(async () => {
if (!open || !definition?.id) return;
setLoading(true);
setError("");
try {
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [definition?.id, open, settings]);
useEffect(() => {
if (!open) return;
setComment("");
setEvidence("");
void load();
}, [load, open]);
useEffect(() => {
if (
!open
|| !canTransition
|| !selected
|| !currentStep
|| currentStep.node_type !== "workflow.dataflow"
|| !["queued", "retrying", "running"].includes(
String(currentStep.handoff.state ?? "")
)
) {
return;
}
let stopped = false;
const poll = window.setInterval(() => {
void reconcileWorkflowInstance(settings, selected.id)
.then((instance) => {
if (!stopped) mergeInstance(instance);
})
.catch((pollError) => {
if (!stopped) setError(errorMessage(pollError));
});
}, 2500);
return () => {
stopped = true;
window.clearInterval(poll);
};
}, [
canTransition,
currentStep,
mergeInstance,
open,
selected,
settings
]);
const start = async () => {
if (!definition?.id) return;
setWorking(true);
setError("");
try {
const instance = await startWorkflowInstance(settings, definition.id, {
idempotency_key: crypto.randomUUID(),
input: {}
});
mergeInstance(instance);
} catch (startError) {
setError(errorMessage(startError));
} finally {
setWorking(false);
}
};
const refreshSelected = async () => {
if (!selected) {
await load();
return;
}
setWorking(true);
setError("");
try {
const instance = canTransition
? await reconcileWorkflowInstance(settings, selected.id)
: (await listWorkflowInstances(settings, definition?.id))
.find((item) => item.id === selected.id);
if (instance) mergeInstance(instance);
else await load();
} catch (refreshError) {
setError(errorMessage(refreshError));
} finally {
setWorking(false);
}
};
const performAction = async (action: WorkflowAction) => {
if (!selected || !currentStep) return;
setWorking(true);
setError("");
try {
const instance = await resolveWorkflowStep(
settings,
selected.id,
currentStep.id,
{
action,
comment: comment.trim() || null,
evidence: evidence
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
);
mergeInstance(instance);
setComment("");
setEvidence("");
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setWorking(false);
}
};
const cancel = async () => {
if (!selected) return;
setWorking(true);
setError("");
try {
mergeInstance(await cancelWorkflowInstance(settings, selected.id));
setCancelOpen(false);
} catch (cancelError) {
setError(errorMessage(cancelError));
} finally {
setWorking(false);
}
};
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
return (
<>
<Dialog
open={open}
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
<div className="workflow-runs-toolbar">
<span>
<strong>Workflow instances</strong>
<small>Revision-pinned runs and human handoffs</small>
</span>
<span>
<IconButton
label="Refresh runs"
icon={<RefreshCw size={16} />}
variant="ghost"
onClick={() => void refreshSelected()}
disabled={loading || working}
/>
<Button
variant="primary"
onClick={() => void start()}
disabled={!canStart || working || definition?.status !== "active"}
disabledReason={
definition?.status !== "active"
? "Activate a definition revision before starting it."
: undefined
}
>
<Play size={16} /> Start
</Button>
</span>
</div>
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<LoadingFrame loading={loading} className="workflow-runs-frame">
<div className="workflow-runs-layout">
<div className="workflow-run-list">
{instances.map((instance) => (
<button
key={instance.id}
type="button"
className={instance.id === selected?.id ? "is-selected" : ""}
onClick={() => {
setSelectedId(instance.id);
setComment("");
setEvidence("");
}}
>
<span>
<strong>{formatDateTime(instance.started_at)}</strong>
<small>
Revision {instance.definition_revision} · {instance.steps.length} steps
</small>
</span>
<StatusBadge
status={instance.status}
label={instance.status}
/>
</button>
))}
{!instances.length ? (
<div className="workflow-run-empty">No runs yet</div>
) : null}
</div>
<div className="workflow-run-detail">
{selected ? (
<>
<header>
<span>
<strong>{selected.definition_name}</strong>
<small>
Revision {selected.definition_revision} · {selected.definition_hash.slice(0, 12)}
</small>
</span>
<span>
<StatusBadge status={selected.status} label={selected.status} />
{["running", "waiting"].includes(selected.status) ? (
<IconButton
label="Cancel workflow run"
icon={<XCircle size={16} />}
variant="danger"
onClick={() => setCancelOpen(true)}
disabled={!canTransition || working}
/>
) : null}
</span>
</header>
{selected.error ? (
<DismissibleAlert tone="danger" resetKey={selected.error}>
{selected.error}
</DismissibleAlert>
) : null}
{currentStep ? (
<section className="workflow-run-handoff">
<div>
<span>
<strong>
{String(
currentStep.handoff.title
?? currentStep.handoff.kind
?? currentStep.node_type
)}
</strong>
<small>
Step {currentStep.sequence} · attempt {currentStep.attempt}
</small>
</span>
<StatusBadge
status={String(currentStep.handoff.state ?? currentStep.status)}
label={String(currentStep.handoff.state ?? currentStep.status)}
/>
</div>
{typeof currentStep.handoff.message === "string" ? (
<p>{currentStep.handoff.message}</p>
) : null}
{typeof currentStep.handoff.instructions === "string"
&& currentStep.handoff.instructions ? (
<p>{currentStep.handoff.instructions}</p>
) : null}
{actionUrl ? (
<a href={actionUrl}>
Open linked Dataflow result <ExternalLink size={14} />
</a>
) : null}
{allowedActions.some((action) => action !== "cancel") ? (
<div className="workflow-run-action-form">
<FormField label="Comment">
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<FormField
label="Evidence references"
help="Enter one durable evidence reference per line."
>
<textarea
value={evidence}
onChange={(event) => setEvidence(event.target.value)}
rows={2}
disabled={!canTransition || working}
/>
</FormField>
<div className="workflow-run-actions">
{allowedActions
.filter((action) => action !== "cancel")
.map((action) => (
<Button
key={action}
variant={
action === "reject"
? "danger"
: action === "approve"
|| action === "complete"
|| action === "resume"
? "primary"
: undefined
}
onClick={() => void performAction(action)}
disabled={!canTransition || working}
>
{action === "retry" ? <RotateCcw size={15} /> : null}
{actionLabel(action)}
</Button>
))}
</div>
</div>
) : null}
</section>
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<div>
{selected.steps.map((step) => (
<span key={step.id}>
<strong>{step.node_id}</strong>
<small>
{step.node_type} · attempt {step.attempt}
</small>
<StatusBadge status={step.status} label={step.status} />
</span>
))}
</div>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
<div>
{[...selected.events].reverse().map((event) => (
<span key={event.id}>
<strong>{event.kind}</strong>
<small>
{formatDateTime(event.created_at)}
{event.actor_id ? ` · ${event.actor_id}` : ""}
</small>
</span>
))}
</div>
</section>
</>
) : (
<div className="workflow-run-empty">
Start a run to track its progress here.
</div>
)}
</div>
</div>
</LoadingFrame>
</Dialog>
<ConfirmDialog
open={cancelOpen}
title="Cancel workflow run"
message="Cancel this workflow instance and its active Dataflow run?"
confirmLabel="Cancel run"
tone="danger"
busy={working}
onCancel={() => setCancelOpen(false)}
onConfirm={() => void cancel()}
/>
</>
);
}
function currentInstanceStep(
instance: WorkflowInstance | null
): WorkflowInstanceStep | null {
if (!instance?.current_step_id) return null;
return instance.steps.find(
(step) => step.id === instance.current_step_id
) ?? null;
}
function handoffActions(step: WorkflowInstanceStep | null): WorkflowAction[] {
const actions = step?.handoff.allowed_actions;
if (!Array.isArray(actions)) return [];
return actions.filter((action): action is WorkflowAction => (
typeof action === "string"
&& [
"complete",
"approve",
"changes",
"reject",
"resume",
"retry",
"cancel"
].includes(action)
));
}
function actionLabel(action: WorkflowAction): string {
return {
complete: "Complete",
approve: "Approve",
changes: "Request changes",
reject: "Reject",
resume: "Resume",
retry: "Retry",
cancel: "Cancel"
}[action];
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "The Workflow request failed.";
}
+273
View File
@@ -656,6 +656,255 @@
padding: 7px 10px;
}
.workflow-runs-dialog {
width: min(1120px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.workflow-runs-dialog-body {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding: 0;
}
.workflow-runs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 0 0 auto;
min-height: 56px;
border-bottom: var(--border-line);
padding: 9px 12px;
}
.workflow-runs-toolbar > span {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-runs-toolbar > span:first-child {
display: grid;
gap: 2px;
}
.workflow-runs-toolbar small {
color: var(--muted);
font-size: 11px;
}
.workflow-runs-frame,
.workflow-runs-layout,
.workflow-run-list,
.workflow-run-detail {
min-width: 0;
min-height: 0;
}
.workflow-runs-frame {
flex: 1 1 auto;
overflow: hidden;
}
.workflow-runs-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
height: 100%;
overflow: hidden;
}
.workflow-run-list {
overflow: auto;
border-right: var(--border-line);
background: var(--panel-soft);
padding: 6px;
}
.workflow-run-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 54px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.workflow-run-list > button:hover,
.workflow-run-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.workflow-run-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.workflow-run-list strong,
.workflow-run-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-list strong {
color: var(--text-strong);
font-size: 12px;
}
.workflow-run-list small {
margin-top: 4px;
color: var(--muted);
font-size: 10px;
}
.workflow-run-detail {
display: flex;
flex-direction: column;
overflow: auto;
}
.workflow-run-detail > header,
.workflow-run-handoff > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.workflow-run-detail > header {
position: sticky;
z-index: 2;
top: 0;
min-height: 56px;
border-bottom: var(--border-line);
background: var(--panel);
padding: 9px 12px;
}
.workflow-run-detail > header > span {
display: flex;
align-items: center;
gap: 7px;
}
.workflow-run-detail > header > span:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.workflow-run-detail > header strong,
.workflow-run-detail > header small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-run-detail > header small {
color: var(--muted);
font-size: 10px;
}
.workflow-run-handoff,
.workflow-run-history,
.workflow-run-events {
display: grid;
gap: 10px;
border-bottom: var(--border-line);
padding: 12px;
}
.workflow-run-handoff p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.workflow-run-handoff a {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
color: var(--accent);
font-size: 12px;
}
.workflow-run-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.workflow-run-action-form textarea {
width: 100%;
resize: vertical;
}
.workflow-run-actions {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 7px;
}
.workflow-run-actions .btn {
display: inline-flex;
align-items: center;
gap: 5px;
}
.workflow-run-history h3,
.workflow-run-events h3 {
margin: 0;
color: var(--text-strong);
font-size: 12px;
}
.workflow-run-history > div,
.workflow-run-events > div {
display: grid;
}
.workflow-run-history > div > span,
.workflow-run-events > div > span {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto;
align-items: center;
gap: 9px;
min-height: 38px;
border-top: var(--border-line);
padding: 6px 2px;
}
.workflow-run-history small,
.workflow-run-events small {
color: var(--muted);
font-size: 10px;
}
.workflow-run-empty {
display: grid;
min-height: 120px;
place-items: center;
color: var(--muted);
font-size: 12px;
text-align: center;
}
@media (max-width: 1180px) {
.workflow-shell {
grid-template-columns: 240px minmax(0, 1fr);
@@ -749,4 +998,28 @@
.workflow-palette-items button {
min-width: 128px;
}
.workflow-runs-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
}
.workflow-run-list {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-run-action-form {
grid-template-columns: 1fr;
}
.workflow-run-history > div > span,
.workflow-run-events > div > span {
grid-template-columns: minmax(0, 1fr) auto;
}
.workflow-run-history > div > span small,
.workflow-run-events > div > span small {
grid-column: 1 / -1;
}
}