91 lines
3.2 KiB
Python
91 lines
3.2 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_db, require_operations_manager
|
|
from app.core.errors import AppError
|
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
|
from app.schemas import AutomationRunOut, CurrentUser
|
|
from app.services.audit import record_audit_event
|
|
|
|
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
|
|
|
|
|
|
def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|
return AutomationRunOut(
|
|
event_id=str(event.event_id),
|
|
event_type=event.event_type,
|
|
aggregate_ref=event.payload_json.get("aggregate_ref", ""),
|
|
status=event.delivery_status,
|
|
attempts=event.attempts,
|
|
last_error=event.last_error,
|
|
last_error_code=event.last_error_code,
|
|
is_demo_scenario=is_demo_scenario_failure(event),
|
|
occurred_at=event.occurred_at,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[AutomationRunOut])
|
|
def list_workflows(
|
|
status: str | None = Query(default=None),
|
|
db: Session = Depends(get_db),
|
|
_user: CurrentUser = Depends(require_operations_manager),
|
|
) -> list[AutomationRunOut]:
|
|
stmt = select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc())
|
|
if status:
|
|
stmt = stmt.where(OutboxEvent.delivery_status == status)
|
|
events = db.scalars(stmt).all()
|
|
return [_to_out(e) for e in events]
|
|
|
|
|
|
@router.post("/{event_id}/retry", response_model=AutomationRunOut)
|
|
def retry_workflow(
|
|
event_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: CurrentUser = Depends(require_operations_manager),
|
|
) -> AutomationRunOut:
|
|
try:
|
|
parsed_id = uuid.UUID(event_id)
|
|
except ValueError as exc:
|
|
raise AppError("INVALID_EVENT_ID", "event_id must be a UUID.", status_code=422) from exc
|
|
|
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == parsed_id))
|
|
if event is None:
|
|
raise AppError("EVENT_NOT_FOUND", "Workflow event not found.", status_code=404)
|
|
if event.delivery_status != "failed":
|
|
raise AppError(
|
|
"NOT_RETRYABLE",
|
|
f"Event is '{event.delivery_status}', not 'failed'; "
|
|
"only failed deliveries can be retried.",
|
|
status_code=409,
|
|
)
|
|
|
|
# Captured before the status flips, so the audit records what was actually retried.
|
|
was_demo_scenario = is_demo_scenario_failure(event)
|
|
|
|
event.delivery_status = "pending"
|
|
event.next_attempt_at = None
|
|
# The retry itself is real either way: the event goes back on the outbox and the
|
|
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
|
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
|
# one -- so the audit trail never implies a production incident was resolved when a
|
|
# prop was.
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_label=user.display_name,
|
|
action="workflow_retry",
|
|
entity_type="outbox_event",
|
|
metadata={
|
|
"event_id": event_id,
|
|
"previous_attempts": event.attempts,
|
|
"demo_scenario": was_demo_scenario,
|
|
},
|
|
)
|
|
db.commit()
|
|
return _to_out(event)
|