Files
NuklearRabbit 6f77a30dce fix: stop the prepared demo failure from degrading n8n integration health
The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate
retry and audit. Because derive_n8n_status() counted any failure, every fresh
reset pinned the n8n integration to "degraded" -- the demo showed a warning
about a prop, which tells a viewer something untrue about the automation.

The seeded failure now carries its own error code, demoScenarioTimeout, rather
than the generic connectionError a real timeout produces. No column and no
migration: last_error_code already existed, is already surfaced to the UI and is
already localizable.

- integration status splits failed into unexpected_failed and
  demo_scenario_failed; only unexpected failures may move the state. A staged
  failure alone leaves n8n operational.
- latest_failure_at is a health signal and now ignores the staged failure;
  latest_demo_scenario_at reports it separately.
- /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run
  as a prepared demo scenario, explains that it is a simulated temporary failure
  that does not affect automation health, and offers a distinct "retry demo
  scenario" action. Translated in nl-BE, en-GB and fr-BE.
- the carve-out stays narrow: a real failure still degrades n8n, and a genuine
  later failure of the same event overwrites the demo code with the real one.
- the retry itself is unchanged and real: the event goes back on the outbox and
  the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on
  an actual round trip. The audit records which kind of failure was retried.

Tests that assert on the seeded scenario now reseed first, since earlier test
files legitimately mutate the outbox and the suite shares one database.

Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy
clean (50 files), tsc clean, frontend build clean. Not deployed and not
browser-verified.
2026-08-05 14:07:05 +00:00

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)