n8n: store cleaned workflow definitions as repo source of truth

Move the two live-validated workflows into n8n/workflows/ (credential-
based auth referenced by name only, no secret values), add a manifest
covering all 4 canonical workflows and a read-only drift-check script
against n8n's Public API. Retire the pre-integration root-level starter
files that still carried the literal-token pattern, and repoint the
Unraid deploy scripts, Makefile targets and runbook at the new files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-04 13:34:51 +02:00
co-authored by Claude Sonnet 5
parent 59cb4c062e
commit e0c107a94a
12 changed files with 410 additions and 165 deletions
+4 -1
View File
@@ -60,7 +60,10 @@
- `knowledge/procedures/09-booking-conflicts.md` - `knowledge/procedures/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md` - `knowledge/procedures/10-roles-and-escalation.md`
- `n8n/README.md` - `n8n/README.md`
- `n8n/mobilityops-return-processing.json` - `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py`
- `seed/README.md` - `seed/README.md`
- `seed/bookings.csv` - `seed/bookings.csv`
- `seed/customers.csv` - `seed/customers.csv`
+7 -4
View File
@@ -26,16 +26,19 @@ reset:
# One-time per environment: imports and activates the n8n return-processing workflow. # One-time per environment: imports and activates the n8n return-processing workflow.
# The n8n owner account itself cannot be scripted safely and must be created once at # The n8n owner account itself cannot be scripted safely and must be created once at
# http://localhost:5678/setup (any email/password, no verification required) before # http://localhost:5678/setup (any email/password, no verification required) before
# this target's activation takes effect. See docs/17-runbook.md. # this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
n8n-setup: n8n-setup:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n docker compose restart n8n
# One-time per environment: imports and activates the scheduled quality-scan workflow. # One-time per environment: imports and activates the scheduled quality-scan workflow.
# Same owner-account precondition as n8n-setup above. # Same owner-account and credential preconditions as n8n-setup above (this workflow only
# needs "Fleet Ops Service Token").
n8n-setup-scan: n8n-setup-scan:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n docker compose restart n8n
+84 -1
View File
@@ -993,7 +993,12 @@ below.
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
final localization" entry below for the next (small correction) round on top of this. final localization" entry below for the next (small correction) round on top of this.
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — IN PROGRESS on fix/fleet-ops-final-i18n-ux ## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — MERGED TO MASTER
Merged to master via `5f0eaa5`; final evidence commit `c0995b7` added
`artifacts/fleet-ops-final-localization/final-summary.md`. Master head at merge:
`c0995b762e1cbf37172a08e03645baa6b66aa8d5`. Details below are the in-progress working log
kept for reference.
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the (`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
@@ -1074,3 +1079,81 @@ product scope.
`a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout `a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout
evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid). evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid).
Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`. Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`.
## Live n8n + RAGcore integration (2026-08-04) — IN PROGRESS on feat/live-n8n-ragcore-integration
Branch `feat/live-n8n-ragcore-integration`, from master `c0995b7`. Full brief: treat n8n
(`https://n8n.itworx.tech`, existing shared instance) as a third integration layer
alongside RAGcore and MCP Hub, owning process orchestration only — Fleet Ops keeps all
business rules, authorization, transactions, audit and idempotency. Four canonical
workflows required: (1) Vehicle Return Orchestration, (2) Scheduled Data Quality Scan —
both pre-existing and now hardened; (3) RAGcore Procedure Sync, (4) Workflow Error
Handler — both net-new, not yet built.
- **Current-state audit**: `docs/live-ai-integration/n8n-current-state.md` documents the
live instance (reachable, production webhook base
`http://192.168.10.150:5678/webhook/mobilityops-return`), both existing workflows'
full node structure, and the findings that drove the security fixes below (webhook
Authentication was `None`; both HTTP nodes had `X-Service-Token` hardcoded as a literal
header value instead of a credential).
- **Security fixes applied and live-validated** (commits `b79d485`, `59cb4c0`): webhook
trigger now requires Header Auth (credential `Fleet Ops Webhook Trigger Token`, a new
token generated this round — value stored in `.env`/Unraid `.env` only, never
printed); the outbound callback HTTP node now uses a `Fleet Ops Service Token` Header
Auth credential instead of a literal header value (existing secret copied
clipboard-to-clipboard, never typed/echoed). Backend: `X-Fleet-Ops-Trigger-Token`
header added to the outbox dispatcher's POST (`backend/app/services/dispatcher.py`),
plus a new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting/env var. Also hardened
`_deliver_one` to treat a 2xx response with a non-JSON-object body as a retryable
failure (`malformedResponse`) instead of an unhandled exception — a real failure mode
hit live when a workflow errors before its "Respond to Webhook" node runs; regression
test `test_deliver_one_treats_empty_2xx_body_as_failure` added. Live-validated: curl
probe without the header → `403`; with the header → pass-through; one real end-to-end
vehicle return produced one correct execution visible in both n8n and Fleet Ops
Audit/Automation. Both workflows explicitly `Publish`ed after the fixes (the editor
does not go live on save alone) and both canonical-renamed ("Fleet Ops — Vehicle
Return Orchestration", "Fleet Ops — Scheduled Data Quality Scan").
- **RAGcore real contract discovered** (not the speculative one the adapter was built
against): OpenAPI at `/openapi.json`, health at `/health/live`/`/health/ready` (not
`/health`), ingestion via `POST /v1/uploads`, answers via `POST /v1/answers` with
`requested_space_ids`, control-plane endpoints require an `Idempotency-Key` header.
Bootstrapped a `fleet-ops` application + knowledge space + grant on the real server at
`http://192.168.10.150:1237`. **Blocked**: credential issuance for that application
failed identically via both the raw API and the admin UI ("authoritative
service-account state rejected issuance") — an apparent privilege boundary beyond the
interactive admin session. User chose to issue the credential themselves via another
mechanism and hand over the token; not yet received. `RAGcoreKnowledgeProvider`
(`backend/app/services/knowledge/ragcore.py`) still targets the old speculative
endpoints and needs fixing once that token arrives — approved, not started.
- **Repository source of truth started** (task in progress): `n8n/workflows/` now holds
cleaned definitions for workflows 1-2 — `fleet-ops-vehicle-return.json` (sha256
`e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e`),
`fleet-ops-data-quality-scan.json` (sha256
`cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e`) — both credential
auth referenced by name only, no secret values. Reconstructed from direct verified
inspection of every live node, **not** a literal n8n export/download: the UI's "..."
menu has no Download option in this n8n version, and clipboard-based
copy/`navigator.clipboard.readText()` extraction timed out twice. Flagged as a known
limitation for the final evidence doc. `n8n/workflows/MANIFEST.md` records canonical
name/purpose/trigger/contract/credentials/live ID/active-status/checksum for all 4
workflows (3-4 marked not-yet-built). `n8n/workflows/check_drift.py` compares a repo
definition against the live workflow via n8n's Public API (`X-N8N-API-KEY`, read-only,
never auto-overwrites). The old root-level `n8n/mobilityops-return-processing.json`
and `n8n/mobilityops-scheduled-quality-scan.json` (pre-integration starters, still
carrying the literal-token pattern) are removed; `deploy/unraid/setup-existing-n8n.sh`,
`setup-scheduled-scan.sh`, `Makefile` (`n8n-setup`, `n8n-setup-scan`) and
`docs/17-runbook.md` updated to import from `n8n/workflows/` and to document the
now-required manual credential-creation step (credentials are never scripted or
committed).
- **Explicitly deferred/forbidden this phase** (per brief): daily AI ops brief, email,
Slack, automatic vehicle-status changes, customer communication, billing, general
monitoring, autonomous MCP actions. An automatic demo-reset workflow may only be
prepared, not activated, once Fleet Ops goes public.
- **Exact next action**: build workflow 4 (Workflow Error Handler) — does not depend on
the blocked RAGcore token, so it proceeds now. Workflow 3 (RAGcore Procedure Sync) and
the `RAGcoreKnowledgeProvider` adapter fix stay blocked until the user supplies a
working RAGcore application credential/token. After both new workflows exist: update
the Fleet Ops integration status page (task #88), run the full 4-workflow
testing/acceptance pass (task #90), and write
`artifacts/live-ai-integration/final-summary.md` (task #91, explicitly: no credentials
or secret values in that file).
+14 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}" callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}" source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "Missing deployment .env" >&2 echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)" # The workflow file no longer carries the callback token as a literal header value -- both
if [ -z "$callback_token" ]; then # the webhook trigger and the outbound callback authenticate via named n8n Header Auth
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2 # credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
exit 1 # exist in the target n8n instance before this workflow is activated; see the echo below.
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)" temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json" container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() { cleanup() {
@@ -32,15 +30,17 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" ' jq --arg callback_url "$callback_url" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url | (.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow" ' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow" docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}" echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
echo "Before activating: in the n8n UI, create Header Auth credentials named"
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "those secret values, and does not restart the container -- restart it yourself once"
echo "credentials are wired up and the workflow is published, if required."
+13 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}" scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}" source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "Missing deployment .env" >&2 echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)" # The workflow file no longer carries the callback token as a literal header value -- the
if [ -z "$callback_token" ]; then # scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2 # Token"), which must exist in the target n8n instance before this workflow is activated;
exit 1 # see the echo below.
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)" temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json" container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
cleanup() { cleanup() {
@@ -32,15 +30,16 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" ' jq --arg scan_url "$scan_url" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url | (.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow" ' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow" docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker restart "$container_name" >/dev/null
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}" echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
echo "Before activating: in the n8n UI, create a Header Auth credential named"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "that secret value, and does not restart the container -- restart it yourself once"
echo "the credential is wired up and the workflow is published, if required."
+15 -15
View File
@@ -16,7 +16,7 @@ Steps:
4. return a stable workflow result; 4. return a stable workflow result;
5. on errors, fail visibly so the outbox dispatcher can retry. 5. on errors, fail visibly so the outbox dispatcher can retry.
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency. The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
## Second live workflow: scheduled quality scan ## Second live workflow: scheduled quality scan
@@ -39,24 +39,24 @@ Steps:
open, so a duplicate or overlapping trigger (a manual test run firing close to the open, so a duplicate or overlapping trigger (a manual test run firing close to the
scheduled one, or a retried HTTP call) does no duplicate domain work. scheduled one, or a retried HTTP call) does no duplicate domain work.
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
published the same way as the return-processing workflow (see (see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
`"active": false` so it cannot fire against any environment until deliberately active on the live instance; a fresh import ships inactive until credentials are wired up
published with a real service token. and it is deliberately published.
## Deferred: knowledge sync ## RAGcore procedure sync (in progress)
Input: manual trigger or manifest-changed event. RAGcore is now reachable in this environment; a live inspection of its real contract is
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
RAGcore Procedure Sync", is being built against that real contract (not the sketch
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
Steps: ## Workflow error handler (in progress)
1. read the fixed knowledge manifest; Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
2. call RAGcore ingestion/sync API; to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
3. record per-document results through MobilityOps integration status API. secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
Deferred until RAGcore's live ingestion API is available in this environment; must not
delay or block the core demo.
## Outbox dispatcher ## Outbox dispatcher
+11 -2
View File
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
which runs: which runs:
```bash ```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n docker compose restart n8n
``` ```
@@ -56,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
(`n8n import:workflow` always leaves the workflow deactivated regardless of its (`n8n import:workflow` always leaves the workflow deactivated regardless of its
`"active"` field; `publish:workflow` + a restart is what actually activates it.) `"active"` field; `publish:workflow` + a restart is what actually activates it.)
Before it will actually process a return, create two Header Auth credentials in the n8n
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
the workflow's webhook trigger and outbound HTTP call reference these credentials by
name; no secret value is embedded in the workflow file itself.
Verify the full round trip: Verify the full round trip:
```bash ```bash
@@ -77,11 +83,14 @@ make n8n-setup-scan
which runs: which runs:
```bash ```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n docker compose restart n8n
``` ```
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
the n8n UI before a run will succeed.
Verify: Verify:
```bash ```bash
+15 -9
View File
@@ -1,13 +1,19 @@
# n8n workflows # n8n workflows
`mobilityops-return-processing.json` is a starter import for the required live workflow. Canonical, live-validated workflow definitions live under `n8n/workflows/`. Each file is a
cleaned export (secret values replaced by named-credential references, never literal
tokens) of the workflow actually running on `https://n8n.itworx.tech`. See
`n8n/workflows/MANIFEST.md` for the authoritative list: canonical name, purpose, trigger,
required credentials, live workflow ID, active status and a checksum of each file.
Before final validation Claude must: The two pre-integration starter files that used to live directly under `n8n/`
(`mobilityops-return-processing.json`, `mobilityops-scheduled-quality-scan.json`) have been
retired — they predate the live n8n validation pass and embedded the service token as a
literal header value instead of a Header Auth credential. Do not resurrect them; the
`n8n/workflows/` versions are the superseding source of truth and are what
`deploy/unraid/setup-existing-n8n.sh` / `setup-scheduled-scan.sh` import.
1. import or provision the workflow; Deploying a workflow from these files into a fresh or existing n8n instance still requires a
2. configure a service credential for the MobilityOps callback; one-time manual step: create the named Header Auth credentials in the n8n UI (see each
3. activate the webhook; workflow's `credentials` block and the manifest) before publishing. This is deliberate —
4. ensure event ID is used as the idempotency key; credential values are never committed to the repository.
5. validate online success and offline retry behaviour.
The callback URL in the starter file is intentionally environment-driven and may require an n8n expression or credential adjustment.
+59
View File
@@ -0,0 +1,59 @@
# n8n workflow manifest
Source of truth for the four canonical Fleet Ops n8n workflows. Definitions in this
directory are cleaned exports of the live workflows on `https://n8n.itworx.tech`
credential values are never embedded; nodes reference named n8n credentials instead. Run
`n8n/workflows/check_drift.py` to compare a live workflow against its repo definition.
## 1. Fleet Ops — Vehicle Return Orchestration
| Field | Value |
|---|---|
| File | `fleet-ops-vehicle-return.json` |
| Purpose | Orchestrate the post-return follow-up (cleaning vs. attention-required) once Fleet Ops emits a `vehicle.returned.v1` outbox event, and report the result back to Fleet Ops. |
| Trigger | Production webhook, `POST /webhook/mobilityops-return`, Header Auth (`Fleet Ops Webhook Trigger Token`) |
| Event contract | `contracts/events.schema.json`, `event_type: vehicle.returned.v1` (envelope: `event_id`, `event_type`, `occurred_at`, `correlation_id`, `aggregate`, `data`) |
| Required credentials | `Fleet Ops Webhook Trigger Token` (Header Auth, on the trigger); `Fleet Ops Service Token` (Header Auth, on the outbound HTTP call) |
| Live workflow ID | `mobilityops-return-processing` |
| Active status (as of 2026-08-04) | Active / Published |
| Checksum (sha256) | `e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e` |
## 2. Fleet Ops — Scheduled Data Quality Scan
| Field | Value |
|---|---|
| File | `fleet-ops-data-quality-scan.json` |
| Purpose | Periodically (and on-demand) run the Fleet Ops data-quality scan and summarize created-issue counts per rule. |
| Trigger | Schedule Trigger (hourly, Europe/Brussels instance timezone) + Manual Trigger for on-demand test runs |
| Event contract | N/A — HTTP-triggered scan call, no inbound event envelope. Request: `POST /api/v1/integrations/n8n/scheduled-scan`, Header Auth. |
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the scan HTTP call) |
| Live workflow ID | `mobilityops-scheduled-quality-scan` |
| Active status (as of 2026-08-04) | Active / Published |
| Checksum (sha256) | `cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e` |
## 3. Fleet Ops — RAGcore Procedure Sync
Not yet built. Blocked on a RAGcore application credential (scope `sources:sync`) for the
`fleet-ops` application, to be provided by the project owner. Will sync
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` against the real RAGcore contract
(`POST /v1/uploads`, `GET /v1/knowledge-spaces`, etc. — see
`contracts/ragcore-contract-assumptions.md` and the live inspection notes in
`docs/live-ai-integration/n8n-current-state.md`).
| Field | Value |
|---|---|
| File | `fleet-ops-ragcore-procedure-sync.json` (not yet created) |
| Live workflow ID | — |
| Active status | Not built |
## 4. Fleet Ops — Workflow Error Handler
Not yet built. Central error workflow to be attached to workflows 1-3 via n8n's
per-workflow "Error Workflow" setting. Reports bounded, secret-free failure details to a
new Fleet Ops automation-failure endpoint.
| Field | Value |
|---|---|
| File | `fleet-ops-error-handler.json` (not yet created) |
| Live workflow ID | — |
| Active status | Not built |
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Report drift between the repo's cleaned workflow definitions and the live n8n instance.
Read-only: this script only issues GET requests against n8n's Public API. It never writes,
imports, activates, or otherwise modifies anything in n8n -- fixing drift is a deliberate,
reviewed action a human takes in the n8n UI (or via a separate, explicit import step), not
something this script does automatically.
Usage:
N8N_BASE_URL=https://n8n.itworx.tech N8N_API_KEY=... python n8n/workflows/check_drift.py
N8N_API_KEY must be an n8n Public API key (n8n UI -> Settings -> API), not a session cookie
and not a workflow credential. It is read from the environment only and is never printed.
Exit code is 0 when every checked workflow matches the live instance, 1 when any drift (or
a fetch error) is found, so this is safe to wire into CI as a non-blocking check.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
WORKFLOWS_DIR = Path(__file__).parent
# (repo file name, live workflow ID) -- kept in sync with MANIFEST.md by hand, since the
# manifest is the human-readable source of truth and this is just its machine-checkable echo.
KNOWN_WORKFLOWS = [
("fleet-ops-vehicle-return.json", "mobilityops-return-processing"),
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
]
# Fields that legitimately differ between a committed definition and the live instance
# (instance-assigned identifiers, timestamps, UI-only cosmetics) and must not be reported
# as drift.
_VOLATILE_TOP_LEVEL_KEYS = {
"versionId",
"createdAt",
"updatedAt",
"pinData",
"staticData",
"shared",
"triggerCount",
"isArchived",
}
_VOLATILE_NODE_KEYS = {"position", "webhookId"}
def _strip_credential_ids(value: Any) -> Any:
"""Live workflows carry instance-specific credential IDs alongside the credential name
(e.g. {"id": "3", "name": "Fleet Ops Service Token"}). The repo definitions intentionally
omit the id, since it's meaningless outside the instance that issued it. Drop it from both
sides so credential *references* are compared by name only."""
if isinstance(value, dict):
if set(value.keys()) <= {"id", "name"} and "name" in value:
return {"name": value["name"]}
return {k: _strip_credential_ids(v) for k, v in value.items()}
if isinstance(value, list):
return [_strip_credential_ids(v) for v in value]
return value
def _normalize_node(node: dict[str, Any]) -> dict[str, Any]:
cleaned = {k: v for k, v in node.items() if k not in _VOLATILE_NODE_KEYS}
return _strip_credential_ids(cleaned)
def _normalize_workflow(doc: dict[str, Any]) -> dict[str, Any]:
nodes_by_name = {n["name"]: _normalize_node(n) for n in doc.get("nodes", [])}
return {
"name": doc.get("name"),
"active": doc.get("active"),
"nodes": nodes_by_name,
"connections": doc.get("connections", {}),
"settings": doc.get("settings", {}),
}
def _diff(path: str, local: Any, live: Any, out: list[str]) -> None:
if isinstance(local, dict) and isinstance(live, dict):
for key in sorted(set(local) | set(live)):
if key in _VOLATILE_TOP_LEVEL_KEYS:
continue
if key not in live:
out.append(f"{path}.{key}: present in repo, missing live")
elif key not in local:
out.append(f"{path}.{key}: present live, missing in repo")
else:
_diff(f"{path}.{key}", local[key], live[key], out)
elif local != live:
out.append(f"{path}: repo={local!r} live={live!r}")
def fetch_live_workflow(base_url: str, api_key: str, workflow_id: str) -> dict[str, Any]:
url = f"{base_url.rstrip('/')}/api/v1/workflows/{workflow_id}"
request = urllib.request.Request(url, headers={"X-N8N-API-KEY": api_key, "Accept": "application/json"})
with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 (fixed https base url from env)
return json.load(response)
def main() -> int:
base_url = os.environ.get("N8N_BASE_URL")
api_key = os.environ.get("N8N_API_KEY")
if not base_url or not api_key:
print(
"Set N8N_BASE_URL and N8N_API_KEY (an n8n Public API key) in the environment.",
file=sys.stderr,
)
return 1
any_drift = False
for filename, workflow_id in KNOWN_WORKFLOWS:
repo_path = WORKFLOWS_DIR / filename
local_doc = json.loads(repo_path.read_text(encoding="utf-8"))
try:
live_doc = fetch_live_workflow(base_url, api_key, workflow_id)
except urllib.error.HTTPError as exc:
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: HTTP {exc.code}")
any_drift = True
continue
except urllib.error.URLError as exc:
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: {exc.reason}")
any_drift = True
continue
differences: list[str] = []
_diff(filename, _normalize_workflow(local_doc), _normalize_workflow(live_doc), differences)
if differences:
any_drift = True
print(f"[{filename}] DRIFT from live workflow {workflow_id}:")
for line in differences:
print(f" - {line}")
else:
print(f"[{filename}] matches live workflow {workflow_id}")
return 1 if any_drift else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,6 +1,6 @@
{ {
"id": "mobilityops-scheduled-quality-scan", "id": "mobilityops-scheduled-quality-scan",
"name": "MobilityOps - Scheduled Quality Scan", "name": "Fleet Ops Scheduled Data Quality Scan",
"nodes": [ "nodes": [
{ {
"parameters": { "parameters": {
@@ -17,10 +17,7 @@
"name": "Hourly schedule", "name": "Hourly schedule",
"type": "n8n-nodes-base.scheduleTrigger", "type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2, "typeVersion": 1.2,
"position": [ "position": [240, 220]
240,
220
]
}, },
{ {
"parameters": {}, "parameters": {},
@@ -28,23 +25,17 @@
"name": "Manual test trigger", "name": "Manual test trigger",
"type": "n8n-nodes-base.manualTrigger", "type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1, "typeVersion": 1,
"position": [ "position": [240, 400]
240,
400
]
}, },
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "={{$env.MOBILITYOPS_API_URL || 'http://api:8000'}}/api/v1/integrations/n8n/scheduled-scan", "url": "http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true, "sendHeaders": true,
"headerParameters": { "headerParameters": {
"parameters": [ "parameters": []
{
"name": "X-Service-Token",
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
}
]
}, },
"options": { "options": {
"timeout": 15000 "timeout": 15000
@@ -54,10 +45,12 @@
"name": "Run quality scan", "name": "Run quality scan",
"type": "n8n-nodes-base.httpRequest", "type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "typeVersion": 4.2,
"position": [ "position": [520, 300],
520, "credentials": {
300 "httpHeaderAuth": {
] "name": "Fleet Ops Service Token"
}
}
}, },
{ {
"parameters": { "parameters": {
@@ -67,52 +60,24 @@
"name": "Summarize result", "name": "Summarize result",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [ "position": [780, 300]
780,
300
]
} }
], ],
"connections": { "connections": {
"Hourly schedule": { "Hourly schedule": {
"main": [ "main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
[
{
"node": "Run quality scan",
"type": "main",
"index": 0
}
]
]
}, },
"Manual test trigger": { "Manual test trigger": {
"main": [ "main": [[{ "node": "Run quality scan", "type": "main", "index": 0 }]]
[
{
"node": "Run quality scan",
"type": "main",
"index": 0
}
]
]
}, },
"Run quality scan": { "Run quality scan": {
"main": [ "main": [[{ "node": "Summarize result", "type": "main", "index": 0 }]]
[
{
"node": "Summarize result",
"type": "main",
"index": 0
}
]
]
} }
}, },
"settings": { "settings": {
"executionOrder": "v1" "executionOrder": "v1"
}, },
"active": false, "active": true,
"versionId": "22222222-2222-4222-8222-222222222222",
"meta": { "meta": {
"templateCredsSetupCompleted": false "templateCredsSetupCompleted": false
}, },
@@ -1,11 +1,12 @@
{ {
"id": "mobilityops-return-processing", "id": "mobilityops-return-processing",
"name": "MobilityOps - Vehicle Return Processing", "name": "Fleet Ops Vehicle Return Orchestration",
"nodes": [ "nodes": [
{ {
"parameters": { "parameters": {
"httpMethod": "POST", "httpMethod": "POST",
"path": "mobilityops-return", "path": "mobilityops-return",
"authentication": "headerAuth",
"responseMode": "responseNode", "responseMode": "responseNode",
"options": {} "options": {}
}, },
@@ -13,11 +14,13 @@
"name": "Return webhook", "name": "Return webhook",
"type": "n8n-nodes-base.webhook", "type": "n8n-nodes-base.webhook",
"typeVersion": 2, "typeVersion": 2,
"position": [ "position": [240, 300],
240, "webhookId": "mobilityops-return",
300 "credentials": {
], "httpHeaderAuth": {
"webhookId": "mobilityops-return" "name": "Fleet Ops Webhook Trigger Token"
}
}
}, },
{ {
"parameters": { "parameters": {
@@ -27,25 +30,20 @@
"name": "Validate and derive follow-up", "name": "Validate and derive follow-up",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [ "position": [500, 300]
500,
300
]
}, },
{ {
"parameters": { "parameters": {
"method": "POST", "method": "POST",
"url": "={{$env.MOBILITYOPS_CALLBACK_URL || 'http://api:8000/api/v1/integrations/n8n/return-callback'}}", "url": "http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true, "sendHeaders": true,
"headerParameters": { "headerParameters": {
"parameters": [ "parameters": [
{ {
"name": "Idempotency-Key", "name": "Idempotency-Key",
"value": "={{$json.event_id}}" "value": "={{$json.event_id}}"
},
{
"name": "X-Service-Token",
"value": "={{$env.MOBILITYOPS_CALLBACK_TOKEN}}"
} }
] ]
}, },
@@ -59,10 +57,12 @@
"name": "Record follow-up", "name": "Record follow-up",
"type": "n8n-nodes-base.httpRequest", "type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "typeVersion": 4.2,
"position": [ "position": [760, 300],
760, "credentials": {
300 "httpHeaderAuth": {
] "name": "Fleet Ops Service Token"
}
}
}, },
{ {
"parameters": { "parameters": {
@@ -74,54 +74,26 @@
"name": "Return result", "name": "Return result",
"type": "n8n-nodes-base.respondToWebhook", "type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4, "typeVersion": 1.4,
"position": [ "position": [1020, 300]
1020,
300
]
} }
], ],
"connections": { "connections": {
"Return webhook": { "Return webhook": {
"main": [ "main": [[{ "node": "Validate and derive follow-up", "type": "main", "index": 0 }]]
[
{
"node": "Validate and derive follow-up",
"type": "main",
"index": 0
}
]
]
}, },
"Validate and derive follow-up": { "Validate and derive follow-up": {
"main": [ "main": [[{ "node": "Record follow-up", "type": "main", "index": 0 }]]
[
{
"node": "Record follow-up",
"type": "main",
"index": 0
}
]
]
}, },
"Record follow-up": { "Record follow-up": {
"main": [ "main": [[{ "node": "Return result", "type": "main", "index": 0 }]]
[
{
"node": "Return result",
"type": "main",
"index": 0
}
]
]
} }
}, },
"settings": { "settings": {
"executionOrder": "v1" "executionOrder": "v1"
}, },
"active": true, "active": true,
"versionId": "11111111-1111-4111-8111-111111111111",
"meta": { "meta": {
"templateCredsSetupCompleted": false "templateCredsSetupCompleted": false
}, },
"tags": [] "tags": []
} }