303 lines
11 KiB
Python
303 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, Header
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_db
|
|
from app.core.config import get_settings
|
|
from app.core.errors import AppError
|
|
from app.models.audit import AuditEvent
|
|
from app.models.outbox import OutboxEvent
|
|
from app.schemas import (
|
|
N8nHeartbeatIn,
|
|
N8nHeartbeatResult,
|
|
ProcedureDocumentOut,
|
|
ProcedureListOut,
|
|
ProcedureSyncResultIn,
|
|
ProcedureSyncResultResult,
|
|
ReturnCallbackIn,
|
|
ScanResultOut,
|
|
WorkflowErrorReportIn,
|
|
WorkflowErrorReportResult,
|
|
)
|
|
from app.services.audit import record_audit_event
|
|
from app.services.data_quality import run_scan
|
|
from app.services.knowledge.procedures import iter_procedure_documents
|
|
|
|
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
|
settings = get_settings()
|
|
|
|
_CANONICAL_WORKFLOW_NAMES = frozenset(
|
|
{
|
|
"Fleet Ops — Vehicle Return Orchestration",
|
|
"Fleet Ops — Scheduled Data Quality Scan",
|
|
"Fleet Ops — RAGcore Procedure Sync",
|
|
"Fleet Ops — Workflow Error Handler",
|
|
}
|
|
)
|
|
|
|
|
|
def _require_service_token(service_token: str) -> None:
|
|
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
|
|
if not hmac.compare_digest(
|
|
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
|
|
):
|
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
|
|
|
|
|
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
|
|
"""Serialize callback check+insert by a stable, transaction-scoped key."""
|
|
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
|
|
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
|
|
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
|
|
|
|
|
|
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
|
def workflow_heartbeat(
|
|
body: N8nHeartbeatIn,
|
|
service_token: str = Header(..., alias="X-Service-Token"),
|
|
db: Session = Depends(get_db),
|
|
) -> N8nHeartbeatResult:
|
|
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
|
_require_service_token(service_token)
|
|
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
|
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
|
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
|
|
already_recorded = (
|
|
db.scalar(
|
|
select(AuditEvent.id).where(
|
|
AuditEvent.action == "n8n_workflow_heartbeat",
|
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
|
AuditEvent.after_json["status"].astext == body.status,
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
if not already_recorded:
|
|
record_audit_event(
|
|
db,
|
|
actor_type="service",
|
|
actor_label="n8n workflow heartbeat",
|
|
action="n8n_workflow_heartbeat",
|
|
entity_type="automation",
|
|
after={
|
|
"workflow_id": body.workflow_id,
|
|
"workflow_name": body.workflow_name,
|
|
"status": body.status,
|
|
},
|
|
metadata={"execution_id": body.execution_id},
|
|
)
|
|
db.commit()
|
|
return N8nHeartbeatResult(
|
|
status="already_registered" if already_recorded else "registered",
|
|
execution_id=body.execution_id,
|
|
occurred_at=datetime.now(UTC),
|
|
)
|
|
|
|
|
|
@router.post("/return-callback")
|
|
def return_callback(
|
|
body: ReturnCallbackIn,
|
|
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
|
service_token: str = Header(..., alias="X-Service-Token"),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
_require_service_token(service_token)
|
|
|
|
try:
|
|
event_id = uuid.UUID(idempotency_key)
|
|
except ValueError as exc:
|
|
raise AppError(
|
|
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
|
) from exc
|
|
|
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
|
|
if event is None:
|
|
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
|
if body.event_id != event_id:
|
|
raise AppError(
|
|
"CALLBACK_EVENT_MISMATCH",
|
|
"Callback event_id does not match Idempotency-Key.",
|
|
status_code=409,
|
|
)
|
|
try:
|
|
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise AppError(
|
|
"INVALID_EVENT_CORRELATION",
|
|
"The stored outbox event has no valid correlation ID.",
|
|
status_code=409,
|
|
) from exc
|
|
if body.correlation_id != expected_correlation_id:
|
|
raise AppError(
|
|
"CALLBACK_CORRELATION_MISMATCH",
|
|
"Callback correlation_id does not match the outbox event.",
|
|
status_code=409,
|
|
)
|
|
|
|
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
|
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
|
already_recorded = (
|
|
db.scalar(
|
|
select(AuditEvent.id).where(
|
|
AuditEvent.action == "n8n_return_followup_recorded",
|
|
AuditEvent.metadata_json["event_id"].astext == str(event_id),
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
if not already_recorded:
|
|
record_audit_event(
|
|
db,
|
|
actor_type="service",
|
|
actor_label="n8n",
|
|
action="n8n_return_followup_recorded",
|
|
entity_type="booking",
|
|
correlation_id=expected_correlation_id,
|
|
after={"follow_up": body.follow_up, "summary": body.summary},
|
|
metadata={"event_id": str(event_id)},
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "recorded",
|
|
"event_id": str(event_id),
|
|
"occurred_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
|
|
@router.post("/scheduled-scan", response_model=ScanResultOut)
|
|
def scheduled_scan(
|
|
service_token: str = Header(..., alias="X-Service-Token"),
|
|
db: Session = Depends(get_db),
|
|
) -> ScanResultOut:
|
|
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
|
|
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
|
doesn't already have one open, so a duplicate or overlapping trigger does no
|
|
duplicate domain work -- it just reports zero new issues for anything already known."""
|
|
_require_service_token(service_token)
|
|
|
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
|
return ScanResultOut(created=result.created)
|
|
|
|
|
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
|
def workflow_error(
|
|
body: WorkflowErrorReportIn,
|
|
service_token: str = Header(..., alias="X-Service-Token"),
|
|
db: Session = Depends(get_db),
|
|
) -> WorkflowErrorReportResult:
|
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
|
_require_service_token(service_token)
|
|
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
|
|
|
|
already_recorded = (
|
|
db.scalar(
|
|
select(AuditEvent.id).where(
|
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
if not already_recorded:
|
|
record_audit_event(
|
|
db,
|
|
actor_type="service",
|
|
actor_label="n8n error handler",
|
|
action="n8n_workflow_failure_registered",
|
|
entity_type="automation",
|
|
correlation_id=body.correlation_id,
|
|
after={
|
|
"workflow_id": body.workflow_id,
|
|
"workflow_name": body.workflow_name,
|
|
"error_category": body.error_category,
|
|
"error_summary": body.error_summary,
|
|
"trigger_context": body.trigger_context,
|
|
"attempt": body.attempt,
|
|
"retry_action": body.retry_action,
|
|
"failed_at": body.failed_at.isoformat(),
|
|
},
|
|
metadata={"execution_id": body.execution_id},
|
|
)
|
|
db.commit()
|
|
|
|
return WorkflowErrorReportResult(
|
|
status="already_registered" if already_recorded else "registered",
|
|
execution_id=body.execution_id,
|
|
occurred_at=datetime.now(UTC),
|
|
)
|
|
|
|
|
|
@router.get("/procedures", response_model=ProcedureListOut)
|
|
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
|
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
|
per-document id (source_id) and a content hash so the caller can detect changes
|
|
without re-fetching content it already has."""
|
|
_require_service_token(service_token)
|
|
|
|
documents = [
|
|
ProcedureDocumentOut(
|
|
id=doc.source_id,
|
|
language=doc.language,
|
|
document_id=doc.document_id,
|
|
title=doc.title,
|
|
version=doc.version,
|
|
content=doc.content,
|
|
content_hash=doc.content_hash,
|
|
)
|
|
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
|
]
|
|
return ProcedureListOut(documents=documents)
|
|
|
|
|
|
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
|
def procedures_sync_result(
|
|
body: ProcedureSyncResultIn,
|
|
service_token: str = Header(..., alias="X-Service-Token"),
|
|
db: Session = Depends(get_db),
|
|
) -> ProcedureSyncResultResult:
|
|
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
|
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
|
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
|
_require_service_token(service_token)
|
|
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
|
|
|
|
already_recorded = (
|
|
db.scalar(
|
|
select(AuditEvent.id).where(
|
|
AuditEvent.action == "n8n_procedures_synced",
|
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
if not already_recorded:
|
|
record_audit_event(
|
|
db,
|
|
actor_type="service",
|
|
actor_label="n8n procedure sync",
|
|
action="n8n_procedures_synced",
|
|
entity_type="automation",
|
|
after={"synced": body.synced, "failed": body.failed},
|
|
metadata={"execution_id": body.execution_id},
|
|
)
|
|
db.commit()
|
|
|
|
return ProcedureSyncResultResult(
|
|
status="already_registered" if already_recorded else "registered",
|
|
execution_id=body.execution_id,
|
|
occurred_at=datetime.now(UTC),
|
|
)
|