feat(workflow): orchestrate resumable dataflow handoffs
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user