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
+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,