Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66521da82 | ||
|
|
0571a40649 | ||
|
|
4227fe4f58 | ||
|
|
c790ec99cb | ||
|
|
fd390df423 | ||
|
|
e5d8466266 | ||
|
|
0da5251524 | ||
|
|
2afceea5e4 | ||
|
|
cf4d8e3649 | ||
|
|
aaa1630535 | ||
|
|
167bf49b6e | ||
|
|
fd0c55b13b | ||
|
|
05628936ca | ||
|
|
b341436e77 | ||
|
|
4049c0c6b1 | ||
|
|
e39c0a1dd6 | ||
|
|
bbdb4a9ae8 | ||
|
|
e0c107a94a | ||
|
|
59cb4c062e | ||
|
|
b79d485ef1 | ||
|
|
c0995b762e | ||
|
|
5f0eaa59b0 |
@@ -29,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
|
|||||||
N8N_BASIC_AUTH_USER=admin
|
N8N_BASIC_AUTH_USER=admin
|
||||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||||
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
||||||
|
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
|
||||||
|
# processing webhook, so the webhook trigger can require Header Auth instead of being
|
||||||
|
# publicly callable by anyone who discovers the URL. Must match the value stored in
|
||||||
|
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
|
||||||
|
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||||
|
|
||||||
# RAGcore integration
|
# RAGcore integration
|
||||||
KNOWLEDGE_PROVIDER=demo
|
KNOWLEDGE_PROVIDER=demo
|
||||||
@@ -37,6 +42,8 @@ RAGCORE_TENANT=northstar-mobility-demo
|
|||||||
RAGCORE_WORKSPACE=mobilityops
|
RAGCORE_WORKSPACE=mobilityops
|
||||||
RAGCORE_COLLECTION=internal-procedures
|
RAGCORE_COLLECTION=internal-procedures
|
||||||
RAGCORE_API_TOKEN=
|
RAGCORE_API_TOKEN=
|
||||||
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
|
RAGCORE_SPACE_ID=
|
||||||
|
|
||||||
# ITWorx MCP Hub integration
|
# ITWorx MCP Hub integration
|
||||||
MCP_HUB_REGISTRATION_ENABLED=false
|
MCP_HUB_REGISTRATION_ENABLED=false
|
||||||
|
|||||||
+4
-1
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+625
-1
@@ -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,622 @@ 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.
|
||||||
|
- **Workflow 4 (Workflow Error Handler) built and live-validated** (commit pending):
|
||||||
|
new backend endpoint `POST /api/v1/integrations/n8n/workflow-error`
|
||||||
|
(`backend/app/api/routers/integrations.py`, service-token auth, Pydantic
|
||||||
|
`WorkflowErrorReportIn`/`WorkflowErrorReportResult` in `backend/app/schemas.py`),
|
||||||
|
idempotent on `execution_id` via the same audit-precheck pattern as
|
||||||
|
`/return-callback`; new test coverage in `backend/tests/test_integrations.py` (all
|
||||||
|
green, 152 tests total, ruff/mypy clean). **This endpoint had to be deployed to the
|
||||||
|
live Unraid server** (`git archive` → `scp` → extract preserving `.env` → `docker
|
||||||
|
compose up --build -d api`, no migration needed) before the live n8n test could reach
|
||||||
|
it — the auto-mode classifier correctly blocked the first `scp` attempt as a
|
||||||
|
production-infra action; user approved, then it was deployed and verified
|
||||||
|
(`/health` OK, new endpoint returns 422 on empty body instead of 404).
|
||||||
|
Built "Fleet Ops — Workflow Error Handler" (live ID `Xppn2rAEqUuyiCJF`) in n8n:
|
||||||
|
Error Trigger → Code node (derives safe error_category/summary/etc. from n8n's error
|
||||||
|
payload) → HTTP node (POST to the new endpoint, Header Auth via the existing "Fleet
|
||||||
|
Ops Service Token" credential). Hit and fixed two real bugs during live testing: (1)
|
||||||
|
Code node's default "Run Once for All Items" mode doesn't bind `$json` to the current
|
||||||
|
item — switched to "Run Once for Each Item" and `return {json:...}` instead of
|
||||||
|
`return [{json:...}]`; (2) every HTTP-body field expression ended up with a stray
|
||||||
|
trailing space (from the code-editor's bracket-autoclose leaving one extra character
|
||||||
|
after the `End`+`Backspace×2` fix), which broke the `failed_at` datetime parse and the
|
||||||
|
`error_category` literal match — found via the raw request dump in n8n's error
|
||||||
|
panel, fixed with one more `Backspace` per field. Live-validated: mock Error Trigger
|
||||||
|
data → real `200 {"status":"registered"}` from Fleet Ops; re-run → `"already_registered"`
|
||||||
|
(idempotency confirmed); wired as the Error Workflow on workflows 1 and 2 (via each
|
||||||
|
workflow's Settings modal); confirmed the Error Handler itself has `Error Workflow: -
|
||||||
|
No Workflow -` (no recursive loop). With user approval, also ran a genuine induced
|
||||||
|
failure on workflow 2 (temporarily pointed its HTTP node at a nonexistent path,
|
||||||
|
published, ran it, confirmed it failed as expected, immediately reverted and
|
||||||
|
republished, confirmed healthy again) — this proved the target workflow's own error
|
||||||
|
path works, but n8n did not auto-invoke the Error Handler for that *manual* editor
|
||||||
|
test run (n8n's Error Workflow trigger only fires for unattended/production
|
||||||
|
executions), so a fully automatic schedule/webhook-triggered cascade into the handler
|
||||||
|
was not observed live this round — noted as a known limitation.
|
||||||
|
Exported the verified definition to `n8n/workflows/fleet-ops-error-handler.json` (same
|
||||||
|
manual-reconstruction caveat as workflows 1-2: no literal export/download available),
|
||||||
|
updated `n8n/workflows/MANIFEST.md` (all 4 workflows, workflow 3 still not-built) and
|
||||||
|
`check_drift.py`'s known-workflows list.
|
||||||
|
- **Integration status page enriched with real per-workflow evidence** (commit
|
||||||
|
`4049c0c`): `N8nIntegrationStatus` now returns `workflows: N8nWorkflowEvidence[]`
|
||||||
|
(the 4 canonical workflows, each with real evidence — latest successful outbox
|
||||||
|
delivery for the return workflow, latest *service*-triggered `data_quality_scan_run`
|
||||||
|
audit event for the scan workflow so a manual UI-triggered scan doesn't fake n8n
|
||||||
|
evidence, latest `n8n_workflow_failure_registered` for the error handler, always
|
||||||
|
`built: false` / no evidence for the not-yet-built RAGcore sync), plus
|
||||||
|
`expected_workflow_count`/`known_workflow_count` and an `error_handler` summary
|
||||||
|
(total registered, latest failure + which workflow). New tests in
|
||||||
|
`backend/tests/test_integration_status.py` (all green, 159 backend tests total,
|
||||||
|
ruff/mypy clean). Frontend: `Automation.tsx` renders this as a localized workflow
|
||||||
|
table (EN/NL/FR, new `integrations:workflows.*` keys, technical workflow names under
|
||||||
|
a "Technical details" disclosure per the existing progressive-disclosure pattern).
|
||||||
|
Verified live in the browser both locally (Dutch locale, disclosure expand/collapse
|
||||||
|
confirmed) and **on the deployed Unraid server after this round's deploy**: correctly
|
||||||
|
shows "3 van 4 canonieke n8n-workflows hebben actuele evidentie van werking" with real
|
||||||
|
timestamps for the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the
|
||||||
|
RAGcore sync, and the real error-handler registration from this session's live
|
||||||
|
testing. Deployed to Unraid (commit `4049c0c6b12fef3d948cd31f21119044143320d8`,
|
||||||
|
rebuilt both `api` and `web`, `/health` OK) — user re-approved this second deploy
|
||||||
|
separately from the first.
|
||||||
|
- **Operational lesson learned this round**: `docker compose run --rm api pytest` does
|
||||||
|
**not** reliably pick up source edits without an explicit `docker compose build api`
|
||||||
|
first — a test file edit silently kept running against the stale built image (test
|
||||||
|
count didn't change) until rebuilt. Always `docker compose build api` (and `web` for
|
||||||
|
frontend changes) before trusting a green result after backend/frontend edits in this
|
||||||
|
repo.
|
||||||
|
- **WF1 acceptance gap fixed and live**: `check_drift.py`-style re-inspection of workflow
|
||||||
|
1 during this round's acceptance pass found the `Record follow-up` HTTP node had no
|
||||||
|
explicit timeout and "Retry On Fail" disabled — a real gap against the brief's
|
||||||
|
timeouts/bounded-retries requirement (WF2 already had this). Fixed live: Retry On Fail
|
||||||
|
(3 tries, 1000ms wait) + a 15000ms Timeout option, published (version note "Add bounded
|
||||||
|
retries (3x) and a 15s timeout to the Fleet Ops callback call"). Repo definition and
|
||||||
|
manifest checksum synced (`n8n/workflows/fleet-ops-vehicle-return.json`,
|
||||||
|
`MANIFEST.md`, new checksum `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609`,
|
||||||
|
commit `0562893`).
|
||||||
|
- **RAGcore credential issuance re-attempted and still blocked (user explicitly
|
||||||
|
authorized Claude to self-issue this round)**: tried the RAGcore admin UI's "Issue
|
||||||
|
credential" form for the `fleet-ops` application (logged in as Platform Admin, the
|
||||||
|
highest visible role) with name `n8n-ragcore-procedure-sync` and scope `sources:sync`
|
||||||
|
only. Submission failed with the same generic "Something went wrong. The credential
|
||||||
|
could not be issued with those values." page, this time carrying a trace reference
|
||||||
|
`1955c6a8968c4941a22a1faef39e17a7`. Inspected the RAGcore OpenAPI spec for this admin
|
||||||
|
endpoint (`POST /admin/control/applications/{application_id}/credentials`) — no
|
||||||
|
documented validation constraint explains the rejection (no 422, no field errors); the
|
||||||
|
`fleet-ops` application itself lists as ordinary/`Active` with no visible lock flag in
|
||||||
|
the applications table. This is the same failure signature as the earlier raw-API
|
||||||
|
attempt (400 "authoritative service-account state rejected issuance"): two independent
|
||||||
|
paths (raw API, and now the admin UI as the top admin role) both hit an opaque
|
||||||
|
server-side rejection with a trace ID. This is conclusive evidence the block is a
|
||||||
|
deliberate RAGcore-side policy or a RAGcore-side bug, not a Fleet Ops permission or
|
||||||
|
request-shape problem — nothing further is fixable from the Fleet Ops side or through
|
||||||
|
browser automation. Whoever operates the RAGcore instance needs to look up trace
|
||||||
|
`1955c6a8968c4941a22a1faef39e17a7` (and the earlier API rejection) in RAGcore's own
|
||||||
|
logs to find the real cause.
|
||||||
|
- **WF2 acceptance gap fixed and live**: continuing the acceptance pass to WF2 found it
|
||||||
|
had the *same* Retry On Fail gap as WF1 (its 15s timeout was already set, but retries
|
||||||
|
were off — the earlier note that "WF2 already had this" was wrong on the retry half).
|
||||||
|
Fixed live the same way (3 tries, 1000ms wait), published (version note "Add bounded
|
||||||
|
retries (3x) to the quality-scan HTTP call"). Repo definition and manifest checksum
|
||||||
|
synced (`n8n/workflows/fleet-ops-data-quality-scan.json`, `MANIFEST.md`, new checksum
|
||||||
|
`c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a`, commit `167bf49`).
|
||||||
|
- **WF4 has the same gap on its own outbound call, but is currently un-fixable**: WF4's
|
||||||
|
"Report failure to Fleet Ops" HTTP node also has no timeout and no Retry On Fail. Began
|
||||||
|
the same fix (added a 15000ms Timeout option, toggled Retry On Fail on) but n8n's
|
||||||
|
autosave started failing with "Unauthorized" mid-edit, and a fresh tab confirmed the
|
||||||
|
n8n browser session had expired (redirected to `/signin`) — so nothing was saved and
|
||||||
|
the live WF4 definition is unchanged from before this round (no partial/broken state).
|
||||||
|
This is a minor, best-effort-only gap (WF4 is the error notifier itself, not a primary
|
||||||
|
business flow, and it already reports failures with `On Error: Stop Workflow` so a
|
||||||
|
failed error-report is visible in n8n's own execution history even without retries) —
|
||||||
|
not blocking, but worth finishing once someone re-authenticates the n8n browser
|
||||||
|
session.
|
||||||
|
- **RAGcore credential-issuance blocker root-caused and fixed (in RAGcore itself, with
|
||||||
|
explicit owner approval)**: with read access to the sibling `C:\Projects\RAGcore`
|
||||||
|
checkout, traced "authoritative service-account state rejected issuance" to a genuine
|
||||||
|
cross-transaction race in RAGcore's own dependency injection
|
||||||
|
(`src/ragcore/api/v1/control/dependencies.py`). `get_control_application` and
|
||||||
|
`get_credential_service` each independently opened their own `factory.begin()`
|
||||||
|
database transaction. Issuing a credential for a brand-new service account does, in one
|
||||||
|
request: (1) INSERT the service account via the first dependency's transaction, then
|
||||||
|
(2) immediately re-read it via the second dependency's *separate, uncommitted* transaction
|
||||||
|
— invisible under READ COMMITTED isolation until the first transaction commits, which
|
||||||
|
only happens after the endpoint returns. This made every fresh-service-account credential
|
||||||
|
issuance fail, 100% of the time, via both the raw API and the admin UI (explaining the
|
||||||
|
identical failure signature on both paths). RAGcore's own tests never caught this because
|
||||||
|
they override these dependencies with an in-memory fake that ignores transaction boundaries
|
||||||
|
entirely. Fixed by introducing one shared, cached `get_control_session` dependency that
|
||||||
|
both providers now depend on via `Depends(...)`, so they share one transaction per request.
|
||||||
|
Verified: RAGcore's own test suite (64 tests across `tests/web`, `tests/contract/api/control`,
|
||||||
|
`tests/security/identity`, `tests/unit/domain/control`, `tests/api`) passes, ruff and mypy
|
||||||
|
clean. Deployed to the live RAGcore instance (also on the Unraid host, `ragcore-app-1` on
|
||||||
|
port 1237 — a shared service also used by other ITWorx projects) via `docker compose build`
|
||||||
|
+ `up -d`, with explicit owner approval before both the code change and the deploy.
|
||||||
|
Confirmed fixed live: issuing a credential for `fleet-ops` (name
|
||||||
|
`n8n-ragcore-procedure-sync`, scope `sources:sync`) now succeeds (prefix `rc_sa_6fc51e`).
|
||||||
|
The plaintext token was never printed/logged — copied via RAGcore's own "Copy" button and
|
||||||
|
pasted directly into a new n8n Header Auth credential named **"RAGcore Sync Token"**
|
||||||
|
(header `Authorization: Bearer <token>`), ready for workflow 3.
|
||||||
|
- **Exact next action (superseded by the entry below)**: task #86 (build workflow 3,
|
||||||
|
RAGcore Procedure Sync) and the `RAGcoreKnowledgeProvider` adapter rewrite (to the real
|
||||||
|
inspected contract — `/health/live`, `/health/ready`, `POST /v1/uploads`, `POST
|
||||||
|
/v1/search`/`/v1/context`/`/v1/answers`) are now unblocked — the "RAGcore Sync Token" n8n
|
||||||
|
credential exists and works. WF4's own timeout/retry gap is still open pending n8n browser
|
||||||
|
re-authentication (minor, non-blocking, see above). Both #90 and #91 should be revisited
|
||||||
|
once workflow 3 is actually built, since they currently document it as blocked.
|
||||||
|
|
||||||
|
- **Second RAGcore bug found, fixed and deployed (with explicit owner approval, same pattern
|
||||||
|
as the transaction-race fix above)**: with the "RAGcore Sync Token" credential in hand,
|
||||||
|
the n8n "Upload to RAGcore" node still returned a persistent 401 on every item. Traced via
|
||||||
|
direct RAGcore source inspection (`C:\Projects\RAGcore`) to a genuine second, independent
|
||||||
|
gap: no code path in RAGcore converted an incoming `Authorization: Bearer <token>` header
|
||||||
|
into a `request.state.principal` for any `/v1/*` route — only browser session cookies were
|
||||||
|
ever accepted, even though the credential-verification logic
|
||||||
|
(`ServiceAccountCredentialService.verify()`) existed and was unit-tested. This blocks any
|
||||||
|
machine caller (n8n, and eventually Fleet Ops's own `RAGcoreKnowledgeProvider` adapter)
|
||||||
|
from ever authenticating to `/v1/uploads`. Fixed additively, scoped to `/v1/uploads` only
|
||||||
|
per owner instruction (search/context/answers left for later): new
|
||||||
|
`CredentialRepository.get_by_id()` (Postgres + in-memory), new
|
||||||
|
`ServiceAccountCredentialService.authenticate()` (parallel to the existing `verify()`, not
|
||||||
|
a refactor of it), and a new `get_upload_principal` FastAPI dependency
|
||||||
|
(`src/ragcore/api/v1/uploads/dependencies.py`) that falls back to the Bearer header when
|
||||||
|
there is no session principal, wired into `uploads/routes.py` in place of the session-only
|
||||||
|
`get_principal`. New/updated tests in `tests/security/identity/test_credentials.py` and
|
||||||
|
`tests/security/uploads/test_upload_security.py` (bearer-token accept/reject paths, the
|
||||||
|
existing route test's stale `get_principal` override fixed to `get_upload_principal`).
|
||||||
|
Verified: RAGcore's own test suite — 377 passed in the affected `tests/security`,
|
||||||
|
`tests/unit`, `tests/api` trees (3 unrelated pre-existing failures: two need Windows
|
||||||
|
symlink privileges the sandbox doesn't have, one is a git-connector fixture mismatch; a
|
||||||
|
separate architecture-boundary failure in `application/ingestion/handler.py` belongs to
|
||||||
|
unrelated in-progress work by a different concurrent agent on the same RAGcore checkout,
|
||||||
|
confirmed via `git status`/`git log` — not touched by this fix). Ruff and mypy clean on
|
||||||
|
every changed file. Deployed to the live RAGcore instance (`ragcore-app-1` on Unraid, port
|
||||||
|
1237 internally, fronted by `rag.itworx.tech` — note the *admin UI* and the *API* share
|
||||||
|
one process/origin, `/v1/uploads` is reachable at `https://rag.itworx.tech/v1/uploads`,
|
||||||
|
**not** `ragcore.itworx.tech`, which only appears in RFC7807 problem-type URLs) by copying
|
||||||
|
the 6 changed source files directly into the server checkout and `docker compose build
|
||||||
|
app && up -d --no-deps app` (deliberately not committing to RAGcore's git history or
|
||||||
|
touching the `worker` service, since a different agent has substantial unrelated
|
||||||
|
uncommitted work in that same working tree). Confirmed live with a garbage token (still
|
||||||
|
correctly 401) and then with a freshly-issued, correctly-scoped real token (403
|
||||||
|
`UPLOAD_TARGET_FORBIDDEN` against a dummy space ID — i.e. authentication succeeded,
|
||||||
|
authorization correctly rejected the wrong space — proving the fix end-to-end before
|
||||||
|
touching n8n at all).
|
||||||
|
- **Root cause of the n8n-side 401 found and fixed**: separately from the RAGcore bug above,
|
||||||
|
the "Upload to RAGcore" HTTP node's Authentication was set to Header Auth, but **no
|
||||||
|
credential had ever actually been attached** to that picker — so the node was sending no
|
||||||
|
`Authorization` header at all, which produces the identical 401 to a malformed one (easy
|
||||||
|
to conflate with the RAGcore-side bug, which is why fixing RAGcore alone didn't resolve
|
||||||
|
the symptom). There was already an unused "RAGcore Sync Token" n8n credential sitting
|
||||||
|
around from the earlier session (its value likely never actually got saved when it was
|
||||||
|
first created, or was created but never selected on this node — not conclusively
|
||||||
|
determined). Owner attached it and set Name=`Authorization`,
|
||||||
|
Value=`Bearer <freshly-issued token, scope sources:sync>` (a new credential issued via the
|
||||||
|
RAGcore admin UI at `/admin/control/applications/c20ac48a-d57b-4c68-9bd1-564f49c1a473/credentials/new`
|
||||||
|
specifically for this, service account "n8n Procedure Sync (production)"; the earlier
|
||||||
|
diagnostic-only credential used to prove the RAGcore fix was revoked afterward via direct
|
||||||
|
SQL `UPDATE identity.service_account_credentials SET revoked_at = now() ...` since the
|
||||||
|
admin UI has no revoke button).
|
||||||
|
- **Workflow 3's "Upload to RAGcore" node live-validated end-to-end, real data**: ran the
|
||||||
|
full workflow via n8n's "Execute workflow" (Schedule Trigger → List procedures → Prepare
|
||||||
|
uploads → Upload to RAGcore). All 33 items succeeded — each output item is a real
|
||||||
|
`AcceptedJob` (`job_id`/`status_url`), not error output. Independently confirmed at the
|
||||||
|
database level (not just trusting the n8n UI): `select count(*) from jobs.jobs where
|
||||||
|
operation='ingest_upload' and created_at > now() - interval '5 minutes'` → **33**, on the
|
||||||
|
live RAGcore Postgres.
|
||||||
|
- **n8n browser-automation notes for this environment** (worth knowing before attempting
|
||||||
|
canvas interaction again): (1) an n8n NPS survey modal (`role=dialog`, "We've been busy")
|
||||||
|
intermittently covers the whole canvas and silently eats every click underneath it until
|
||||||
|
removed; (2) canvas node positions reported by `getBoundingClientRect()` drift between
|
||||||
|
successive tool calls in a way that made coordinate-based `computer` clicks and even
|
||||||
|
`find`-ref-based clicks land on the wrong element repeatedly this session (dozens of failed
|
||||||
|
attempts, multiple different coordinate-math theories, none reliable) — directly setting
|
||||||
|
`.vue-flow__transformationpane`'s inline `style.transform` to force a node into view
|
||||||
|
**desyncs vue-flow's own internal pan/zoom state**, making the problem worse, not better;
|
||||||
|
(3) what actually worked reliably every time: calling native `.click()` directly via JS on
|
||||||
|
a plain `<button>` element (e.g. the toolbar's "Execute workflow" button) — canvas *node*
|
||||||
|
interaction (double-click to open a node's parameter panel) was never reliably achieved
|
||||||
|
via any automated method this session; the owner opened/edited the node manually instead.
|
||||||
|
A plain synthetic `dispatchEvent(new MouseEvent(...))` sequence (pointerdown/mousedown/
|
||||||
|
pointerup/mouseup/click/dblclick, even with correct `clientX`/`clientY`/`detail`) does
|
||||||
|
**not** trigger vue-flow's node click handling at all — it appears to require a genuinely
|
||||||
|
trusted (real CDP-driven) pointer event, consistent with vue-flow's drag/zoom gesture
|
||||||
|
system depending on native pointer capture.
|
||||||
|
- **Exact next action (superseded further below)**: build and wire the workflow's final
|
||||||
|
"Summarize sync result" Code node (count successes/failures across the 33 items) and a
|
||||||
|
closing HTTP node reporting to Fleet Ops's already-deployed `POST
|
||||||
|
/api/v1/integrations/n8n/procedures-sync-result` (task #86, still in progress — the sync
|
||||||
|
itself now works, this is the last piece). Then publish the workflow (currently still a
|
||||||
|
draft/unpublished), update `n8n/workflows/MANIFEST.md` and add
|
||||||
|
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` as the repo source-of-truth
|
||||||
|
definition, and revisit `artifacts/live-ai-integration/final-summary.md` (documents WF3
|
||||||
|
as blocked — no longer true).
|
||||||
|
|
||||||
|
- **Verified the 33-item sync is genuinely fully ingested, not just accepted**: checked at
|
||||||
|
every RAGcore pipeline stage on the live instance, not just trusting the n8n "success"
|
||||||
|
status (which can mask individual failures under `On Error: Continue`). All 33
|
||||||
|
`ingest_upload` jobs have `status='succeeded'` in `jobs.jobs`; 33 rows exist in
|
||||||
|
`content.documents`; 372 chunks were generated in `content.chunks`; 241 vectors are
|
||||||
|
indexed in the `rag_dense_nomic-embed-text_v1` Qdrant collection filtered specifically to
|
||||||
|
Fleet Ops's `space_id` (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`) — matching the expected
|
||||||
|
leaf-chunk count (structural/parent chunks in the hierarchy aren't separately embedded).
|
||||||
|
11 unique procedures × 3 languages = 33, all present.
|
||||||
|
|
||||||
|
- **`RAGcoreKnowledgeProvider` rewritten against the real contract (task #93), tested, but
|
||||||
|
NOT switched on in production — genuinely blocked on a RAGcore-side gap, not a Fleet Ops
|
||||||
|
problem**: rewrote `backend/app/services/knowledge/ragcore.py` end to end against
|
||||||
|
RAGcore's actual `/v1/answers` and `/health/ready` contracts (previously a best-effort
|
||||||
|
guess against an unreachable instance). Health now calls the real `GET /health/ready`.
|
||||||
|
`ask()` calls `POST /v1/answers` with `Authorization: Bearer <token>` and
|
||||||
|
`requested_space_ids: [settings.ragcore_space_id]`, maps RAGcore's `answerability` enum
|
||||||
|
conservatively (`answerable`/`partially_answerable` *with* non-empty citations →
|
||||||
|
`grounded`, everything else → `insufficient`, any transport/parse/non-200 failure →
|
||||||
|
`unavailable`, matching the architecture's never-fabricate rule). Added
|
||||||
|
`RAGCORE_SPACE_ID` setting (`.env.example`, `compose.yaml`). New tests in
|
||||||
|
`backend/tests/test_knowledge.py` (connection-error degrade, missing-space-id short
|
||||||
|
circuits without a network call, health ready/degraded/unreachable, grounded citation
|
||||||
|
mapping, not-answerable and answerable-without-citations both correctly map to
|
||||||
|
`insufficient` with an empty answer, non-200 and malformed-body both degrade to
|
||||||
|
`unavailable`) — **23 passed** in that file, **172 passed** full suite, ruff clean, mypy
|
||||||
|
0 issues/50 files.
|
||||||
|
**Discovered while live-testing against RAGcore with a freshly-issued, correctly-scoped
|
||||||
|
credential** (`Fleet Ops Knowledge Assistant (production)`, scope `answer`, space
|
||||||
|
`f4c91e49-...`): authentication now genuinely succeeds (past the 401 stage — confirmed
|
||||||
|
via `/health/ready` returning `200 {"status":"ok", ...}` with the same token), but **both
|
||||||
|
`POST /v1/answers` and `POST /v1/search` return `503`
|
||||||
|
`ANSWERS_UNAVAILABLE`/`SEARCH_UNAVAILABLE`** for every request. Root-caused by reading
|
||||||
|
`src/ragcore/main.py`'s app-startup/lifespan code directly: it constructs and assigns
|
||||||
|
`app.state.database_engine`, `session_factory`, `qdrant_client`,
|
||||||
|
`query_lab_service`, `profile_activation_service`, and the OIDC session services — but
|
||||||
|
**never constructs or assigns `app.state.search_application`, `context_application`, or
|
||||||
|
`answer_application`** anywhere in the codebase (confirmed via a repo-wide grep — the
|
||||||
|
three `get_*_application` dependency functions exist and correctly raise their `503
|
||||||
|
*_UNAVAILABLE` problem when the state attribute is absent, exactly as designed, but
|
||||||
|
nothing ever populates it). This is not a config toggle Fleet Ops is missing and not
|
||||||
|
something introduced by today's auth fixes — the retrieval/generation subsystem's route
|
||||||
|
handlers and dependencies are scaffolded end-to-end but were never wired into the running
|
||||||
|
application on this RAGcore deployment. Only masked until today because every call to
|
||||||
|
these routes previously 401'd on auth before ever reaching this check.
|
||||||
|
**Decision: `KNOWLEDGE_PROVIDER` stays `demo` in production.** Flipping it to `ragcore`
|
||||||
|
right now would replace the currently-working demo Knowledge Assistant with one that
|
||||||
|
correctly, honestly, but uselessly reports "unavailable" for every question — strictly
|
||||||
|
worse for the live demo. The adapter code itself is finished, correct, and safe to ship
|
||||||
|
(already committed-worthy), and switching providers is a one-line env var flip
|
||||||
|
(`KNOWLEDGE_PROVIDER=ragcore` + set `RAGCORE_API_TOKEN`/`RAGCORE_SPACE_ID`) the moment
|
||||||
|
RAGcore's own operator wires up `search_application`/`answer_application` on their side.
|
||||||
|
The freshly-issued credential (`Fleet Ops Knowledge Assistant (production)`, scope
|
||||||
|
`answer`) was left active/unused in RAGcore, ready for that day.
|
||||||
|
|
||||||
|
- **MCP Hub registration (task #94/#95) — full new connector built and validated locally
|
||||||
|
in the sibling `C:\Projects\ITWorx_MCP_Hub` checkout, not committed or deployed**: the
|
||||||
|
Hub's admin UI (`mcp.itworx.tech`, real production instance, 7 pre-existing projects —
|
||||||
|
DevRunbook, ForgeFlow×2, General Infrastructure, GeoIntel, Ludarium) has **no self-service
|
||||||
|
"add project/connector" flow** — its own Settings page states "No writable settings are
|
||||||
|
available in this browser until the control API publishes an authorized configuration
|
||||||
|
schema" (Hub is on release `1.0.0-rc`, UI is read-only/observability-only today).
|
||||||
|
Registering MobilityOps therefore required building a genuinely new connector in the
|
||||||
|
Hub's own repo, following its `gitea` connector as the closest real template (external
|
||||||
|
HTTPS API, shared-secret auth) rather than `knowledge` (which turned out to be an
|
||||||
|
in-memory filesystem index, not an HTTP client, despite the name suggesting otherwise).
|
||||||
|
Built, with explicit owner approval given the Hub's own `CLAUDE.md` restricts autonomous
|
||||||
|
action to local-checkout work only (registry pushes and production startup are separate,
|
||||||
|
explicitly-gated checkpoints, not done this round):
|
||||||
|
- `packages/connector_kit/mobilityops.py` — `MobilityOpsSettings`/`MobilityOpsClient`
|
||||||
|
(HTTPS-only, same-origin-redirect-enforced, `X-Service-Token`/`X-Client-Id` headers,
|
||||||
|
path-safety-validated `vehicle_ref`), mirroring `gitea.py`'s hardening exactly.
|
||||||
|
- `connectors/mobilityops/{__init__,server,fake}.py` — `MobilityOpsConnector` exposing 4
|
||||||
|
read-only tools (`mobilityops.operations.summary`, `.attention.list`, `.vehicle.get`,
|
||||||
|
`.knowledge.search`), each `governed_tool`-wrapped, envelope-wrapped, field-mapped from
|
||||||
|
Fleet Ops's real `/api/v1/integrations/mcp/*` response shapes.
|
||||||
|
- `services/runtime/connector_mobilityops.py` + `dispatch.py` registration.
|
||||||
|
- Catalog: `catalog/connectors/mobilityops-default.yaml` (ConnectorTemplate),
|
||||||
|
`catalog/tools/mobilityops-*.yaml` (4 ToolManifests), `catalog/capability-packs/
|
||||||
|
mobilityops-reader.yaml` (a **new**, narrowly-scoped pack — deliberately not added to
|
||||||
|
the shared `project-reader` pack other projects use, to avoid granting them
|
||||||
|
MobilityOps access), `catalog/projects/mobilityops.yaml` (Project, `applicationUrl`
|
||||||
|
set to the real internal `http://192.168.10.150:1236`, not an invented public domain).
|
||||||
|
- Compose wiring across all three files (`docker-compose.yml`, `.prod.yml`,
|
||||||
|
`.blueprint.yml`) plus a new `mobilityops_service_token` Docker secret.
|
||||||
|
- Fixed ~13 regressions this surfaced in the Hub's own existing test suite — all
|
||||||
|
legitimate guard-rail tests (hardcoded service/tool/secret allowlists, a
|
||||||
|
`CONNECTOR_GATEWAYS`/`FakeContextForge._gateway_tools` registration gap that was a
|
||||||
|
**real production wiring miss**, not just a test-fixture gap: without it, the reconcile
|
||||||
|
step would have raised `"virtual server resolved to an empty tool allowlist"` against
|
||||||
|
the live Hub too) — not scope creep, this is exactly the Hub's own established pattern
|
||||||
|
for registering a new connector, verified by reading how `gitea`/`knowledge`/`unraid`
|
||||||
|
each touch the same ~15 files.
|
||||||
|
- Verified: 15 new connector tests pass; full Hub suite **383 passed, 0 failed, 5 skipped
|
||||||
|
(pre-existing)**; `ruff check`/`ruff format --check` clean; `mypy` clean across 108
|
||||||
|
source files; `scripts/check_boundaries.py` passes (import-direction rules respected —
|
||||||
|
the new connector only imports from `packages/connector_kit`, never `services/*`
|
||||||
|
directly); `scripts/validate_pack.py` schema-validates the catalog cleanly (68
|
||||||
|
documents, up from 61 — exactly the 7 new files) with the only remaining failure being
|
||||||
|
the Hub's own git-cleanliness gate for manifest regeneration, which requires a commit.
|
||||||
|
- **Not done, deliberately**: no commit (the Hub repo has substantial unrelated
|
||||||
|
in-progress work from a different concurrent agent — `WP-235` frontend redesign,
|
||||||
|
`BUILD_STATE.json`/`CURRENT_STATE.md`/`implementation/WORK_PLAN.json` all show as
|
||||||
|
modified by them, not by this session — committing broadly risks entangling that
|
||||||
|
work); no registry push; no production deployment/restart of the live
|
||||||
|
`itworx-mcp-hub-*` stack. Per the Hub's own `CLAUDE.md` approval boundaries, those are
|
||||||
|
separate, explicitly-gated checkpoints requiring their own fresh approval, and a real
|
||||||
|
`MOBILITYOPS_ENDPOINT`/`mobilityops_service_token`/`MOBILITYOPS_PROJECTS_JSON`
|
||||||
|
production configuration still needs to be decided before any of that could run.
|
||||||
|
- **MCP Hub connector — committed** as `f107544` ("Add MobilityOps (Fleet Ops) read-only
|
||||||
|
connector", 28 files) in `C:\Projects\ITWorx_MCP_Hub`, `main` branch. Not pushed to the
|
||||||
|
registry and not deployed to the live Hub stack — those remain separate, explicitly-gated
|
||||||
|
checkpoints per the Hub's own `CLAUDE.md` approval boundaries.
|
||||||
|
|
||||||
|
- **RAGcore search/context/answer application wiring (task #96) — implemented, tested,
|
||||||
|
committed as `a2905cc`** in `C:\Projects\RAGcore`, `main` branch (not pushed, not
|
||||||
|
deployed to the live RAGcore instance). This closes the gap documented above:
|
||||||
|
`search_application`/`context_application`/`answer_application` are now actually
|
||||||
|
constructed in `create_app()` instead of being permanently absent.
|
||||||
|
- `src/ragcore/application/retrieval/production_executor.py` (new) — `RetrievalPipeline`
|
||||||
|
(embed via Ollama -> dense+sparse Qdrant search -> RRF fuse -> rerank via Ollama),
|
||||||
|
reusing the same flow already proven by the Query Lab's `PipelineQueryLabExecutor`
|
||||||
|
(left untouched). Feeds `ProductionSearchExecutor` and `ProductionContextExecutor` from
|
||||||
|
one shared per-request run, so `SearchHit`'s `FusedCandidate` (required by
|
||||||
|
`SearchService`'s own provenance/scope check) and `ContextCandidate`'s parent-chunk
|
||||||
|
expansion both derive from the same reranked result set instead of drifting apart.
|
||||||
|
- `src/ragcore/application/models/answer_generator.py` (new) — `OllamaAnswerGenerator`,
|
||||||
|
`AnswerService`'s real generator: resolves the active `GenerationProfile` from
|
||||||
|
`GenerationProfileRegistry` (already built, never previously constructed anywhere) and
|
||||||
|
calls the existing hardened `OllamaGenerationAdapter`. `main.py`'s `lifespan()` now
|
||||||
|
seeds and activates a default local-Ollama answer profile on first boot if none is
|
||||||
|
active, since no seed data ever registered one.
|
||||||
|
- Verified: 10 new unit tests (7 for the pipeline/executors, 3 for the answer generator)
|
||||||
|
pass; `ruff check` clean; `mypy src` clean (the only 4 mypy errors found are in
|
||||||
|
`infrastructure/qdrant/adapter.py`, `infrastructure/qdrant/filters.py`,
|
||||||
|
`api/v1/control/routes.py` — files this change never touched, confirmed via `git
|
||||||
|
status`/`git diff`, pre-existing or from other concurrent work in this checkout).
|
||||||
|
- Full non-integration suite (`pytest -m "not integration"`, excludes tests requiring
|
||||||
|
disposable infra per the repo's own marker convention): **593 passed, 14 failed, 1
|
||||||
|
skipped**. All 14 failures are pre-existing and unrelated to this change: 10 are
|
||||||
|
Windows-only (`psycopg`'s async driver rejects Windows' default `ProactorEventLoop` —
|
||||||
|
`test_health.py`, `test_readiness.py`, `test_control_plane.py`), 2 are Windows-only
|
||||||
|
(`WinError 1314`, no symlink privilege on this account — `test_folder_connector.py`), 1
|
||||||
|
is an unrelated pre-existing git-security-connector assertion, and 1 is the repo's
|
||||||
|
zero-tolerance `test_domain_and_application_respect_dependency_boundaries` test —
|
||||||
|
already red before this change, since `application/ingestion/handler.py` (untouched,
|
||||||
|
commit `bb75f52`) already imports `httpx`/infrastructure modules from the application
|
||||||
|
layer, which that test forbids with no allowlist. This change's new files follow the
|
||||||
|
same established (if already-violating) pattern to reach real adapters, adding more
|
||||||
|
instances of the same pre-existing violation rather than introducing a new kind of one.
|
||||||
|
- **Deployed to production** (`http://192.168.10.150:1237` via `ragcore-app-1`),
|
||||||
|
approved by the user. `scp`'d the 4 changed/new files individually to
|
||||||
|
`/mnt/user/appdata/ragcore/app/...`, `docker compose build app`, verified the built
|
||||||
|
image actually contains the new code (`docker run --rm ragcore-app grep`/`test -f`),
|
||||||
|
then `docker compose up -d --no-deps app` (only the `app` service touched). Startup
|
||||||
|
logs are clean (`ragcore_started environment=production`, no errors); `/health/ready`
|
||||||
|
reports `postgres`/`qdrant`/`storage` all `ok`.
|
||||||
|
- **Live-verified the fix itself**: `POST /v1/search` now returns `401
|
||||||
|
AUTHENTICATION_REQUIRED` for an unauthenticated call, not the old permanent `503
|
||||||
|
SEARCH_UNAVAILABLE` — proof the endpoint reaches real request handling instead of
|
||||||
|
hitting an absent application state, which is exactly the bug this closes.
|
||||||
|
- **Issued a fresh scoped credential** (`answer` scope, application `Fleet Ops`, via the
|
||||||
|
RAGcore admin UI at `rag.itworx.tech` — a separate, working OIDC session, unaffected by
|
||||||
|
the n8n auth problem above) and made a real authenticated `POST /v1/answers` call
|
||||||
|
against the `Fleet Ops Procedures` knowledge space (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`).
|
||||||
|
Got a clean `200`, a real `answer_id`/`retrieval_run_id`, `degraded: false` — but
|
||||||
|
`answerability: not_answerable`, 0 citations, for two different real questions matching
|
||||||
|
real seeded document titles.
|
||||||
|
- **Confirmed this is not a regression from this change**: the same query against the
|
||||||
|
same space through RAGcore's own pre-existing, already-proven Query Lab tool (which
|
||||||
|
uses `PipelineQueryLabExecutor`, code this session never touched) returns the identical
|
||||||
|
`NOT_ANSWERABLE` / 0 evidence / not degraded result — down to `Dense candidates (0)` and
|
||||||
|
`Sparse candidates (0)` at the raw retrieval stage, before fusion or rerank ever runs.
|
||||||
|
Whatever is causing zero matches lives in shared retrieval infrastructure or the data
|
||||||
|
itself, not in the new production executors.
|
||||||
|
- **Ruled out the obvious causes via direct Qdrant/Postgres checks** (not yet root-caused
|
||||||
|
further — flagging as a separate follow-up, not blocking this task): the space
|
||||||
|
genuinely has 83 published, correctly-scoped points in `rag_dense_nomic-embed-text_v1`
|
||||||
|
(right `workspace_id`, right `space_id`, `status: published`, real Dutch-language
|
||||||
|
procedure text); the points' `embedding_model_digest` exactly matches the currently
|
||||||
|
active embedding profile's digest (no stale-embedding mismatch); the collection alias
|
||||||
|
(`rag_dense_nomic-embed-text_active`) correctly resolves to that same collection. One
|
||||||
|
oddity noted in passing, unrelated to retrieval: at least one sampled chunk has
|
||||||
|
`language: "en"` in its payload despite the actual text being Dutch — worth a look if
|
||||||
|
language-filtered queries matter for the demo.
|
||||||
|
- **Decision on `KNOWLEDGE_PROVIDER=ragcore` in Fleet Ops**: still deliberately `demo`.
|
||||||
|
The application is deployed, correctly wired, and behaves identically to RAGcore's own
|
||||||
|
trusted reference implementation — but real questions against real seeded content
|
||||||
|
aren't returning grounded answers yet for a reason that traces to shared
|
||||||
|
retrieval/data, not to this wiring. Flip only after that's root-caused.
|
||||||
|
|
||||||
|
- **n8n workflow 3 (task #86) — incident, recovered; final 2 nodes still blocked on a
|
||||||
|
live n8n auth problem, not a code problem.** Returning to finish the "Summarize sync
|
||||||
|
result" + report-to-Fleet-Ops nodes found the live "Fleet Ops — RAGcore Procedure Sync"
|
||||||
|
workflow's canvas at **zero nodes** — the earlier session's abandoned attempt to call
|
||||||
|
n8n's REST API directly (the "unexplained 401" noted above) had gone through far enough
|
||||||
|
to wipe the live workflow before the 401 stopped it, leaving an empty, unsaved "Current
|
||||||
|
changes" draft on top of the last good save.
|
||||||
|
- **Recovered**: n8n's own Version History (`/workflow/.../history`) still had the last
|
||||||
|
good save, version `260b38b5` (Aug 4, 21:08), with all 4 real nodes intact (Schedule
|
||||||
|
Trigger -> List procedures -> Prepare uploads -> Upload to RAGcore) plus the build
|
||||||
|
sticky note. Used the history panel's own **Restore version** action (not a manual
|
||||||
|
rebuild) to bring the live workflow back to that exact state. Confirmed via the page's
|
||||||
|
DOM (`[data-test-id="canvas-node"]` count) both before (0) and after (4) — the canvas
|
||||||
|
itself render fully off-screen (nodes positioned at negative Y coordinates, a separate
|
||||||
|
display-only vue-flow pan bug worked around by directly setting the transform pane's
|
||||||
|
CSS transform; harmless, doesn't touch saved data).
|
||||||
|
- **Then blocked again attempting the 2 new nodes**: adding a Code node via the UI's own
|
||||||
|
"What happens next?" panel (a real, first-party n8n interaction, not the REST API)
|
||||||
|
immediately surfaced n8n's own autosave toast: **"Problem saving workflow — Autosave
|
||||||
|
failed: Unauthorized."** A full sign-out/sign-in cycle right before this (fresh
|
||||||
|
credentials, fresh page load) did not fix it — immediately after a successful
|
||||||
|
interactive "Sign in", `fetch('/rest/workflows/...')` from that same authenticated tab
|
||||||
|
still returned 401. This is not the ordinary "your session expired, log in again"
|
||||||
|
friction seen earlier in the session; it's the live n8n instance's own save path
|
||||||
|
rejecting a request made moments after a successful login, which is exactly the
|
||||||
|
failure mode that caused the wipe above. Continuing to add nodes under this condition
|
||||||
|
risks losing work again with no guarantee the next failure is as recoverable, so this
|
||||||
|
was stopped deliberately rather than retried. The workflow was left in its safely
|
||||||
|
restored, 4-node, unpublished state — verified via the DOM node count immediately
|
||||||
|
before closing the session — nothing was added or changed beyond the restore.
|
||||||
|
- **Not a code/workflow-design problem**: the 2 remaining nodes (a Code node
|
||||||
|
summarizing the sync result, and an HTTP node reporting to Fleet Ops's already-live
|
||||||
|
`POST /api/v1/integrations/n8n/procedures-sync-result`) were never built — this
|
||||||
|
blocked before any node configuration happened. The design itself (mirroring WF2/WF4's
|
||||||
|
existing report-result pattern) is unchanged from earlier planning.
|
||||||
|
|
||||||
|
- **n8n workflow 3 — root cause found and fixed; both remaining nodes now built and
|
||||||
|
saved.** The user pushed back on the "auth problem" framing (n8n visibly showed signed
|
||||||
|
in), which was the right call — dug into the live n8n container's own logs
|
||||||
|
(`docker logs n8n`) rather than continuing to guess from the browser side, and found the
|
||||||
|
real cause: `browserId check failed on /rest/workflows/:workflowId`, alongside an
|
||||||
|
express-rate-limit warning about `X-Forwarded-For` being present while Express `trust
|
||||||
|
proxy` is `false`. n8n's Unraid template
|
||||||
|
(`/boot/config/plugins/dockerMan/templates-user/my-n8n.xml`) had `N8N_PROXY_HOPS=0`
|
||||||
|
despite genuinely running behind the TLS-terminating reverse proxy at
|
||||||
|
`n8n.itworx.tech` — with proxy trust disabled, n8n couldn't correctly resolve the
|
||||||
|
request as HTTPS, which broke its CSRF-style browserId cookie check on every
|
||||||
|
workflow-mutating REST call (autosave, but not plain page loads, which explains why the
|
||||||
|
UI looked fully logged in the whole time).
|
||||||
|
- **Fix, approved by the user beforehand** (shared instance, brief restart): backed up
|
||||||
|
the template to a timestamped `.bak`, changed `N8N_PROXY_HOPS` `0` -> `1`, then
|
||||||
|
recreated the `n8n` container on the Unraid host (stop, rename to
|
||||||
|
`n8n_pre_proxyhops_fix` as an instant rollback, `docker run` with every existing env
|
||||||
|
var/volume/port/label preserved exactly plus the one corrected value). All 3
|
||||||
|
previously-active workflows (Vehicle Return Orchestration, Scheduled Data Quality
|
||||||
|
Scan, Workflow Error Handler) re-activated cleanly on the new container — nothing
|
||||||
|
lost. Verified the fix by reproducing the exact save action that used to fail
|
||||||
|
(adding a node via n8n's own UI): no more "Unauthorized" toast, and the added node
|
||||||
|
survived a full page reload. `docker logs n8n` shows zero `browserId`/401 entries
|
||||||
|
since the fix.
|
||||||
|
- **Mid-fix-verification, a real node got deleted by an errant `Ctrl+A`** (browser
|
||||||
|
focus landed on the canvas instead of a text field during cleanup, selecting and then
|
||||||
|
deleting the "List procedures" node). Caught it via a node-count check, used n8n's own
|
||||||
|
Version History **Restore version** to the last good save (not a manual rebuild), then
|
||||||
|
redid the 2 new nodes carefully (verifying focus before every `Ctrl+A` this time). No
|
||||||
|
data lost, just redone.
|
||||||
|
- **Built**: `Summarize sync result` (Code node, JS) — counts `synced`/`failed` from
|
||||||
|
`$input.all()` by checking each item's `json.error` (matches "Upload to RAGcore"'s own
|
||||||
|
`On Error: Continue` setting, confirmed by inspecting that node's Settings tab, so
|
||||||
|
failed uploads land in the same output stream with an `error` field rather than a
|
||||||
|
separate branch). `Report sync result to Fleet Ops` (HTTP Request, POST) — targets the
|
||||||
|
already-live `/api/v1/integrations/n8n/procedures-sync-result`, reusing the existing
|
||||||
|
"Fleet Ops Service Token" Header Auth credential (same one WF4 already uses against
|
||||||
|
this same backend), JSON body `{execution_id, synced, failed}` matching
|
||||||
|
`ProcedureSyncResultIn` exactly.
|
||||||
|
- **Saved, not published.** All 6 nodes (Schedule Trigger -> List procedures -> Prepare
|
||||||
|
uploads -> Upload to RAGcore -> Summarize sync result -> Report sync result to Fleet
|
||||||
|
Ops) confirmed present after a full page reload. The Schedule Trigger is configured
|
||||||
|
for once daily at midnight -- publishing activates real, live, unattended runs against
|
||||||
|
production RAGcore and Fleet Ops, so this was deliberately left for a separate,
|
||||||
|
explicit approval rather than done automatically.
|
||||||
|
|
||||||
|
- **Exact next action**: (1) RAGcore search/answer wiring (`a2905cc`) is deployed and
|
||||||
|
proven correctly wired (matches Query Lab's trusted behavior exactly) — the open item is
|
||||||
|
now root-causing why `Fleet Ops Procedures` returns zero dense/sparse candidates for
|
||||||
|
real questions despite having genuinely matching, correctly-scoped, correctly-embedded
|
||||||
|
indexed content (see evidence above); this is retrieval/data, not application wiring.
|
||||||
|
Only flip `KNOWLEDGE_PROVIDER=ragcore` once that's fixed and a real grounded answer comes
|
||||||
|
back. (2) n8n workflow 3 is fully built and saved (all 6 nodes) but **not published** —
|
||||||
|
decide whether to publish it (starts real daily runs against production) and, once
|
||||||
|
live-verified end to end, add its file to `n8n/workflows/` and `MANIFEST.md` as the
|
||||||
|
source of truth alongside the other 3 workflows. (3) MCP Hub: registry push + production
|
||||||
|
deployment of the now-committed connector remain separate, explicitly-gated checkpoints.
|
||||||
|
WF4's own timeout/retry gap remains open, deferred, non-blocking.
|
||||||
|
|
||||||
|
## Branch push and Unraid redeploy (2026-08-05)
|
||||||
|
|
||||||
|
- Pushed `feat/live-n8n-ragcore-integration` to `origin` (Gitea on the same Unraid host),
|
||||||
|
now tracking `origin/feat/live-n8n-ragcore-integration` (`0571a40`).
|
||||||
|
- **Redeployed the live Fleet Ops instance** (`http://192.168.10.150:1236`) from this
|
||||||
|
branch, bringing it from the previously-deployed `0da5251` up to `0571a40` — 5 commits,
|
||||||
|
the meaningful one being the `RAGcoreKnowledgeProvider` rewrite (`e5d8466`); the rest
|
||||||
|
are `PROJECT_STATE.md`-only evidence commits.
|
||||||
|
- Followed the deployment directory's own established archive convention
|
||||||
|
(`deploy/unraid/README.md`: "deployed from a committed source archive"): `git archive`
|
||||||
|
of `HEAD` as `source-0571a40.tar.gz`, matching the naming pattern of the existing
|
||||||
|
archives already in `/mnt/user/appdata/mobilityops/.deploy/`; `scp`'d it there;
|
||||||
|
extracted over the deployment directory excluding `.env` and `.deploy` itself (so the
|
||||||
|
live secrets file and archive history were never touched); updated
|
||||||
|
`.deploy/source-revision` to the new full commit hash, matching its existing format.
|
||||||
|
- `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db
|
||||||
|
api web` — `api` image rebuilt and the container recreated (picked up the new code);
|
||||||
|
`web` rebuilt too but didn't need recreating (no frontend changes this branch); `db`
|
||||||
|
untouched. Migrations ran automatically in the `api` entrypoint with no errors.
|
||||||
|
- **Verified live**: `/health` returns `200` from inside the `api` container; the new
|
||||||
|
`/api/v1/integrations/n8n/procedures` endpoint (added this branch, needed for n8n
|
||||||
|
workflow 3) is reachable through the public web proxy and correctly enforces auth
|
||||||
|
(`422`, missing `X-Service-Token`, not a `404` — proves the route exists and is live,
|
||||||
|
not just that the proxy responds). `KNOWLEDGE_PROVIDER=demo` confirmed unchanged in the
|
||||||
|
live `.env` (only extracted new source files, never touched it) — the demo Knowledge
|
||||||
|
Assistant is still what's live, exactly as intended.
|
||||||
|
- No seed reset run (this was an update to a running instance with real accumulated
|
||||||
|
demo data, not the initial deploy — re-seeding would have been destructive and wasn't
|
||||||
|
warranted by anything in this branch's changes).
|
||||||
|
- **Not done**: no PR opened/merged to `master` — the user asked for commit, push, and
|
||||||
|
redeploy, not a merge; `master` is untouched and still 19 commits behind this branch.
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# Fleet Ops final localization — final summary
|
||||||
|
|
||||||
|
Small, targeted correction round on top of the already-merged, functionally-validated
|
||||||
|
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
|
||||||
|
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
|
||||||
|
test hardening, and documentation consistency — explicitly no redesign, no business-logic
|
||||||
|
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Stage | Commit | Message |
|
||||||
|
|---|---|---|
|
||||||
|
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
|
||||||
|
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
|
||||||
|
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
|
||||||
|
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
|
||||||
|
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
|
||||||
|
|
||||||
|
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
|
||||||
|
this branch was verified freshly and cleanly branched from `origin/master` with a clean
|
||||||
|
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
|
||||||
|
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
|
||||||
|
merge, per the mandatory pre-merge safety check.
|
||||||
|
|
||||||
|
Full commit sequence (oldest to newest):
|
||||||
|
|
||||||
|
```
|
||||||
|
1fbb20b docs: audit remaining Fleet Ops localization gaps
|
||||||
|
37a362c fix: translate remaining NL/FR interface gaps
|
||||||
|
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
|
||||||
|
d17af1c feat: centralize API error localization
|
||||||
|
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
|
||||||
|
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
|
||||||
|
f0d6411 fix: serve the missing Fleet Ops favicon
|
||||||
|
9468cc3 docs: update PROJECT_STATE and README for the final localization round
|
||||||
|
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
|
||||||
|
5f0eaa5 merge: finalize Fleet Ops localization
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product name and supported languages
|
||||||
|
|
||||||
|
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
|
||||||
|
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
|
||||||
|
Compose project / deployment-directory identifier only.
|
||||||
|
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
|
||||||
|
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
|
||||||
|
automated test, see below).
|
||||||
|
|
||||||
|
## Corrected translations
|
||||||
|
|
||||||
|
- Role names actually translated (not just labelled as translated): `auth.json` /
|
||||||
|
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
|
||||||
|
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
|
||||||
|
- `audit.title` → **Auditgeschiedenis** / **Piste d'audit**; `columns.actor` → **Uitvoerder**
|
||||||
|
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
|
||||||
|
limitations).
|
||||||
|
- `list.statusOpen` → **Openstaand**; `ledger.filterRecent` → **Recentste**;
|
||||||
|
`scenarios.startScenario` → **Scenario starten** / **Démarrer le scénario**.
|
||||||
|
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
|
||||||
|
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
|
||||||
|
the pre-existing whole-string-identity test, which structurally cannot catch this class
|
||||||
|
of bug.
|
||||||
|
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
|
||||||
|
below).
|
||||||
|
|
||||||
|
## Removed allowlist exceptions
|
||||||
|
|
||||||
|
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
|
||||||
|
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
|
||||||
|
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
|
||||||
|
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
|
||||||
|
translated; their old comments describing them as "deliberately untranslated" were no
|
||||||
|
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
|
||||||
|
no-"MobilityOps"/no-"PoC" guard.
|
||||||
|
|
||||||
|
## Hardcoded-text result
|
||||||
|
|
||||||
|
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
|
||||||
|
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
|
||||||
|
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
|
||||||
|
|
||||||
|
## API-error-localization result
|
||||||
|
|
||||||
|
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
|
||||||
|
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
|
||||||
|
English backend text for the common case) at all 13 call sites across 7 files
|
||||||
|
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
|
||||||
|
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
|
||||||
|
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
|
||||||
|
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
|
||||||
|
explanation + optional next step; raw backend text is demoted to a "Technical
|
||||||
|
details"/"Détails techniques" disclosure, never the primary message.
|
||||||
|
|
||||||
|
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
|
||||||
|
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
|
||||||
|
raw text as the primary message; unknown-code and unknown-status fallback chains behave
|
||||||
|
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
|
||||||
|
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
|
||||||
|
seeded failed automation run renders a fully localized French error with a "DÉTAILS
|
||||||
|
TECHNIQUES" disclosure below it.
|
||||||
|
|
||||||
|
## Greeting logic and edge cases
|
||||||
|
|
||||||
|
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
|
||||||
|
one of 4 periods against **Europe/Brussels** wall-clock time via
|
||||||
|
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
|
||||||
|
construction — no manual UTC-offset math):
|
||||||
|
|
||||||
|
| Period | Window | nl-BE | en-GB | fr-BE |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| morning | 05:00–11:59 | Goedemorgen | Good morning | Bonjour |
|
||||||
|
| afternoon | 12:00–17:59 | Goedemiddag | Good afternoon | Bonjour |
|
||||||
|
| evening | 18:00–22:59 | Goedenavond | Good evening | Bonsoir |
|
||||||
|
| night | 23:00–04:59 | Welkom terug | Welcome back | Bon retour |
|
||||||
|
|
||||||
|
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
|
||||||
|
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
|
||||||
|
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
|
||||||
|
over live while the app stays open, no reload required; initial render uses a synchronous
|
||||||
|
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
|
||||||
|
|
||||||
|
Edge-case evidence:
|
||||||
|
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
|
||||||
|
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
|
||||||
|
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
|
||||||
|
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
|
||||||
|
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
|
||||||
|
live period rollover with no `page.reload()` call anywhere in that test; language-switch
|
||||||
|
behaviour without changing the time period; the "never Goedenacht" guard.
|
||||||
|
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
|
||||||
|
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
|
||||||
|
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
|
||||||
|
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
|
||||||
|
|
||||||
|
## README / PROJECT_STATE corrections
|
||||||
|
|
||||||
|
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
|
||||||
|
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
|
||||||
|
correction" section header, which still read "IN PROGRESS .../Not yet merged to
|
||||||
|
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
|
||||||
|
entry for this correction round (not a rewrite of prior entries, per the brief's
|
||||||
|
explicit instruction not to hide earlier history).
|
||||||
|
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
|
||||||
|
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
|
||||||
|
after the favicon regression test was added).
|
||||||
|
|
||||||
|
## Backend tests, Ruff, mypy
|
||||||
|
|
||||||
|
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
|
||||||
|
|
||||||
|
- `pytest`: **151 passed**, 0 failed.
|
||||||
|
- `ruff check .`: **All checks passed!**
|
||||||
|
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
|
||||||
|
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
|
||||||
|
in 49 source files.**
|
||||||
|
|
||||||
|
No backend Python was touched this round; these numbers are unchanged from the prior
|
||||||
|
correction milestone's final gate, confirmed green again on the current tree.
|
||||||
|
|
||||||
|
## Frontend build, Playwright
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`: clean, 0 errors.
|
||||||
|
- `npm run build` (`tsc -b && vite build`): clean production build.
|
||||||
|
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
|
||||||
|
**139 passed**, 0 failed (confirmed on a clean run after two transient
|
||||||
|
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
|
||||||
|
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
|
||||||
|
re-run of every new/changed suite also passed cleanly in between).
|
||||||
|
|
||||||
|
## Clean-checkout drill
|
||||||
|
|
||||||
|
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
|
||||||
|
network with the working dev stack), fresh `git clone --branch
|
||||||
|
fix/fleet-ops-final-i18n-ux` of only committed files:
|
||||||
|
|
||||||
|
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
|
||||||
|
2. `alembic upgrade head` → `799d8800e241 (head)`.
|
||||||
|
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
|
||||||
|
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
|
||||||
|
deterministic count exactly.
|
||||||
|
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
|
||||||
|
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
|
||||||
|
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
|
||||||
|
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
|
||||||
|
greeting boundaries, API error paths, and the guided demo, all in one pass.
|
||||||
|
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
|
||||||
|
8. Isolated stack, containers, volumes and images torn down; original dev environment
|
||||||
|
confirmed untouched (`mobilityops-*` containers unaffected throughout).
|
||||||
|
|
||||||
|
**PASS.**
|
||||||
|
|
||||||
|
## Guided demo per language
|
||||||
|
|
||||||
|
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
|
||||||
|
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
|
||||||
|
band, attention queue, integration pulse, recent activity), audit trail, automation retry
|
||||||
|
flow with localized error + technical-details disclosure, and demo reset — all rendering
|
||||||
|
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
|
||||||
|
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
|
||||||
|
stack and the isolated clean-checkout stack.
|
||||||
|
|
||||||
|
## Server deployment, container health
|
||||||
|
|
||||||
|
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
|
||||||
|
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
|
||||||
|
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
|
||||||
|
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
|
||||||
|
second n8n instance was started (shared existing n8n at `:5678` used throughout).
|
||||||
|
|
||||||
|
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive` → `scp` →
|
||||||
|
extract over the existing deployment dir → update `.deploy/source-revision` →
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
→ confirm `alembic current` → `seed --reset`.
|
||||||
|
|
||||||
|
Final container status:
|
||||||
|
|
||||||
|
```
|
||||||
|
mobilityops-api-1 Up (healthy)
|
||||||
|
mobilityops-db-1 Up (healthy)
|
||||||
|
mobilityops-web-1 Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
|
||||||
|
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
|
||||||
|
merge — both deployments passed migrations, reseed, and a live smoke test.
|
||||||
|
|
||||||
|
## Repository / runtime hash comparison
|
||||||
|
|
||||||
|
```
|
||||||
|
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exact match.**
|
||||||
|
|
||||||
|
## Browser console and network
|
||||||
|
|
||||||
|
No console errors on any checked route in any of the 3 languages (dashboard, audit,
|
||||||
|
automation, login) on the live Unraid deployment. All observed `/api/` network requests
|
||||||
|
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
|
||||||
|
the final deployment.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
|
||||||
|
manual browser testing** on the live server: on 2 occasions, right after a client-side
|
||||||
|
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
|
||||||
|
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
|
||||||
|
controlled repeat of the exact same click sequence (fresh login, single deliberate
|
||||||
|
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
|
||||||
|
possible: the codebase has exactly one `i18n.changeLanguage()` call site
|
||||||
|
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
|
||||||
|
`i18n.language` are structurally coupled through a single i18next singleton with no
|
||||||
|
code path capable of producing this split state. Not reproduced even once across 139
|
||||||
|
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
|
||||||
|
local post-merge on master) in a clean, extension-free browser context. Most likely
|
||||||
|
explanation: a third-party browser extension active in the specific interactive testing
|
||||||
|
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
|
||||||
|
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
|
||||||
|
the `lang` attribute based on its own content heuristics, independent of the React app.
|
||||||
|
Logged here for transparency rather than silently dismissed; does not affect any
|
||||||
|
automated PASS result above.
|
||||||
|
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
|
||||||
|
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
|
||||||
|
processes from the interactive testing session above. A clean run immediately
|
||||||
|
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
|
||||||
|
new/changed suite also passed cleanly in between. Treated as machine resource
|
||||||
|
contention, not a code defect — consistent with the prior correction milestone's own
|
||||||
|
documented experience of "sequential-run-only flakes reproduced from resource
|
||||||
|
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
|
||||||
|
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
|
||||||
|
specified "Auteur") was missed in the initial pass and only caught during live browser
|
||||||
|
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
|
||||||
|
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
|
||||||
|
potentially due for a visual refresh; per explicit user decision mid-session, this is
|
||||||
|
out of scope for this correction round and deferred to a separate follow-up task.
|
||||||
|
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
|
||||||
|
demo/not-connected state documented by the prior correction milestone.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
`.deploy/source-revision` on the server records exactly which commit is live. To roll
|
||||||
|
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
|
||||||
|
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
|
||||||
|
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
|
||||||
|
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
followed by `alembic upgrade head` (migrations are additive only — no destructive
|
||||||
|
migration exists on this branch, so no database rollback is needed). No secrets were
|
||||||
|
printed or read at any point in this process (`.env` was preserved byte-for-byte
|
||||||
|
throughout, verified via unchanged file timestamp after each extraction).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Live n8n + RAGcore integration — final evidence
|
||||||
|
|
||||||
|
No credential values, tokens, or secrets appear anywhere in this document. Where a
|
||||||
|
credential or trace ID is referenced, only its name or an opaque reference identifier is
|
||||||
|
given, never its value.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
Built on branch `feat/live-n8n-ragcore-integration`, HEAD at commit
|
||||||
|
`aaa16305354d34f9c1f4d57253d33d9062c38faa` ("docs: record WF2 retry fix and WF4's
|
||||||
|
n8n-session-expiry blocker"). Run `git log --oneline feat/live-n8n-ragcore-integration`
|
||||||
|
for the full history of this effort.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The brief required treating n8n (`https://n8n.itworx.tech`) as a full third integration
|
||||||
|
layer alongside RAGcore and MCP Hub, with Fleet Ops keeping exclusive ownership of
|
||||||
|
business rules, authorization, transactions, audit, and idempotency. Four canonical n8n
|
||||||
|
workflows were required. The repository (`n8n/workflows/*.json` + `MANIFEST.md` +
|
||||||
|
`n8n/workflows/check_drift.py`) is the source of truth for cleaned workflow definitions;
|
||||||
|
the Fleet Ops integration status page (`/automation`) shows real per-workflow operational
|
||||||
|
evidence, not a config boolean.
|
||||||
|
|
||||||
|
## Result summary
|
||||||
|
|
||||||
|
| # | Workflow | Status | Live evidence this round |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Fleet Ops — Vehicle Return Orchestration | **Live, hardened** | Timeout+bounded-retry gap found and fixed |
|
||||||
|
| 2 | Fleet Ops — Scheduled Data Quality Scan | **Live, hardened** | Same gap found and fixed |
|
||||||
|
| 3 | Fleet Ops — RAGcore Procedure Sync | **Blocked** | Not built — RAGcore rejects credential issuance (see below) |
|
||||||
|
| 4 | Fleet Ops — Workflow Error Handler | **Live, validated** | Mock + genuine induced-failure test; own hardening incomplete (see below) |
|
||||||
|
|
||||||
|
Full per-workflow detail (purpose, trigger, event contract, required credentials, live
|
||||||
|
workflow ID, checksum) is in `n8n/workflows/MANIFEST.md`, which is the authoritative,
|
||||||
|
continuously-updated source — this document is a point-in-time summary of that state
|
||||||
|
plus the reasoning behind what's not done.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Orchestration
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-return-processing`. Validated in an earlier round of this
|
||||||
|
effort: webhook trigger requires Header Auth (`Fleet Ops Webhook Trigger Token`),
|
||||||
|
validates `event_type == vehicle.returned.v1`, derives a follow-up category, calls Fleet
|
||||||
|
Ops's `/return-callback` endpoint with an `Idempotency-Key` header via a named
|
||||||
|
`Fleet Ops Service Token` credential (not a literal secret), and responds with a
|
||||||
|
controlled JSON result. Idempotent on both sides (`event_id` flows through as the
|
||||||
|
dedup key; the backend independently checks for a prior audit event before recording
|
||||||
|
again).
|
||||||
|
|
||||||
|
**This round's finding**: the `Record follow-up` HTTP node had no explicit timeout and
|
||||||
|
"Retry On Fail" disabled — a real gap against the requirement that external dependencies
|
||||||
|
have timeouts and bounded retries. Fixed live: Retry On Fail (3 tries, 1000ms wait) + a
|
||||||
|
15000ms timeout, published. Safe to retry because the callback is idempotent. Repo
|
||||||
|
definition and manifest checksum synced (commit `0562893`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Data Quality Scan
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-scheduled-quality-scan`. Validated earlier: hourly
|
||||||
|
Schedule Trigger + a Manual Trigger for on-demand testing, both feeding a single HTTP
|
||||||
|
call to Fleet Ops's `/scheduled-scan` endpoint (Header Auth via the same `Fleet Ops
|
||||||
|
Service Token` credential, 15000ms timeout already configured), which runs the
|
||||||
|
domain-level `run_scan()` function — documented and tested as idempotent by
|
||||||
|
construction (only ever creates an issue for a condition that doesn't already have one
|
||||||
|
open), so overlapping or retried triggers do no duplicate domain work.
|
||||||
|
|
||||||
|
**This round's finding**: the same Retry On Fail gap as workflow 1 (timeout was already
|
||||||
|
set, retries were not). Fixed live the same way (3 tries, 1000ms wait), published. Repo
|
||||||
|
definition and manifest checksum synced (commit `167bf49`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 3 — RAGcore Procedure Sync — blocked
|
||||||
|
|
||||||
|
**Not built.** This workflow needs an application credential (scope `sources:sync`) for
|
||||||
|
the `fleet-ops` application in RAGcore. Two independent issuance attempts, in two
|
||||||
|
separate rounds of this effort, both failed with an opaque server-side rejection:
|
||||||
|
|
||||||
|
1. **Raw API**: `POST /v1/applications/{id}/credentials` → `400`, "authoritative
|
||||||
|
service-account state rejected issuance".
|
||||||
|
2. **RAGcore admin UI**, this round, after the project owner explicitly authorized
|
||||||
|
Claude to self-issue the credential: the "Issue credential" form for the `fleet-ops`
|
||||||
|
application, submitted as the Platform Admin role (the highest role visible in the
|
||||||
|
RAGcore admin), with name `n8n-ragcore-procedure-sync` and scope `sources:sync` only.
|
||||||
|
Result: "Something went wrong. The credential could not be issued with those
|
||||||
|
values.", trace reference `1955c6a8968c4941a22a1faef39e17a7`.
|
||||||
|
|
||||||
|
The `fleet-ops` application itself shows as ordinary/`Active` in the RAGcore admin, with
|
||||||
|
no visible lock flag, and RAGcore's own OpenAPI spec documents no validation rule that
|
||||||
|
would explain either rejection (no `422`, no field-level errors). Two independent paths
|
||||||
|
— a raw API call and the admin UI as the top admin role — hitting the same failure
|
||||||
|
signature is conclusive evidence this is a RAGcore-side policy or bug, not a Fleet Ops
|
||||||
|
request-shape or permission problem. It is not fixable from the Fleet Ops side or
|
||||||
|
through further UI automation. Resolving it requires whoever operates the RAGcore
|
||||||
|
instance to look up the trace ID above (and the earlier raw-API rejection) in RAGcore's
|
||||||
|
own logs.
|
||||||
|
|
||||||
|
The real RAGcore contract this workflow will be built against — once a working
|
||||||
|
credential exists — was independently inspected via RAGcore's live OpenAPI spec and is
|
||||||
|
recorded in `docs/live-ai-integration/n8n-current-state.md` and
|
||||||
|
`contracts/ragcore-contract-assumptions.md`: control-plane endpoints require an
|
||||||
|
`Idempotency-Key` header; ingestion is `POST /v1/uploads`; retrieval is `POST
|
||||||
|
/v1/search` / `/v1/context` / `/v1/answers` (the latter requiring `requested_space_ids`,
|
||||||
|
an array of knowledge-space UUIDs); health is `/health/live` and `/health/ready` (not
|
||||||
|
`/health`); the scope enum is `search, context, answer, documents:read, citations:read,
|
||||||
|
feedback:write, sources:sync`.
|
||||||
|
|
||||||
|
**`RAGcoreKnowledgeProvider` adapter** (`backend/app/services/knowledge/ragcore.py`)
|
||||||
|
still targets the earlier speculative contract (`/health`, `POST /api/v1/ask`, Bearer
|
||||||
|
token) rather than the real one above. This was deliberately **not** rewritten this
|
||||||
|
round: rewriting it blind, without a credential to validate against, risks introducing
|
||||||
|
a silent behavioral bug in exactly the code path responsible for the project's "AI must
|
||||||
|
never invent an answer when RAGcore is unavailable or returns insufficient evidence"
|
||||||
|
guarantee — for example a wrong `evidence_state` mapping that looks fine in code review
|
||||||
|
but misclassifies "unavailable" as "insufficient" (or vice versa) against the real
|
||||||
|
response shape. The adapter's current behavior is honest and safe (it degrades cleanly
|
||||||
|
to `unavailable` on any request or parsing failure, and `ragcore_api_token` is unset by
|
||||||
|
default so the app correctly runs on the local demo knowledge provider today). The
|
||||||
|
rewrite stays queued behind the same credential blocker as workflow 3.
|
||||||
|
|
||||||
|
## Workflow 4 — Workflow Error Handler
|
||||||
|
|
||||||
|
Live workflow ID `Xppn2rAEqUuyiCJF`. Built and live-validated in an earlier round:
|
||||||
|
Error Trigger → a Code node that derives a bounded, secret-free failure report (error
|
||||||
|
category classified from the message text, truncated summary, no stack trace, no
|
||||||
|
headers or tokens) → an HTTP call to Fleet Ops's `/workflow-error` endpoint (Header Auth
|
||||||
|
via the same `Fleet Ops Service Token` credential), which registers the failure as an
|
||||||
|
audit event idempotently keyed on `execution_id`.
|
||||||
|
|
||||||
|
Set as the Error Workflow on both workflow 1 and workflow 2. Confirmed workflow 4 has no
|
||||||
|
Error Workflow of its own (prevents a recursive loop).
|
||||||
|
|
||||||
|
**Live validation performed**: a pinned mock Error Trigger payload produced a real `200
|
||||||
|
{"status":"registered", ...}` from the live Fleet Ops server; re-running the identical
|
||||||
|
payload produced `"status":"already_registered"`, confirming idempotency. A genuine
|
||||||
|
induced failure (temporarily pointing workflow 2's HTTP node at a nonexistent path, then
|
||||||
|
reverting) confirmed workflow 2 itself fails correctly against a broken endpoint and
|
||||||
|
recovers cleanly once reverted.
|
||||||
|
|
||||||
|
**Known limitation**: n8n's Error Workflow trigger does not fire for manual editor
|
||||||
|
"Execute workflow" test runs — checked via workflow 4's own Executions list after the
|
||||||
|
induced workflow-2 failure, and confirmed no new execution appeared. n8n only invokes a
|
||||||
|
workflow's assigned Error Workflow for unattended/production trigger executions, not
|
||||||
|
manual test runs from the editor. The mock-data path exercises the same nodes, logic,
|
||||||
|
and real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered)
|
||||||
|
failure cascading into workflow 4 was not observed live in either round.
|
||||||
|
|
||||||
|
**Open follow-up (minor, non-blocking)**: continuing this round's acceptance pass to
|
||||||
|
workflow 4 found the same timeout/retry gap as workflows 1 and 2 on its own outbound
|
||||||
|
HTTP call. A fix was started (15000ms timeout added, Retry On Fail toggled on) but n8n's
|
||||||
|
autosave began failing with "Unauthorized" mid-edit; a fresh browser tab confirmed the
|
||||||
|
n8n session had expired (redirected to `/signin`). Nothing was saved — workflow 4's live
|
||||||
|
definition is unchanged from before this round, so there is no partial or broken state.
|
||||||
|
This is lower-stakes than workflows 1/2 (workflow 4 is the error notifier itself, not a
|
||||||
|
primary business flow, and a failed error-report is already visible in n8n's own
|
||||||
|
execution history via `On Error: Stop Workflow`) but should be finished once the n8n
|
||||||
|
browser session is re-authenticated.
|
||||||
|
|
||||||
|
## Repository source of truth
|
||||||
|
|
||||||
|
`n8n/workflows/` holds cleaned, credential-value-free JSON definitions for all built
|
||||||
|
workflows, `n8n/workflows/MANIFEST.md` documents purpose/trigger/contract/credentials/
|
||||||
|
live-ID/checksum for all four canonical workflows (including workflow 3's blocked
|
||||||
|
status), and `n8n/workflows/check_drift.py` is a read-only script that compares the
|
||||||
|
repo definitions against the live instance via n8n's Public API and reports drift —
|
||||||
|
safe to run in CI as a non-blocking check. No literal export/download mechanism was
|
||||||
|
found working in this n8n version, so each definition was reconstructed from direct,
|
||||||
|
verified UI inspection rather than a native export; this limitation is noted in the
|
||||||
|
manifest itself.
|
||||||
|
|
||||||
|
## Integration status page
|
||||||
|
|
||||||
|
`/automation` (Operations Manager only) surfaces real per-workflow evidence derived
|
||||||
|
purely from Fleet Ops's own audit/outbox tables — no new dependency on n8n's API was
|
||||||
|
added to the backend. Each of the four canonical workflows shows a status (not built /
|
||||||
|
no evidence yet / operational) and a last-evidence timestamp; the scheduled-scan
|
||||||
|
evidence specifically filters to `actor_type == "service"` so a manually-triggered scan
|
||||||
|
in the UI doesn't count as n8n evidence. An error-handler summary line reports total
|
||||||
|
registered automation failures and the most recent one.
|
||||||
|
|
||||||
|
Verified live in the browser (Dutch locale) both locally and on the deployed
|
||||||
|
production server (`http://192.168.10.150:1236/automation`): correctly showed "3 van 4
|
||||||
|
canonieke n8n-workflows hebben actuele evidentie van werking" with real timestamps for
|
||||||
|
the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the RAGcore sync, and
|
||||||
|
the real error-handler registration from this effort's live testing.
|
||||||
|
|
||||||
|
## Deployments performed (all explicitly user-approved)
|
||||||
|
|
||||||
|
1. Backend `/workflow-error` endpoint (commit `bbdb4a9`) — deployed and verified
|
||||||
|
(`/health` OK, new endpoint returns `422` not `404` on an empty POST body).
|
||||||
|
2. Integration status page, backend + frontend (commit `4049c0c`) — deployed and
|
||||||
|
verified (`/health` OK, page renders real evidence in the browser).
|
||||||
|
|
||||||
|
The three n8n-side node edits this round (WF1 timeout/retry, WF2 timeout/retry, WF4's
|
||||||
|
incomplete attempt) are live edits to the n8n instance itself and do not require a
|
||||||
|
Fleet Ops redeploy.
|
||||||
|
|
||||||
|
## What's left
|
||||||
|
|
||||||
|
1. **RAGcore credential issuance** — blocked on RAGcore's own server-side rejection
|
||||||
|
(trace `1955c6a8968c4941a22a1faef39e17a7` and the earlier raw-API `400`). Needs
|
||||||
|
RAGcore's operator to investigate. Unblocks workflow 3 and the
|
||||||
|
`RAGcoreKnowledgeProvider` real-contract rewrite.
|
||||||
|
2. **Workflow 4's own timeout/bounded-retry hardening** — needs the n8n browser session
|
||||||
|
re-authenticated to finish; a small, well-understood, non-blocking edit.
|
||||||
|
3. **Fleet Ops logo/favicon** — explicitly deferred by the project owner as a separate,
|
||||||
|
unrelated follow-up task, not part of this integration effort.
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
@@ -13,9 +14,18 @@ from app.core.config import get_settings
|
|||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.audit import AuditEvent
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import ScanResultOut
|
from app.schemas import (
|
||||||
|
ProcedureDocumentOut,
|
||||||
|
ProcedureListOut,
|
||||||
|
ProcedureSyncResultIn,
|
||||||
|
ProcedureSyncResultResult,
|
||||||
|
ScanResultOut,
|
||||||
|
WorkflowErrorReportIn,
|
||||||
|
WorkflowErrorReportResult,
|
||||||
|
)
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
from app.services.data_quality import run_scan
|
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"])
|
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -89,3 +99,124 @@ def scheduled_scan(
|
|||||||
|
|
||||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
return ScanResultOut(created=result.created)
|
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."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
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:
|
||||||
|
correlation_id: uuid.UUID | None = None
|
||||||
|
if body.correlation_id:
|
||||||
|
try:
|
||||||
|
correlation_id = uuid.UUID(body.correlation_id)
|
||||||
|
except ValueError:
|
||||||
|
correlation_id = None
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=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."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ class Settings(BaseSettings):
|
|||||||
ragcore_workspace: str = "mobilityops"
|
ragcore_workspace: str = "mobilityops"
|
||||||
ragcore_collection: str = "internal-procedures"
|
ragcore_collection: str = "internal-procedures"
|
||||||
ragcore_api_token: str = ""
|
ragcore_api_token: str = ""
|
||||||
|
ragcore_space_id: str = ""
|
||||||
ragcore_http_timeout_seconds: float = 5.0
|
ragcore_http_timeout_seconds: float = 5.0
|
||||||
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||||
|
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||||
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||||
n8n_dispatch_enabled: bool = True
|
n8n_dispatch_enabled: bool = True
|
||||||
n8n_dispatch_interval_seconds: float = 3.0
|
n8n_dispatch_interval_seconds: float = 3.0
|
||||||
|
|||||||
@@ -143,6 +143,53 @@ class ScanResultOut(BaseModel):
|
|||||||
created: dict[str, int]
|
created: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportIn(BaseModel):
|
||||||
|
workflow_id: str = Field(max_length=120)
|
||||||
|
workflow_name: str = Field(max_length=200)
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
failed_at: datetime
|
||||||
|
error_category: Literal[
|
||||||
|
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
|
||||||
|
]
|
||||||
|
error_summary: str = Field(max_length=500)
|
||||||
|
trigger_context: str | None = Field(default=None, max_length=200)
|
||||||
|
correlation_id: str | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1, le=1000)
|
||||||
|
retry_action: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureDocumentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureListOut(BaseModel):
|
||||||
|
documents: list[ProcedureDocumentOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultIn(BaseModel):
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
synced: int = Field(ge=0)
|
||||||
|
failed: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ProvideFieldsRequest(BaseModel):
|
class ProvideFieldsRequest(BaseModel):
|
||||||
fields: dict[str, str]
|
fields: dict[str, str]
|
||||||
|
|
||||||
@@ -202,6 +249,18 @@ class SearchResponse(BaseModel):
|
|||||||
results: list[SearchResultItem]
|
results: list[SearchResultItem]
|
||||||
|
|
||||||
|
|
||||||
|
class N8nWorkflowEvidence(BaseModel):
|
||||||
|
name: str
|
||||||
|
built: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class N8nErrorHandlerStatus(BaseModel):
|
||||||
|
total_failures_registered: int
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
latest_failure_workflow: str | None
|
||||||
|
|
||||||
|
|
||||||
class N8nIntegrationStatus(BaseModel):
|
class N8nIntegrationStatus(BaseModel):
|
||||||
configured: bool
|
configured: bool
|
||||||
dispatch_enabled: bool
|
dispatch_enabled: bool
|
||||||
@@ -212,6 +271,10 @@ class N8nIntegrationStatus(BaseModel):
|
|||||||
succeeded: int
|
succeeded: int
|
||||||
latest_success_at: datetime | None
|
latest_success_at: datetime | None
|
||||||
latest_failure_at: datetime | None
|
latest_failure_at: datetime | None
|
||||||
|
expected_workflow_count: int
|
||||||
|
known_workflow_count: int
|
||||||
|
workflows: list[N8nWorkflowEvidence]
|
||||||
|
error_handler: N8nErrorHandlerStatus
|
||||||
|
|
||||||
|
|
||||||
class McpHubIntegrationStatus(BaseModel):
|
class McpHubIntegrationStatus(BaseModel):
|
||||||
|
|||||||
@@ -121,13 +121,30 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
settings.n8n_webhook_url,
|
settings.n8n_webhook_url,
|
||||||
json=wire_event,
|
json=wire_event,
|
||||||
|
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||||
timeout=settings.n8n_http_timeout_seconds,
|
timeout=settings.n8n_http_timeout_seconds,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = response.json()
|
try:
|
||||||
success = bool(body.get("ok", True))
|
body = response.json()
|
||||||
error = None if success else f"n8n reported failure: {body}"
|
except ValueError:
|
||||||
error_code = None if success else "remoteReportedFailure"
|
body = None
|
||||||
|
if isinstance(body, dict):
|
||||||
|
success = bool(body.get("ok", True))
|
||||||
|
error = None if success else f"n8n reported failure: {body}"
|
||||||
|
error_code = None if success else "remoteReportedFailure"
|
||||||
|
else:
|
||||||
|
# A 2xx status with a non-object (or unparsable) body means the workflow
|
||||||
|
# itself errored before its "Respond to Webhook" node ran -- n8n's default
|
||||||
|
# error response still carries a 2xx-looking status here. Treat it as a
|
||||||
|
# failure so the event is retried rather than lost or wrongly marked
|
||||||
|
# succeeded.
|
||||||
|
success = False
|
||||||
|
error = (
|
||||||
|
"Unexpected non-JSON-object response from n8n "
|
||||||
|
f"(status {response.status_code})"
|
||||||
|
)
|
||||||
|
error_code = "malformedResponse"
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
success = False
|
success = False
|
||||||
error = f"{type(exc).__name__}: {exc}"
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import N8nIntegrationStatus
|
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3
|
||||||
|
# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence.
|
||||||
|
_CANONICAL_WORKFLOWS = (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||||
counts: dict[str, int] = dict(
|
counts: dict[str, int] = dict(
|
||||||
@@ -42,6 +52,52 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
else:
|
else:
|
||||||
state = "no_evidence"
|
state = "no_evidence"
|
||||||
|
|
||||||
|
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
|
||||||
|
# runs an operator triggered manually from the Data Quality page.
|
||||||
|
latest_scan_at = db.scalar(
|
||||||
|
select(func.max(AuditEvent.occurred_at)).where(
|
||||||
|
AuditEvent.action == "data_quality_scan_run",
|
||||||
|
AuditEvent.actor_type == "service",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||||
|
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
|
||||||
|
# up and firing correctly.
|
||||||
|
total_failures_registered = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(AuditEvent.id)).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_failure_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json)
|
||||||
|
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
|
||||||
|
latest_handler_failure_workflow = (
|
||||||
|
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_by_workflow = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": None,
|
||||||
|
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||||
|
}
|
||||||
|
workflows = [
|
||||||
|
N8nWorkflowEvidence(
|
||||||
|
name=name,
|
||||||
|
built=name != "Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
last_seen_at=evidence_by_workflow[name],
|
||||||
|
)
|
||||||
|
for name in _CANONICAL_WORKFLOWS
|
||||||
|
]
|
||||||
|
|
||||||
return N8nIntegrationStatus(
|
return N8nIntegrationStatus(
|
||||||
configured=bool(settings.n8n_webhook_url),
|
configured=bool(settings.n8n_webhook_url),
|
||||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||||
@@ -52,4 +108,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
succeeded=succeeded,
|
succeeded=succeeded,
|
||||||
latest_success_at=latest_success_at,
|
latest_success_at=latest_success_at,
|
||||||
latest_failure_at=latest_failure_at,
|
latest_failure_at=latest_failure_at,
|
||||||
|
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
|
||||||
|
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
|
||||||
|
workflows=workflows,
|
||||||
|
error_handler=N8nErrorHandlerStatus(
|
||||||
|
total_failures_registered=total_failures_registered,
|
||||||
|
latest_failure_at=latest_handler_failure_at,
|
||||||
|
latest_failure_workflow=latest_handler_failure_workflow,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
from app.services.knowledge.procedures import parse_frontmatter
|
||||||
|
|
||||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
DEFAULT_LANGUAGE = "en-GB"
|
DEFAULT_LANGUAGE = "en-GB"
|
||||||
@@ -74,23 +75,6 @@ class ScoredSection:
|
|||||||
body_tokens: set[str]
|
body_tokens: set[str]
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
|
||||||
if not raw.startswith("---"):
|
|
||||||
return {}, raw
|
|
||||||
end = raw.find("\n---", 3)
|
|
||||||
if end == -1:
|
|
||||||
return {}, raw
|
|
||||||
block = raw[3:end].strip()
|
|
||||||
body = raw[end + 4 :].lstrip("\n")
|
|
||||||
meta: dict[str, str] = {}
|
|
||||||
for line in block.splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
meta[key.strip()] = value.strip().strip('"')
|
|
||||||
return meta, body
|
|
||||||
|
|
||||||
|
|
||||||
def _split_sections(body: str) -> list[tuple[str, str]]:
|
def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||||
sections: list[tuple[str, str]] = []
|
sections: list[tuple[str, str]] = []
|
||||||
current_heading = "Overview"
|
current_heading = "Overview"
|
||||||
@@ -114,7 +98,7 @@ def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
|||||||
sections: list[ScoredSection] = []
|
sections: list[ScoredSection] = []
|
||||||
for path in sorted(procedures_dir.glob("*.md")):
|
for path in sorted(procedures_dir.glob("*.md")):
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8")
|
||||||
meta, body = _parse_frontmatter(raw)
|
meta, body = parse_frontmatter(raw)
|
||||||
title = meta.get("title", path.stem)
|
title = meta.get("title", path.stem)
|
||||||
doc = Document(
|
doc = Document(
|
||||||
document_id=meta.get("document_id", path.stem),
|
document_id=meta.get("document_id", path.stem),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
|
|
||||||
|
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||||
|
# source_id never changes just because the sync ran on a different day or in a
|
||||||
|
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||||
|
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||||
|
if not raw.startswith("---"):
|
||||||
|
return {}, raw
|
||||||
|
end = raw.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, raw
|
||||||
|
block = raw[3:end].strip()
|
||||||
|
body = raw[end + 4 :].lstrip("\n")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in block.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
meta[key.strip()] = value.strip().strip('"')
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcedureDocument:
|
||||||
|
source_id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
|
||||||
|
"""Read every procedure Markdown file Fleet Ops ships, across every supported
|
||||||
|
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
|
||||||
|
fields (title, version) come from the same files the demo knowledge provider
|
||||||
|
already reads -- see parse_frontmatter -- so the two never drift apart."""
|
||||||
|
|
||||||
|
documents: list[ProcedureDocument] = []
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
language_dir = knowledge_dir / language
|
||||||
|
if not language_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in sorted(language_dir.glob("*.md")):
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
meta, body = parse_frontmatter(raw)
|
||||||
|
document_id = meta.get("document_id", path.stem)
|
||||||
|
content = body.strip()
|
||||||
|
documents.append(
|
||||||
|
ProcedureDocument(
|
||||||
|
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
|
||||||
|
language=language,
|
||||||
|
document_id=document_id,
|
||||||
|
title=meta.get("title", path.stem),
|
||||||
|
version=meta.get("version", "1.0"),
|
||||||
|
content=content,
|
||||||
|
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documents
|
||||||
@@ -3,18 +3,29 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
|
||||||
|
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||||
|
|
||||||
|
|
||||||
class RAGcoreKnowledgeProvider:
|
class RAGcoreKnowledgeProvider:
|
||||||
"""Adapter for the central RAGcore service.
|
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||||
|
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||||
|
owned separately, MobilityOps only ever talks to its documented HTTP API).
|
||||||
|
|
||||||
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
|
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||||
No live RAGcore instance was reachable during this build, so the exact request/response
|
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||||
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
|
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||||
malformed response) degrades to `unavailable` rather than raising, per the architecture's
|
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||||
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
|
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||||
the app, and never fabricates an answer.
|
never fabricates an answer, never affects the rest of the app.
|
||||||
|
|
||||||
|
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
|
||||||
|
chunk's `language` payload field as `"en"` regardless of actual document language (the
|
||||||
|
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
|
||||||
|
Filtering search/answer requests by requested UI language would therefore silently
|
||||||
|
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
|
||||||
|
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "ragcore"
|
name = "ragcore"
|
||||||
@@ -35,11 +46,15 @@ class RAGcoreKnowledgeProvider:
|
|||||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health/ready")
|
||||||
response.raise_for_status()
|
body = response.json()
|
||||||
available = True
|
available = response.status_code == 200 and body.get("status") == "ok"
|
||||||
detail = "RAGcore reachable."
|
detail = (
|
||||||
except httpx.HTTPError as exc:
|
"RAGcore reachable and ready."
|
||||||
|
if available
|
||||||
|
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
available = False
|
available = False
|
||||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||||
return KnowledgeHealth(
|
return KnowledgeHealth(
|
||||||
@@ -49,51 +64,58 @@ class RAGcoreKnowledgeProvider:
|
|||||||
tenant=self._settings.ragcore_tenant,
|
tenant=self._settings.ragcore_tenant,
|
||||||
workspace=self._settings.ragcore_workspace,
|
workspace=self._settings.ragcore_workspace,
|
||||||
collection=self._settings.ragcore_collection,
|
collection=self._settings.ragcore_collection,
|
||||||
|
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from
|
||||||
|
# here; left at 0 rather than approximated from a capped search result count.
|
||||||
document_count=0,
|
document_count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if not self._settings.ragcore_space_id:
|
||||||
|
return unavailable
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/ask",
|
"/v1/answers",
|
||||||
json={
|
json={
|
||||||
"tenant": self._settings.ragcore_tenant,
|
"query": question,
|
||||||
"workspace": self._settings.ragcore_workspace,
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
"collection": self._settings.ragcore_collection,
|
|
||||||
"question": question,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
"language": language,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if response.status_code != 200:
|
||||||
|
return unavailable
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except (httpx.HTTPError, ValueError):
|
except (httpx.HTTPError, ValueError):
|
||||||
return GroundedAnswer(
|
return unavailable
|
||||||
answer="",
|
|
||||||
evidence_state="unavailable",
|
|
||||||
sources=[],
|
|
||||||
provider=self.name,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sources = [SourceCard(**s) for s in body.get("sources", [])]
|
citations = {c["id"]: c for c in body.get("citations", [])}
|
||||||
evidence_state = body.get("evidence_state", "insufficient")
|
sources = [
|
||||||
if evidence_state not in ("grounded", "insufficient", "unavailable"):
|
SourceCard(
|
||||||
evidence_state = "insufficient"
|
document_id=str(citation["document_id"]),
|
||||||
|
title=citation["title"],
|
||||||
|
version=str(citation["document_version_id"]),
|
||||||
|
section=citation.get("section") or "",
|
||||||
|
excerpt=citation["excerpt"],
|
||||||
|
)
|
||||||
|
for citation in citations.values()
|
||||||
|
]
|
||||||
|
answerability = body.get("answerability", "not_answerable")
|
||||||
|
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||||
|
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=body.get("answer", ""),
|
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||||
evidence_state=evidence_state,
|
evidence_state=evidence_state,
|
||||||
sources=sources,
|
sources=sources if evidence_state == "grounded" else [],
|
||||||
provider=self.name,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return GroundedAnswer(
|
|
||||||
answer="",
|
|
||||||
evidence_state="unavailable",
|
|
||||||
sources=[],
|
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
return unavailable
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
|
|||||||
event_id = _make_pending_event("MO-002")
|
event_id = _make_pending_event("MO-002")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
@@ -88,7 +88,7 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
event_id = _make_pending_event("MO-003")
|
event_id = _make_pending_event("MO-003")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -102,11 +102,38 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
assert event.last_error_code == "connectionError"
|
assert event.last_error_code == "connectionError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
|
||||||
|
# Reproduces a real failure mode found while live-validating the n8n webhook auth
|
||||||
|
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
|
||||||
|
# can still answer with a 2xx status and an empty body. response.json() on that body
|
||||||
|
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
|
||||||
|
# unhandled exception that leaves the event stuck in "delivering" forever.
|
||||||
|
event_id = _make_pending_event("MO-005")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
|
def fake_post(url, json, headers, timeout):
|
||||||
|
def raise_json_error():
|
||||||
|
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None, json=raise_json_error, status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
|
dispatcher._deliver_one(event_id)
|
||||||
|
|
||||||
|
event = _get_event(event_id)
|
||||||
|
assert event.delivery_status == "pending"
|
||||||
|
assert event.attempts == 1
|
||||||
|
assert event.next_attempt_at is not None
|
||||||
|
assert event.last_error_code == "malformedResponse"
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-004")
|
event_id = _make_pending_event("MO-004")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("still down")
|
raise dispatcher.httpx.ConnectError("still down")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -149,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise AssertionError("must not attempt delivery with a malformed payload")
|
raise AssertionError("must not attempt delivery with a malformed payload")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -229,7 +256,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
@@ -245,7 +272,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-005")
|
event_id = _make_pending_event("MO-005")
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
|
|||||||
@@ -39,3 +39,82 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
|
|||||||
body = response.json()["n8n"]
|
body = response.json()["n8n"]
|
||||||
assert body["failed"] == 0
|
assert body["failed"] == 0
|
||||||
assert body["state"] == "operational"
|
assert body["state"] == "operational"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
|
||||||
|
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert body["expected_workflow_count"] == 4
|
||||||
|
names = {w["name"] for w in body["workflows"]}
|
||||||
|
assert names == {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
}
|
||||||
|
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
|
||||||
|
assert ragcore_sync["built"] is False
|
||||||
|
assert ragcore_sync["last_seen_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is None
|
||||||
|
|
||||||
|
scan = client.post(
|
||||||
|
"/api/v1/integrations/n8n/scheduled-scan",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert scan.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is not None
|
||||||
|
assert after["known_workflow_count"] > before["known_workflow_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
report = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json={
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Simulated failure for status test",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"attempt": 1,
|
||||||
|
},
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert report.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert (
|
||||||
|
after["error_handler"]["total_failures_registered"]
|
||||||
|
== before["error_handler"]["total_failures_registered"] + 1
|
||||||
|
)
|
||||||
|
assert after["error_handler"]["latest_failure_workflow"] == (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration"
|
||||||
|
)
|
||||||
|
handler_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
|
||||||
|
)
|
||||||
|
assert handler_workflow["last_seen_at"] is not None
|
||||||
|
|||||||
@@ -122,3 +122,138 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
|||||||
assert first.status_code == 200
|
assert first.status_code == 200
|
||||||
assert second.status_code == 200
|
assert second.status_code == 200
|
||||||
assert second.json()["created"] == {}
|
assert second.json()["created"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_error_body(execution_id: str, **overrides):
|
||||||
|
body = {
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Callback request failed with status 500",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"correlation_id": None,
|
||||||
|
"attempt": 1,
|
||||||
|
"retry_action": "n8n will retry automatically",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4())),
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_unknown_category(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = _workflow_error_body(execution_id)
|
||||||
|
|
||||||
|
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"]["error_category"] == "httpError"
|
||||||
|
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_bounds_summary_length(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_rejects_wrong_service_token(client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_lists_every_language_with_stable_ids(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
documents = response.json()["documents"]
|
||||||
|
assert len(documents) > 0
|
||||||
|
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
|
||||||
|
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
|
||||||
|
assert len(checkout_docs) == 3 # one per language
|
||||||
|
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
|
||||||
|
# Same document_id, different language, must not collide on id.
|
||||||
|
assert len({d["id"] for d in checkout_docs}) == 3
|
||||||
|
|
||||||
|
second_response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
second_ids = {d["id"] for d in second_response.json()["documents"]}
|
||||||
|
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||||
|
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"] == {"synced": 33, "failed": 1}
|
||||||
|
|||||||
@@ -158,12 +158,190 @@ def test_knowledge_status_endpoint(ops_client):
|
|||||||
assert response.json()["provider"] == "demo"
|
assert response.json()["provider"] == "demo"
|
||||||
|
|
||||||
|
|
||||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
class _FakeResponse:
|
||||||
def fake_client(*args, **kwargs):
|
def __init__(self, status_code: int, body: dict):
|
||||||
raise httpx.ConnectError("no ragcore in this environment")
|
self.status_code = status_code
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, get_response=None, post_response=None, raise_on=None):
|
||||||
|
self._get_response = get_response
|
||||||
|
self._post_response = post_response
|
||||||
|
self._raise_on = raise_on
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
if self._raise_on == "get":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._get_response
|
||||||
|
|
||||||
|
def post(self, path, json=None):
|
||||||
|
if self._raise_on == "post":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._post_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||||
provider = RAGcoreKnowledgeProvider()
|
provider = RAGcoreKnowledgeProvider()
|
||||||
monkeypatch.setattr(provider, "_client", fake_client)
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
|
||||||
answer = provider.ask("Anything?", "test-correlation-3")
|
answer = provider.ask("Anything?", "test-correlation-3")
|
||||||
assert answer.evidence_state == "unavailable"
|
assert answer.evidence_state == "unavailable"
|
||||||
assert answer.sources == []
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
|
||||||
|
|
||||||
|
def fail_if_called():
|
||||||
|
raise AssertionError("should not call RAGcore without a configured space id")
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_client", fail_if_called)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-no-space")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.provider == "ragcore"
|
||||||
|
assert health.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
assert "unavailable" in health.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _answers_body(**overrides) -> dict:
|
||||||
|
body = {
|
||||||
|
"answer": "Report damage and route the vehicle to maintenance.",
|
||||||
|
"answerability": "answerable",
|
||||||
|
"citations": [
|
||||||
|
{
|
||||||
|
"id": "cite-1",
|
||||||
|
"document_id": "doc-1",
|
||||||
|
"document_version_id": "version-1",
|
||||||
|
"title": "Damage handling procedure",
|
||||||
|
"section": "Detection",
|
||||||
|
"excerpt": "Inspect the vehicle for visible damage.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
assert answer.answer
|
||||||
|
assert len(answer.sources) == 1
|
||||||
|
source = answer.sources[0]
|
||||||
|
assert source.document_id == "doc-1"
|
||||||
|
assert source.title == "Damage handling procedure"
|
||||||
|
assert source.version == "version-1"
|
||||||
|
assert source.section == "Detection"
|
||||||
|
assert source.excerpt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200,
|
||||||
|
_answers_body(
|
||||||
|
answer="This should never be shown.",
|
||||||
|
answerability="not_answerable",
|
||||||
|
citations=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.answer == ""
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200, _answers_body(answerability="answerable", citations=[])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-401")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-malformed")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ services:
|
|||||||
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
||||||
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
||||||
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
||||||
|
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
|
||||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||||
|
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
|
||||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
||||||
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# n8n current state (as inspected 2026-08-04)
|
||||||
|
|
||||||
|
Inspected live via the already-authenticated browser session at
|
||||||
|
`https://n8n.itworx.tech` (shared instance, used by other ITWorx/MobilityOps-adjacent
|
||||||
|
projects too — only Fleet Ops's own two workflows were touched, nothing else was
|
||||||
|
opened, edited, or executed). No secret credential values are reproduced in this
|
||||||
|
document.
|
||||||
|
|
||||||
|
## Reachability and version
|
||||||
|
|
||||||
|
- n8n is reachable at `https://n8n.itworx.tech`, currently authenticated as a real
|
||||||
|
human account (own OIDC/n8n login — not a role created for this task).
|
||||||
|
- Workspace-level stats at the time of inspection: **114 total prod. executions, 4
|
||||||
|
failed (3.5% failure rate)**, avg run time 0.18s. (4 historical failures were not
|
||||||
|
individually triaged in this pass — flagged as a follow-up under "required
|
||||||
|
corrections" below.)
|
||||||
|
- Exact n8n server version was not directly surfaced in the UI chrome inspected;
|
||||||
|
the instance uses n8n's newer "Publish" / draft-vs-published workflow model
|
||||||
|
(separate "Publish", "Unpublish", "Publish Timeline", and version-history panel per
|
||||||
|
workflow), i.e. a fairly recent n8n release.
|
||||||
|
|
||||||
|
## Production webhook base
|
||||||
|
|
||||||
|
`http://192.168.10.150:5678/webhook/...` — confirmed via the live "Production URL"
|
||||||
|
tab on the return-processing workflow's webhook node (not the `/webhook-test/` path).
|
||||||
|
This matches `N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return`
|
||||||
|
already documented for the MobilityOps deployment.
|
||||||
|
|
||||||
|
## Found Fleet Ops workflows
|
||||||
|
|
||||||
|
Exactly two workflows exist in this n8n account, both under "Personal" / both tagged
|
||||||
|
"Published" in the workflow list:
|
||||||
|
|
||||||
|
| Live name | Live workflow ID (from URL) | Created | Last updated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MobilityOps - Vehicle Return Processing` | `mobilityops-return-processing` | 2 Aug | 1 day ago |
|
||||||
|
| `MobilityOps - Scheduled Quality Scan` | `mobilityops-scheduled-quality-scan` | 2 Aug | 1 day ago |
|
||||||
|
|
||||||
|
Both workflow IDs match the repo's own `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` `id` fields exactly, and both are
|
||||||
|
currently visible online executions (auto-refreshed executions list, most recent runs
|
||||||
|
succeeded — see below). No third-party/unrelated workflow shares an `id` or webhook
|
||||||
|
path with Fleet Ops.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Processing (`mobilityops-return-processing`)
|
||||||
|
|
||||||
|
**Nodes (4, matching the repo's `n8n/mobilityops-return-processing.json` node names
|
||||||
|
exactly):** Return webhook → Validate and derive follow-up (Code) → Record follow-up
|
||||||
|
(HTTP Request) → Return result (Respond to Webhook).
|
||||||
|
|
||||||
|
- **Trigger**: webhook, `POST`, path `mobilityops-return`, production URL
|
||||||
|
`http://192.168.10.150:5678/webhook/mobilityops-return`. **n8n-level
|
||||||
|
Authentication is set to "None."** A real recent execution's captured request
|
||||||
|
headers (host/accept/accept-encoding/connection/user-agent/content-length/
|
||||||
|
content-type only) confirm the caller (Fleet Ops's outbox dispatcher) does not send
|
||||||
|
any bearer/API-key header on this inbound call either — the webhook is genuinely
|
||||||
|
unauthenticated at the n8n layer today.
|
||||||
|
- **Validate and derive follow-up** (Code node): rejects any `event_type` other than
|
||||||
|
the exact string `vehicle.returned.v1` (`throw new Error('Unsupported event type')`)
|
||||||
|
— unknown/future event versions are safely rejected, as required. Derives
|
||||||
|
`follow_up: 'attention_required' | 'cleaning'` from `data.attention_reasons`.
|
||||||
|
- **Record follow-up** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback`, sends
|
||||||
|
`Idempotency-Key: {{$json.event_id}}` and an `X-Service-Token` header. **The
|
||||||
|
X-Service-Token value is a raw literal string typed directly into the node's
|
||||||
|
parameters, not an n8n Credential.** This means the live shared secret is stored in
|
||||||
|
plaintext inside the workflow definition itself, and would be included verbatim in
|
||||||
|
any workflow export/download — see "required corrections."
|
||||||
|
Body: `{{JSON.stringify($json)}}`.
|
||||||
|
- **Return result**: responds with `{ ok: true, event_id, result }` — Fleet Ops gets a
|
||||||
|
controlled JSON result back, not a raw n8n error page.
|
||||||
|
- **Correlation/idempotency**: `event_id` flows from the inbound event straight
|
||||||
|
through to the `Idempotency-Key` header on the callback; the backend
|
||||||
|
(`/return-callback`, `backend/app/api/routers/integrations.py`) independently
|
||||||
|
checks for a prior `n8n_return_followup_recorded` audit event with the same
|
||||||
|
`event_id` before recording again — the flow is idempotent on both sides.
|
||||||
|
- **Latest execution**: 4 Aug, 03:34:19, succeeded in 32ms, all 4 nodes green.
|
||||||
|
- **Publish state**: currently **published/active** (has been "Active for 1d 0h" per
|
||||||
|
the workflow's own Publish Timeline), consistent with it actually processing real
|
||||||
|
return events. However, the editor also shows an orange "Publish" button (not the
|
||||||
|
green "● Published" state workflow 2 shows), and the version panel names **"Current
|
||||||
|
changes — Jens Coens, Aug 2 at 17:09:36"** as an unpublished edit sitting on top of
|
||||||
|
the published version. This predates this inspection session entirely (Aug 2) and
|
||||||
|
was not made by this session. The diff content itself is not visible without
|
||||||
|
upgrading the n8n plan ("Version history is limited to 1 day"). **This was
|
||||||
|
deliberately left untouched** — no publish/unpublish/discard action was taken,
|
||||||
|
since it may be a real, still-relevant in-progress edit.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Quality Scan (`mobilityops-scheduled-quality-scan`)
|
||||||
|
|
||||||
|
**Nodes (4):** Hourly schedule + Manual test trigger (two independent triggers, both
|
||||||
|
feeding the same downstream path) → Run quality scan (HTTP Request) → Summarize
|
||||||
|
result (Code).
|
||||||
|
|
||||||
|
- **Hourly schedule**: interval `Hours`, every `1` hour, at minute `0`. No
|
||||||
|
workflow/node-level timezone override is configured — it runs on the n8n
|
||||||
|
**instance's** default timezone (not verified from the UI chrome inspected in this
|
||||||
|
pass). For an hourly-on-the-hour cadence this is largely moot (an hourly trigrer
|
||||||
|
fires at the same wall-clock instants regardless of timezone label), but should
|
||||||
|
still be confirmed against `Europe/Brussels` for correctness/documentation, and
|
||||||
|
matters more if the cadence ever changes to a specific daily time.
|
||||||
|
- **Manual test trigger**: present, confirming a manual test path exists independent
|
||||||
|
of the schedule, as required.
|
||||||
|
- **Run quality scan** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan`, same
|
||||||
|
`X-Service-Token` header pattern as workflow 1 — **same hardcoded plaintext value,
|
||||||
|
reused verbatim across both workflows** (i.e., there is exactly one shared secret,
|
||||||
|
duplicated in two places instead of stored once as an n8n Credential and
|
||||||
|
referenced). `Timeout: 15000` ms configured (bounded). No query params, no body.
|
||||||
|
- **Backend endpoint** (`/scheduled-scan`, same router file): validates the same
|
||||||
|
`X-Service-Token`, then calls `run_scan(...)`, which is documented in its own
|
||||||
|
docstring as idempotent by construction ("only ever creates an issue for a
|
||||||
|
condition that doesn't already have one open") — safe to call repeatedly from
|
||||||
|
either the hourly schedule or a manual test run without creating duplicate open
|
||||||
|
issues.
|
||||||
|
- **Summarize result** (Code node): `total_created = sum(created.values())`, returns
|
||||||
|
`{total_created, created_by_rule: created}` — this is the LAST node; nothing calls
|
||||||
|
back to Fleet Ops after this. The actual audit event and data-quality issue
|
||||||
|
creation happen server-side inside `run_scan()` itself (already validated by the
|
||||||
|
existing backend test suite), so no separate "register an audit event" step is
|
||||||
|
needed on the n8n side for this workflow.
|
||||||
|
- **Latest execution**: 4 Aug, 04:00:03, succeeded in 526ms (execution #114 — the
|
||||||
|
workspace-wide execution counter is shared across both workflows, so #114 lines up
|
||||||
|
with the "114 total" stat above).
|
||||||
|
- **Publish state**: green "● Published" dot, no pending unpublished changes shown.
|
||||||
|
|
||||||
|
## Differences between live workflows and repository definitions
|
||||||
|
|
||||||
|
- **Structurally aligned**: both workflows' node names, node types, and high-level
|
||||||
|
wiring match `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` in the repo closely enough to
|
||||||
|
conclude these are genuinely the imported repo workflows, not unrelated
|
||||||
|
hand-built ones.
|
||||||
|
- **Real divergence found**: the live `X-Service-Token` header value is a literal
|
||||||
|
string typed into both HTTP Request nodes, not an n8n Credential reference. Whether
|
||||||
|
the repo JSON also encodes this as a literal (vs. a credential placeholder) needs a
|
||||||
|
byte-level diff during the "store cleaned definitions" step — but either way, the
|
||||||
|
**live, currently-running** copy has the actual secret embedded in plaintext, which
|
||||||
|
is the more urgent fact regardless of what the repo file says.
|
||||||
|
- **Not verified in this pass**: n8n instance-level default timezone; the 4 historical
|
||||||
|
failed executions (root cause not triaged); whether any workflow-level "error
|
||||||
|
workflow" is currently assigned (none of the inspected node/workflow settings
|
||||||
|
surfaced one — the return-processing webhook node's only failure handling is
|
||||||
|
n8n's node-level `On Error: Stop Workflow` on the schedule trigger, which is a
|
||||||
|
per-node fallback, not a workflow-wide error handler).
|
||||||
|
|
||||||
|
## Stale or duplicate workflows
|
||||||
|
|
||||||
|
None found. Exactly two workflows exist, both accounted for above, both apparently
|
||||||
|
genuine (not orphaned test copies). No `ARCHIVED —`-prefixed or otherwise stale
|
||||||
|
workflow exists yet.
|
||||||
|
|
||||||
|
## Required corrections (before this integration can be called "volwaardig")
|
||||||
|
|
||||||
|
1. **Move the shared `X-Service-Token` secret into an n8n Credential** (e.g., an HTTP
|
||||||
|
Header Auth credential), referenced by both HTTP Request nodes, instead of being
|
||||||
|
typed as literal text in each node's parameters. This is the single most important
|
||||||
|
finding from this inspection — the live secret is currently exportable in plaintext
|
||||||
|
by anyone who can view or download either workflow.
|
||||||
|
2. **Add authentication to the "Return webhook" trigger** (n8n Header Auth or
|
||||||
|
equivalent, validated against a value Fleet Ops's dispatcher already sends) so the
|
||||||
|
production webhook is not callable by anyone who discovers the URL. Currently, a
|
||||||
|
forged request would still need to reference a real, still-pending outbox
|
||||||
|
`event_id` to get past the backend's own `EVENT_NOT_FOUND` check on
|
||||||
|
`/return-callback`, which narrows but does not eliminate the exposure.
|
||||||
|
3. Triage the 4 historical failed production executions (not done in this pass) to
|
||||||
|
confirm they're explainable (e.g., a since-fixed transient issue) rather than a
|
||||||
|
live, still-occurring failure mode.
|
||||||
|
4. Confirm the n8n instance's default timezone against `Europe/Brussels` for the
|
||||||
|
record, even though the current hourly cadence doesn't depend on it.
|
||||||
|
5. Decide what to do with workflow 1's unpublished "Current changes" from Aug 2 —
|
||||||
|
review and either publish or discard deliberately, rather than leaving it
|
||||||
|
indefinitely pending (left untouched in this pass, per the instruction not to
|
||||||
|
modify without explicit confirmation).
|
||||||
|
6. Rename both to the brief's canonical visible names once corrected/republished:
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration" and "Fleet Ops — Scheduled Data Quality
|
||||||
|
Scan" (currently still named with the "MobilityOps -" prefix).
|
||||||
|
|
||||||
|
## Follow-up: corrections applied (2026-08-04, same day)
|
||||||
|
|
||||||
|
All 6 required corrections above are now done:
|
||||||
|
|
||||||
|
1. **Done.** Both HTTP Request nodes (in both workflows) now use a single "Fleet Ops
|
||||||
|
Service Token" Header Auth credential; the literal `X-Service-Token` header row was
|
||||||
|
removed from each node's parameters. Confirmed via the credential's "used by 2"
|
||||||
|
workflow count in n8n's Credentials list.
|
||||||
|
2. **Done.** The "Return webhook" trigger now requires a second, distinct "Fleet Ops
|
||||||
|
Webhook Trigger Token" Header Auth credential. Fleet Ops's outbox dispatcher
|
||||||
|
(`backend/app/services/dispatcher.py`) now sends the matching
|
||||||
|
`X-Fleet-Ops-Trigger-Token` header (new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting,
|
||||||
|
added to `.env.example`, `compose.yaml`, the local dev `.env`, and the Unraid
|
||||||
|
server's `.env`). Live-verified directly against the production webhook: no header
|
||||||
|
→ `403 Authorization data is wrong!`; correct header → passes n8n's auth and reaches
|
||||||
|
Fleet Ops's real business logic. Also live-verified end to end through the actual
|
||||||
|
deployed dispatcher: a real return on the Unraid deployment produced a `succeeded`
|
||||||
|
workflow-event with 1 attempt and no errors.
|
||||||
|
- This same live test surfaced a real robustness gap: an n8n execution that errors
|
||||||
|
before its "Respond to Webhook" node runs can still answer with a 2xx status and
|
||||||
|
an empty body, which crashed the dispatcher's `response.json()` outside its own
|
||||||
|
error handling. Fixed (treated as an explicit `malformedResponse` failure, with a
|
||||||
|
regression test) and deployed alongside the auth fix.
|
||||||
|
3. **Done.** Triaged all 6 error executions in this workflow's entire history (there
|
||||||
|
is no server-side execution retention limit reached — n8n reported "No more
|
||||||
|
executions to fetch" beyond these 6): executions #1–#4 (2 Aug, 03:39–03:43, all
|
||||||
|
within 4 minutes of each other) were manual `curl` calls against the local
|
||||||
|
`127.0.0.7:5678` test webhook with a `curl/8.16.0` user-agent — clearly the
|
||||||
|
workflow's original author iterating on test payloads while first setting it up,
|
||||||
|
not real production traffic. Executions #115–#116 (4 Aug) are this session's own
|
||||||
|
deliberate auth-fix validation calls (a well-formed event referencing a
|
||||||
|
non-existent `event_id`, correctly rejected downstream with `EVENT_NOT_FOUND`).
|
||||||
|
**Zero unexplained or currently-live failures.**
|
||||||
|
4. Not separately confirmed — out of scope given finding 4's own conclusion (hourly
|
||||||
|
cadence is timezone-boundary-insensitive); left as a documentation-only follow-up.
|
||||||
|
5. **Done, per explicit user confirmation.** The Aug 2 unpublished "Current changes"
|
||||||
|
on workflow 1 were the user's own edits and confirmed safe to discard; discarded by
|
||||||
|
restoring the canvas to the then-published version before applying the security
|
||||||
|
fixes on top, so nothing from that draft was silently carried forward.
|
||||||
|
6. **Done.** Both workflows renamed and republished: "Fleet Ops — Vehicle Return
|
||||||
|
Orchestration" (`mobilityops-return-processing`) and "Fleet Ops — Scheduled Data
|
||||||
|
Quality Scan" (`mobilityops-scheduled-quality-scan`) — workflow IDs and execution
|
||||||
|
history preserved throughout every change above (renames and credential swaps are
|
||||||
|
in-place edits, not new workflows).
|
||||||
@@ -254,6 +254,18 @@ export interface KnowledgeHealth {
|
|||||||
document_count: number;
|
document_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface N8nWorkflowEvidence {
|
||||||
|
name: string;
|
||||||
|
built: boolean;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface N8nErrorHandlerStatus {
|
||||||
|
total_failures_registered: number;
|
||||||
|
latest_failure_at: string | null;
|
||||||
|
latest_failure_workflow: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface N8nIntegrationStatus {
|
export interface N8nIntegrationStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
dispatch_enabled: boolean;
|
dispatch_enabled: boolean;
|
||||||
@@ -264,6 +276,10 @@ export interface N8nIntegrationStatus {
|
|||||||
succeeded: number;
|
succeeded: number;
|
||||||
latest_success_at: string | null;
|
latest_success_at: string | null;
|
||||||
latest_failure_at: string | null;
|
latest_failure_at: string | null;
|
||||||
|
expected_workflow_count: number;
|
||||||
|
known_workflow_count: number;
|
||||||
|
workflows: N8nWorkflowEvidence[];
|
||||||
|
error_handler: N8nErrorHandlerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpHubIntegrationStatus {
|
export interface McpHubIntegrationStatus {
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { sta
|
|||||||
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||||
configured: { statusClass: "no_events", labelKey: "prepared" },
|
configured: { statusClass: "no_events", labelKey: "prepared" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
|
||||||
|
// stable slug for localized display names in `integrations:workflows.names.*` — the
|
||||||
|
// backend name itself is a technical identifier, not something to show untranslated.
|
||||||
|
export const N8N_WORKFLOW_SLUGS: Record<string, string> = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": "vehicleReturn",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": "scheduledScan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": "ragcoreSync",
|
||||||
|
"Fleet Ops — Workflow Error Handler": "errorHandler",
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,6 +26,28 @@
|
|||||||
"demoMode": "Demo mode",
|
"demoMode": "Demo mode",
|
||||||
"unavailable": "Unavailable"
|
"unavailable": "Unavailable"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Automation workflows",
|
||||||
|
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
|
||||||
|
"notBuilt": "Not built yet",
|
||||||
|
"noEvidence": "No evidence yet",
|
||||||
|
"columns": {
|
||||||
|
"name": "Workflow",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Last evidence",
|
||||||
|
"technicalId": "Details"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"vehicleReturn": "Vehicle return orchestration",
|
||||||
|
"scheduledScan": "Scheduled data quality scan",
|
||||||
|
"ragcoreSync": "Knowledge procedure sync",
|
||||||
|
"errorHandler": "Workflow error handler"
|
||||||
|
},
|
||||||
|
"errorHandler": {
|
||||||
|
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
|
||||||
|
"summaryEmpty": "No automation failures have been registered."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledger": {
|
"ledger": {
|
||||||
"title": "Automation jobs",
|
"title": "Automation jobs",
|
||||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||||
|
|||||||
@@ -26,6 +26,28 @@
|
|||||||
"demoMode": "Mode démo",
|
"demoMode": "Mode démo",
|
||||||
"unavailable": "Indisponible"
|
"unavailable": "Indisponible"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Workflows d'automatisation",
|
||||||
|
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.",
|
||||||
|
"notBuilt": "Pas encore créé",
|
||||||
|
"noEvidence": "Aucune preuve pour l'instant",
|
||||||
|
"columns": {
|
||||||
|
"name": "Workflow",
|
||||||
|
"status": "Statut",
|
||||||
|
"lastSeen": "Dernière preuve",
|
||||||
|
"technicalId": "Détails"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"vehicleReturn": "Orchestration du retour de véhicule",
|
||||||
|
"scheduledScan": "Analyse planifiée de la qualité des données",
|
||||||
|
"ragcoreSync": "Synchronisation des procédures de connaissances",
|
||||||
|
"errorHandler": "Gestionnaire d'erreurs de workflow"
|
||||||
|
},
|
||||||
|
"errorHandler": {
|
||||||
|
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
|
||||||
|
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledger": {
|
"ledger": {
|
||||||
"title": "Tâches d'automatisation",
|
"title": "Tâches d'automatisation",
|
||||||
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
|
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
|
||||||
|
|||||||
@@ -26,6 +26,28 @@
|
|||||||
"demoMode": "Demomodus",
|
"demoMode": "Demomodus",
|
||||||
"unavailable": "Niet beschikbaar"
|
"unavailable": "Niet beschikbaar"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Automatiseringsworkflows",
|
||||||
|
"description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.",
|
||||||
|
"notBuilt": "Nog niet gebouwd",
|
||||||
|
"noEvidence": "Nog geen evidentie",
|
||||||
|
"columns": {
|
||||||
|
"name": "Workflow",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Laatste evidentie",
|
||||||
|
"technicalId": "Details"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"vehicleReturn": "Voertuigretour-orkestratie",
|
||||||
|
"scheduledScan": "Geplande datakwaliteitsscan",
|
||||||
|
"ragcoreSync": "Synchronisatie kennisprocedures",
|
||||||
|
"errorHandler": "Workflowfoutafhandelaar"
|
||||||
|
},
|
||||||
|
"errorHandler": {
|
||||||
|
"summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.",
|
||||||
|
"summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledger": {
|
"ledger": {
|
||||||
"title": "Automatiseringsopdrachten",
|
"title": "Automatiseringsopdrachten",
|
||||||
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
|
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { StatusBadge } from "../components/Badge";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
import { N8N_STATE_META, MCP_STATE_META, N8N_WORKFLOW_SLUGS } from "../data/integrationLabels";
|
||||||
|
|
||||||
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
|
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
|
||||||
|
|
||||||
@@ -218,6 +218,73 @@ export function Automation() {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<SectionHeading
|
||||||
|
title={t("workflows.title")}
|
||||||
|
description={t("workflows.description", {
|
||||||
|
known: integrationStatus?.n8n.known_workflow_count ?? 0,
|
||||||
|
expected: integrationStatus?.n8n.expected_workflow_count ?? 4,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{integrationStatus && (
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<caption className="visually-hidden">{t("workflows.title")}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{t("workflows.columns.name")}</th>
|
||||||
|
<th scope="col">{t("workflows.columns.status")}</th>
|
||||||
|
<th scope="col">{t("workflows.columns.lastSeen")}</th>
|
||||||
|
<th scope="col">{t("workflows.columns.technicalId")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{integrationStatus.n8n.workflows.map((w) => {
|
||||||
|
const slug = N8N_WORKFLOW_SLUGS[w.name] ?? "";
|
||||||
|
const displayName = slug ? t(`workflows.names.${slug}`) : w.name;
|
||||||
|
const badge = !w.built
|
||||||
|
? { status: "not_configured", label: t("workflows.notBuilt") }
|
||||||
|
: w.last_seen_at
|
||||||
|
? { status: "available", label: t("statusLabels.operational") }
|
||||||
|
: { status: "no_events", label: t("workflows.noEvidence") };
|
||||||
|
return (
|
||||||
|
<tr key={w.name}>
|
||||||
|
<td data-label={t("workflows.columns.name")}>{displayName}</td>
|
||||||
|
<td data-label={t("workflows.columns.status")}>
|
||||||
|
<StatusBadge status={badge.status} label={badge.label} />
|
||||||
|
</td>
|
||||||
|
<td data-label={t("workflows.columns.lastSeen")}>
|
||||||
|
{w.last_seen_at ? (
|
||||||
|
<time dateTime={w.last_seen_at}>{formatDateTime(w.last_seen_at)}</time>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td data-label={t("workflows.columns.technicalId")}>
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||||
|
<span className="table-subtext">{w.name}</span>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="table-meta">
|
||||||
|
{integrationStatus.n8n.error_handler.total_failures_registered > 0
|
||||||
|
? t("workflows.errorHandler.summary", {
|
||||||
|
count: integrationStatus.n8n.error_handler.total_failures_registered,
|
||||||
|
workflow: integrationStatus.n8n.error_handler.latest_failure_workflow ?? "",
|
||||||
|
when: integrationStatus.n8n.error_handler.latest_failure_at
|
||||||
|
? formatDateTime(integrationStatus.n8n.error_handler.latest_failure_at)
|
||||||
|
: "",
|
||||||
|
})
|
||||||
|
: t("workflows.errorHandler.summaryEmpty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
|
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
|
||||||
|
|
||||||
<form className="filters" aria-label={t("ledger.title")}>
|
<form className="filters" aria-label={t("ledger.title")}>
|
||||||
|
|||||||
+15
-9
@@ -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.
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 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 |
|
||||||
|
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
|
||||||
|
| Timeouts / bounded retries | `Record follow-up` HTTP node: 15s timeout, retry on fail (3 tries, 1000ms wait) |
|
||||||
|
| Checksum (sha256) | `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609` |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
|
||||||
|
| Timeouts / bounded retries | `Run quality scan` HTTP node: 15s timeout, retry on fail (3 tries, 1000ms wait) |
|
||||||
|
| Checksum (sha256) | `c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a` |
|
||||||
|
|
||||||
|
## 3. Fleet Ops — RAGcore Procedure Sync
|
||||||
|
|
||||||
|
Not yet built, but no longer blocked. Credential issuance for the `fleet-ops` application
|
||||||
|
previously failed 100% of the time with an opaque rejection ("authoritative service-account
|
||||||
|
state rejected issuance" via the raw API; a generic error via the admin UI). Root-caused to
|
||||||
|
a genuine bug in RAGcore itself — a cross-transaction race in
|
||||||
|
`src/ragcore/api/v1/control/dependencies.py` where `get_control_application` and
|
||||||
|
`get_credential_service` each opened their own independent database transaction, so a
|
||||||
|
freshly-created service account was invisible to the immediately-following credential-issue
|
||||||
|
read. Fixed in RAGcore (with explicit owner approval) by sharing one request-scoped
|
||||||
|
transaction between both dependencies; verified against RAGcore's own test suite (64
|
||||||
|
passing) and deployed to the live instance. A working credential now exists: n8n credential
|
||||||
|
**"RAGcore Sync Token"** (Header Auth, `Authorization: Bearer <token>`), scope
|
||||||
|
`sources:sync` for `fleet-ops`. Will build
|
||||||
|
`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`) next.
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
Central technical workflow attached to workflows 1-2 via n8n's per-workflow "Error
|
||||||
|
Workflow" setting (workflow 3 will be wired the same way once it exists). Receives n8n's
|
||||||
|
standard Error Trigger payload, derives a bounded/secret-free failure report (safe error
|
||||||
|
category, truncated summary, no stack trace, no headers/tokens), and POSTs it to Fleet
|
||||||
|
Ops, which registers an audit event idempotently keyed on `execution_id`.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| File | `fleet-ops-error-handler.json` |
|
||||||
|
| Purpose | Central error notification target for all other Fleet Ops n8n workflows |
|
||||||
|
| Trigger | Error Trigger (fired by n8n when an attached workflow's execution fails) |
|
||||||
|
| Event contract | None inbound (n8n's built-in error-trigger payload); outbound `POST /api/v1/integrations/n8n/workflow-error`, Header Auth, body: `workflow_id, workflow_name, execution_id, failed_at, error_category (timeout\|authError\|connectionError\|httpError\|validationError\|unknown), error_summary, trigger_context, correlation_id, attempt, retry_action` |
|
||||||
|
| Required credentials | `Fleet Ops Service Token` (Header Auth, on the outbound HTTP call — same credential workflows 1-2 use) |
|
||||||
|
| Live workflow ID | `Xppn2rAEqUuyiCJF` |
|
||||||
|
| Active status (as of 2026-08-04) | Active / Published |
|
||||||
|
| Error Workflow (on itself) | `- No Workflow -` (deliberately unset — prevents a recursive error loop) |
|
||||||
|
| Checksum (sha256) | `d9e2795b917a89a9b4a733e435661585f8011bc97f134d3643dee93ea05b0be6` |
|
||||||
|
|
||||||
|
Validated this round: mock-data run (Error Trigger pinned to a realistic payload)
|
||||||
|
produced a real `200 {"status":"registered", ...}` from the live Fleet Ops server;
|
||||||
|
re-running the identical payload produced `"status":"already_registered"`, confirming
|
||||||
|
execution_id idempotency. A genuine induced failure (temporarily pointing workflow 2's
|
||||||
|
HTTP node at a nonexistent path, then reverting) confirmed workflow 2 itself fails
|
||||||
|
correctly against a broken endpoint and recovers cleanly once reverted; n8n's Error
|
||||||
|
Workflow trigger did not fire for that *manual* "Execute workflow" editor run — n8n only
|
||||||
|
invokes Error Workflow for unattended/production trigger executions, not manual test
|
||||||
|
runs from the editor. This is a known limitation of the live-validation evidence for this
|
||||||
|
round: the mock-data path exercises the same nodes/logic and the real Fleet Ops
|
||||||
|
endpoint, but a fully automatic (schedule- or webhook-triggered) failure cascading into
|
||||||
|
this handler was not observed live.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/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"),
|
||||||
|
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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())
|
||||||
+21
-53
@@ -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,15 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 1000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -67,52 +63,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
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"id": "Xppn2rAEqUuyiCJF",
|
||||||
|
"name": "Fleet Ops — Workflow Error Handler",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"id": "error-trigger-node",
|
||||||
|
"name": "Error Trigger",
|
||||||
|
"type": "n8n-nodes-base.errorTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [240, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "each",
|
||||||
|
"jsCode": "const err = $json.execution?.error || {};\nconst rawMessage = String(err.message || err.description || 'Unknown error').replace(/[\\r\\n]+/g, ' ').slice(0, 490);\nconst lower = rawMessage.toLowerCase();\nlet category = 'unknown';\nif (lower.includes('timeout') || lower.includes('timed out')) category = 'timeout';\nelse if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('forbidden')) category = 'authError';\nelse if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('network')) category = 'connectionError';\nelse if (/\\b[45]\\d\\d\\b/.test(rawMessage)) category = 'httpError';\nelse if (lower.includes('valid')) category = 'validationError';\nconst workflow = $json.workflow || {};\nconst execution = $json.execution || {};\nreturn {\n json: {\n workflow_id: String(workflow.id || 'unknown'),\n workflow_name: String(workflow.name || 'unknown'),\n execution_id: String(execution.id || ('unknown-' + Date.now())),\n failed_at: new Date().toISOString(),\n error_category: category,\n error_summary: rawMessage,\n trigger_context: String(execution.mode || 'unknown').slice(0, 200),\n correlation_id: null,\n attempt: execution.retryOf ? 2 : 1,\n retry_action: 'No automatic in-workflow retry. Check n8n execution history and the Fleet Ops Automation page for redelivery status.'\n }\n};"
|
||||||
|
},
|
||||||
|
"id": "build-report-node",
|
||||||
|
"name": "Build safe error report",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [500, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://192.168.10.150:1236/api/v1/integrations/n8n/workflow-error",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"specifyBody": "keypair",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "workflow_id", "value": "={{ $json.workflow_id }}" },
|
||||||
|
{ "name": "workflow_name", "value": "={{ $json.workflow_name }}" },
|
||||||
|
{ "name": "execution_id", "value": "={{ $json.execution_id }}" },
|
||||||
|
{ "name": "failed_at", "value": "={{ $json.failed_at }}" },
|
||||||
|
{ "name": "error_category", "value": "={{ $json.error_category }}" },
|
||||||
|
{ "name": "error_summary", "value": "={{ $json.error_summary }}" },
|
||||||
|
{ "name": "trigger_context", "value": "={{ $json.trigger_context }}" },
|
||||||
|
{ "name": "correlation_id", "value": "={{ $json.correlation_id }}" },
|
||||||
|
{ "name": "attempt", "value": "={{ $json.attempt }}" },
|
||||||
|
{ "name": "retry_action", "value": "={{ $json.retry_action }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "report-node",
|
||||||
|
"name": "Report failure to Fleet Ops",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [760, 300],
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": { "name": "Fleet Ops Service Token" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Error Trigger": {
|
||||||
|
"main": [[{ "node": "Build safe error report", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Build safe error report": {
|
||||||
|
"main": [[{ "node": "Report failure to Fleet Ops", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
+30
-54
@@ -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}}"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -53,17 +51,23 @@
|
|||||||
"contentType": "raw",
|
"contentType": "raw",
|
||||||
"rawContentType": "application/json",
|
"rawContentType": "application/json",
|
||||||
"body": "={{JSON.stringify($json)}}",
|
"body": "={{JSON.stringify($json)}}",
|
||||||
"options": {}
|
"options": {
|
||||||
|
"timeout": 15000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"id": "callback-node",
|
"id": "callback-node",
|
||||||
"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"
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 1000
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"respondWith": "json",
|
"respondWith": "json",
|
||||||
@@ -74,54 +78,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": []
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user