Author SHA1 Message Date
NuklearRabbit ad1182582d docs: record visual roadmap evidence 2026-08-10 01:05:30 +02:00
NuklearRabbit f2cdad194c UX: implement visual product roadmap 2026-08-10 01:05:07 +02:00
NuklearRabbitandClaude Sonnet 5 13ad2ba6a3 docs: record the MCP Hub URL fix and RAGcore Procedure Sync go-live
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:07:41 +02:00
NuklearRabbitandClaude Sonnet 5 086dfed992 fix: publish RAGcore Procedure Sync and derive its evidence for real
MCP_HUB_BASE_URL had the same wrong-hostname bug as RAGCORE_BASE_URL earlier
this session (itworx-mcp-hub:8000 doesn't resolve; the real container is
reachable at the host's own 192.168.10.150:1100) -- fixed live, resolving the
Automation page showing "Operationeel" and "Hub Onbereikbaar" simultaneously.

Went on to actually publish the "Fleet Ops -- RAGcore Procedure Sync" n8n
workflow now that RAGcore is reachable: its own RAGcore Sync Token credential
had gone stale from the same rotation as the earlier one, so minted a fresh,
dedicated, minimally-scoped (sources:sync only) credential, verified a real
manual run (33 synced, 0 failed, result registered) before publishing.

That exposed a real, now-stale bug: derive_n8n_status() hardcoded this
workflow's evidence to None with a comment explaining it was unpublished --
true when written, false now. The workflow's own result-report callback
already writes a real n8n_procedures_synced audit event; wired that in as its
evidence source, the same pattern the scheduled scan and error handler already
use, instead of a value that could never update itself once the workflow went
live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:05:13 +02:00
NuklearRabbitandClaude Sonnet 5 64cc96fa4b docs: record the RAGcore go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:22:57 +02:00
NuklearRabbitandClaude Sonnet 5 319f43312e fix: drop RAGcore's opaque version UUID from the fallback answer sentence
document_version_id is an internal UUID, not a human-meaningful version like
the demo corpus's markdown frontmatter -- confirmed live it made the fallback
answer read as "Per \"vehicle-checkout-procedure.md\" (v2e139422-b10b-...)".
Still shown on the source card itself, just not in the composed sentence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:18:58 +02:00
NuklearRabbitandClaude Sonnet 5 a2433d7fa3 fix: fall back to real RAGcore search when /v1/answers is unavailable
RAGcore's /v1/answers (generation + citation validation) is currently returning
a consistent 503 VALIDATION_RETRIES_EXHAUSTED live -- a RAGcore-side bug in its
own generation/validation step, out of scope to fix here (CLAUDE.md forbids
modifying the RAGcore repo). Its retrieval pipeline (/v1/search) is a materially
different, simpler stage with no generation step, and returns real, correctly
cited results right now.

RAGcoreKnowledgeProvider.ask() tries /v1/answers first (unchanged behavior once
RAGcore's generation is fixed), and only when that endpoint itself is
unavailable -- non-2xx or unreachable, never a real 200 classifying the
question as insufficiently answerable -- falls back to /v1/search and builds
the shown "answer" as an extractive citation-wrapped excerpt, mirroring
DemoKnowledgeProvider's own existing template exactly. Never invents an answer
to the question; only ever shows a real, cited excerpt RAGcore's own search
actually found.

Also fixed two real config bugs found while wiring this up live: RAGCORE_BASE_URL
pointed at a non-existent internal hostname (ragcore-api:8000 -- the real
container is reachable at the host's own address on port 1237), and the
previous test credential had been invalidated with nothing to replace it. Minted
a fresh, correctly-scoped service-account credential via RAGcore's own admin
control plane (the documented, legitimate way to obtain one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:11:28 +02:00
NuklearRabbitandClaude Sonnet 5 453c7241fe docs: record the MCP Hub go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:19:07 +02:00
NuklearRabbitandClaude Sonnet 5 529e7364a9 fix: pass MCP_HUB_REGISTRATION_ENABLED/MCP_HUB_BASE_URL through to the api container
compose.yaml's api service environment block forwarded MCP_HUB_SERVICE_TOKEN but
never these two -- so .env's value was silently ignored and Settings always fell
back to its Python default (false / empty), no matter what .env said. Found while
flipping the flag live: the container's actual reported registration_enabled
stayed false after a full recreate, even though .env had been updated and two
real mcp_tool_request audit events already existed (itworx-mcp-hub:readiness ->
fleet_ops_get_operations_summary), proving the Hub's connector already reaches
Fleet Ops successfully independent of this flag -- only the status display was
gated, and silently stuck off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:15:17 +02:00
NuklearRabbitandClaude Sonnet 5 7d686ae2aa docs: record polish fixes, merge, and second deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:04:42 +02:00
NuklearRabbitandClaude Sonnet 5 c2b8268927 fix: form field alignment, raw maintenance text, and static movements list
- .form-grid labels (missing-field form, odometer-regression correction fields)
  and the odometer/overlap note textareas had no stacked label-above-input
  styling at all -- the shared rule only covered .filters/.return-form, so these
  fell back to default inline browser layout with mismatched input widths.
  Extended the existing rule to cover .form-grid and label:has(> textarea).

- Vehicle maintenance list showed the raw, untranslated seed text
  ("Synthetic scheduled service record") regardless of locale -- purely
  decorative and 1:1 redundant with the (already-translated) category. Replaced
  it with the record's real odometer reading, mirroring the sibling
  Inspections tab's pattern.

- "Today's movements" was always the same fixed 4 bookings (2 returns, 2
  departures) on every reset, reading as a static mockup rather than live
  fleet activity. Added 8 more bookings anchored to land on "today" across 8
  additional vehicles, spread through the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:29:56 +02:00
NuklearRabbitandClaude Sonnet 5 9e9dd8e0e3 docs: record the three-defect fix, gates, and live verification evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:52:13 +02:00
NuklearRabbitandClaude Sonnet 5 4faac24b5a fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:44:32 +02:00
NuklearRabbitandClaude Sonnet 5 3808bbe132 docs: record push, Unraid deploy, and live verification evidence
Closes out the demo-scenario fix: pushed the two pending commits, deployed
6f77a30 to Unraid, and verified all four live checks (integration status,
failed-workflow listing, retry via API and UI, audit trail, Automation page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:39:41 +02:00
NuklearRabbit 6f77a30dce fix: stop the prepared demo failure from degrading n8n integration health
The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate
retry and audit. Because derive_n8n_status() counted any failure, every fresh
reset pinned the n8n integration to "degraded" -- the demo showed a warning
about a prop, which tells a viewer something untrue about the automation.

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

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

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

Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy
clean (50 files), tsc clean, frontend build clean. Not deployed and not
browser-verified.
2026-08-05 14:07:05 +00:00
NuklearRabbit e5307a7c0f fix: derive demo-manifest MCP Hub status from real tool-call evidence
The demo manifest still reported the MCP Hub integration as operational purely
because MCP_HUB_REGISTRATION_ENABLED was set, while the integration status page
had already moved to evidence-based status in Batch 4. Registration is
catalog-driven on the Hub's side, so the flag alone proves nothing; reuse
derive_mcp_hub_status() so "operational" requires real recorded mcp_tool_request
calls.

No change to the MCP integration contract: the four read-only routes, service
token and client id handling, inbound X-Correlation-Id preservation, the locale
field on search-knowledge and the provider/correlation_id response fields were
verified as already correct at deployed revision 727c19a and left untouched.
2026-08-05 13:28:43 +00:00
NuklearRabbitandClaude Sonnet 5 dee8f2f7e9 docs: record Batch 5 evidence and final session state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:51:11 +02:00
NuklearRabbitandClaude Sonnet 5 57992bf153 M10: AI Operations Brief runbook, full live e2e regression, final evidence
Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own
MobilityOpsClient against production Fleet Ops: real operations summary, real
most-pressing vehicle, real grounded knowledge answer with citations, real
correlation IDs verified end-to-end in Fleet Ops's own audit log. No write
actions performed. Runbook and full output in
docs/final-integrations/ai-operations-brief-runbook.md.

Ran the full Playwright e2e suite against the live deployed instance and fixed
two pre-existing fragile locators unrelated to this session's feature work
(both broke because Automation now legitimately has two tables sharing the
same generic selectors, exposed by running the full suite rather than
individual files) plus one pre-existing untranslated-loanword false positive.
All specs pass.

artifacts/final-integrations/final-summary.md has the complete evidence
write-up: repository/deployment state, what was fixed vs. handed off, test
results, and known limitations stated plainly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:49:23 +02:00
NuklearRabbitandClaude Sonnet 5 727c19a779 M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image
Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field
existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's
existing language param), and the correlation ID was always freshly minted,
ignoring any inbound X-Correlation-Id header. Added a shared dependency and
applied it to all four MCP endpoints so Fleet Ops's own audit log preserves
the Hub's real correlation ID end to end.

MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read
anywhere. Since the Hub's own registration is catalog-driven (it never needs
Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub
reachability health check instead of an unneeded self-registration call.

Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_*
(mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated
aliases); documented that the live Hub connector's own dotted tool namespace
is a separate, Hub-owned naming layer, deliberately not touched.

Automation page's MCP card now shows real evidence (last tool/client/count/
timestamp, honest no-evidence state) instead of just the registration flag.

Also fixed a real methodology gap found mid-session: compose.yaml's api
service has no bind mount, so `docker compose run --rm api` silently tests a
stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one
genuinely stale test assertion and two lint line-length errors surfaced by
that rebuild. 176 tests passing, ruff clean, mypy clean (50 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:30:24 +02:00
NuklearRabbitandClaude Sonnet 5 2ae2044e3a docs: record Batches 1-3 evidence and exact next action
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:53 +02:00
NuklearRabbitandClaude Sonnet 5 34df66d28c M8: GUI polish, n8n workflow-3 fixes, RAGcore retrieval root-cause and fix
GUI: dashboard Attention Queue presents a curated severity mix instead of pure
severity-sort (grouped Now/Today/Later headers); Today's Movements seed data
curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with
a new seed-integrity test; About Demo restructured into a compact grid with
progressive disclosure for technical sections; Duplicate Merge shows match/conflict
counts, hides matching fields by default, and previews the final merged record
before confirmation.

Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB
zip export; `.gitignore` now excludes future archive exports.

n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed
`fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated
workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body
parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both
via the safe `n8n import:workflow` CLI path; exported the corrected, still-
inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py.
Publishing it (starts real daily unattended runs) remains a separate decision.

RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates"
bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the
app's own runtime user) that broke every retrieval call before it reached Qdrant.
Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was
verified healthy first. Found a second, deeper gap: the reranker adapter calls
an Ollama HTTP route that does not exist on the deployed Ollama version, so
`/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo`
until that is resolved on the RAGcore side. Evidence-based MCP Hub integration
status (real tool-call audit history, not just a boolean flag) replaces the old
`configured`/`not_configured` guess. Full findings in
`docs/final-integrations/current-state-audit.md`.

Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc
clean, production build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:02 +02:00
NuklearRabbitandClaude Sonnet 5 3ebca9e9b7 feat: replace brand mark with waypoint (pin-on-route) logo
Swaps the peaks-over-a-road glyph for a location pin on a route line
in both the favicon and the BrandMark component, so the mark stays
legible at 16px favicon size and reads more literally as fleet/route
tracking. Palette unchanged (navy #0f172a, teal #2dd4bf).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:47:11 +02:00
NuklearRabbitandClaude Sonnet 5 b66521da82 docs: record branch push and Unraid redeploy to 0571a40
Pushed feat/live-n8n-ragcore-integration to origin, then redeployed
the live Fleet Ops instance from 0da5251 to 0571a40 following the
deployment directory's own established source-archive convention.
Verified live: /health OK, the new n8n procedures endpoint (added
this branch) is reachable and correctly auth-gated, KNOWLEDGE_PROVIDER
still demo as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:25:30 +02:00
NuklearRabbitandClaude Sonnet 5 0571a40649 docs: record n8n proxy-hops fix and completed workflow-3 build
Root cause found via the live n8n container's own logs: N8N_PROXY_HOPS=0
in the Unraid template didn't match the real reverse-proxy in front of
it, breaking the browserId/CSRF check on every workflow save while
leaving the UI looking fully signed in -- the user's pushback that it
"shows logged in" was correct and prompted digging into server logs
instead of continuing to guess client-side.

Fixed by editing the Unraid template (N8N_PROXY_HOPS 0->1, backed up
first) and recreating the container with every other setting preserved
exactly. Verified by reproducing the exact save action that used to
fail; it now works, and node persistence survives a full reload.

Built and saved both remaining workflow-3 nodes (Summarize sync
result, Report sync result to Fleet Ops) after recovering from an
errant Ctrl+A that deleted a node mid-verification (caught via node
count, restored via n8n's own Version History, redone carefully). Not
published -- that starts real daily production runs and is left for a
separate decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:12:57 +02:00
NuklearRabbitandClaude Sonnet 5 4227fe4f58 docs: record live /v1/answers verification and a retrieval finding
Issued a fresh scoped credential via the RAGcore admin UI (separate,
working OIDC session, unaffected by the n8n auth problem) and made a
real authenticated /v1/answers call. Got a clean 200 with a real
answer_id/retrieval_run_id, not degraded -- but not_answerable, 0
citations.

Confirmed this isn't a regression: the same query through RAGcore's
own pre-existing Query Lab tool (untouched this session) returns the
identical result down to zero dense/sparse candidates at the raw
retrieval stage. Ruled out the obvious causes via direct Qdrant/
Postgres checks -- workspace_id, space_id, status, and embedding
digest all correctly match real indexed content. Root cause not yet
found; flagged as a follow-up rather than pursued further to avoid
scope creep on what was a deployment-verification task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:01:16 +02:00
NuklearRabbitandClaude Sonnet 5 c790ec99cb docs: record RAGcore search/answer wiring deployment to production
Deployed a2905cc to the live RAGcore instance (approved). Verified
the fix directly: POST /v1/search now returns 401
AUTHENTICATION_REQUIRED instead of the old permanent 503
SEARCH_UNAVAILABLE, proving the endpoint reaches real request
handling. A full authenticated /v1/answers call with a real grounded
answer is still outstanding -- the previously-issued production
credential's raw token was never persisted anywhere retrievable, and
issuing a fresh one needs the RAGcore admin UI, not attempted this
round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:44:39 +02:00
NuklearRabbitandClaude Sonnet 5 fd390df423 docs: record n8n workflow-3 wipe/recovery and the live auth blocker
The live "Fleet Ops -- RAGcore Procedure Sync" workflow's canvas was
found at zero nodes -- the earlier session's abandoned direct n8n
REST API attempt had gone far enough to wipe it before hitting its
401. Recovered via n8n's own Version History "Restore version"
action back to the last good 4-node save; verified via DOM node
count before and after.

Adding the two remaining nodes then hit the same failure mode: n8n's
own first-party autosave reported "Unauthorized" moments after a
fresh, successful interactive sign-in. Stopped deliberately rather
than retrying against a live instance that already caused one data
loss incident this session -- this looks like an n8n-side session/auth
problem, not something fixable from browser automation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:38:16 +02:00
NuklearRabbitandClaude Sonnet 5 e5d8466266 knowledge: rewrite RAGcoreKnowledgeProvider to the real search/answers contract
The previous adapter targeted an endpoint shape RAGcore never actually
exposed. health() now checks /health/ready and ask() posts to the real
POST /v1/answers with Bearer auth and requested_space_ids, matching
RAGcore's actual contract after this session's Bearer-auth and
search/answer wiring work.

Adds RAGCORE_SPACE_ID config/env plumbing (a question is meaningless
without a knowledge space to scope it to) and 12 new adapter tests
covering degradation paths: missing space id, connection errors,
non-200 responses, malformed responses, not-answerable, and
answerable-without-citations all fail closed to "insufficient
evidence" rather than fabricating an answer.

KNOWLEDGE_PROVIDER stays "demo" in production for now -- switching
requires RAGcore's own search/answer application to actually be
deployed and live-verified, tracked separately in PROJECT_STATE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:20:55 +02:00
NuklearRabbit 0da5251524 n8n: add backend endpoints for the RAGcore Procedure Sync workflow
GET /api/v1/integrations/n8n/procedures lists every procedure Markdown
file Fleet Ops ships (all languages) with a stable per-document id and
content hash, ready for workflow 3 to push into RAGcore. POST
.../procedures-sync-result records the sync outcome as an idempotent
audit event, matching the existing return-callback/workflow-error
pattern. Extracted frontmatter parsing out of the demo knowledge
provider into a shared module so both read the same source of truth.
2026-08-04 19:47:39 +02:00
NuklearRabbit 2afceea5e4 docs: record root cause and fix for the RAGcore credential-issuance bug
With explicit owner approval, traced the persistent credential-issuance
rejection to a cross-transaction race in RAGcore's own dependency
injection (two independent DB transactions per request instead of one
shared transaction), fixed and deployed it in RAGcore, and verified a
working "RAGcore Sync Token" n8n credential now exists. Unblocks
workflow 3 and the RAGcoreKnowledgeProvider adapter rewrite.
2026-08-04 18:11:34 +02:00
NuklearRabbit cf4d8e3649 docs: write final n8n + RAGcore integration evidence summary
Consolidates this effort's outcome across all four canonical workflows:
WF1/WF2 hardened and live, WF3 blocked on a RAGcore-side credential
rejection (with trace IDs for the operator to investigate), WF4 built
and live-validated with one open non-blocking follow-up. No credential
values or secrets included.
2026-08-04 17:07:54 +02:00
NuklearRabbit aaa1630535 docs: record WF2 retry fix and WF4's n8n-session-expiry blocker 2026-08-04 17:04:33 +02:00
NuklearRabbit 167bf49b6e n8n: add bounded retries to WF2's quality-scan callback
Found during this round's full acceptance pass: WF2 had the same
timeouts/bounded-retries gap as WF1 (timeout was already set, but Retry
On Fail was disabled). Fixed live (3 tries, 1000ms wait), published, and
synced the repo definition + manifest checksum.
2026-08-04 17:03:52 +02:00
NuklearRabbit fd0c55b13b docs: record RAGcore credential re-attempt and its concrete failure evidence
User explicitly authorized issuing the RAGcore credential directly this
round. Retried via the admin UI (Platform Admin role) after the earlier
raw-API attempt; both fail with an opaque server-side rejection carrying
a trace ID. Documents this as a RAGcore-side blocker, not a Fleet Ops gap.
2026-08-04 16:54:02 +02:00
NuklearRabbit 05628936ca n8n: add bounded retries and timeout to WF1's Fleet Ops callback
Vehicle Return Orchestration had no explicit timeout and Retry On Fail
disabled on its outbound HTTP call, a gap against the acceptance
checklist's timeouts/bounded-retries requirement. Fixed live (3 tries,
1000ms wait, 15s timeout, matching WF2's existing convention) and
synced the repo definition + manifest checksum.
2026-08-04 16:46:11 +02:00
NuklearRabbitandClaude Sonnet 5 b341436e77 docs: record integration status page verification and deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:32:43 +02:00
NuklearRabbitandClaude Sonnet 5 4049c0c6b1 n8n: surface real per-workflow evidence on the integration status page
Fleet Ops integration status no longer depends only on a config
boolean or the most recent outbox event: N8nIntegrationStatus now
reports per-canonical-workflow evidence (last successful outbox
delivery for the return workflow, latest service-triggered
data_quality_scan_run for the scan workflow, latest
n8n_workflow_failure_registered for the error handler, and "not built"
for the still-blocked RAGcore sync), plus an error-handler summary
(total failures registered, latest failure + which workflow).

Automation page renders this as a localized workflow table (EN/NL/FR)
with technical workflow names tucked under a "Technical details"
disclosure, matching the existing progressive-disclosure pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:15:09 +02:00
NuklearRabbitandClaude Sonnet 5 e39c0a1dd6 n8n: build and live-validate the Workflow Error Handler (WF4)
New central "Fleet Ops — Workflow Error Handler" workflow (Error
Trigger -> safe-report Code node -> POST to the new /workflow-error
endpoint), wired as the Error Workflow on both existing workflows with
no recursive loop on itself. Live-validated end-to-end against the
real Fleet Ops server (register + idempotent re-register), and via a
genuine induced failure on the scheduled-scan workflow (broken URL,
confirmed failure, reverted, confirmed healthy).

Fixed two real bugs found during live testing: Code node needed
"Run Once for Each Item" (not "All Items") for $json binding, and
every HTTP body field had a stray trailing space from the n8n
code-editor's bracket auto-close that broke datetime/enum validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:52:17 +02:00
NuklearRabbitandClaude Sonnet 5 bbdb4a9ae8 n8n: add Fleet Ops endpoint to receive workflow error reports
New POST /api/v1/integrations/n8n/workflow-error, service-token
authenticated, for the central "Fleet Ops — Workflow Error Handler"
n8n workflow to report a bounded, secret-free failure (workflow id/
name, execution id, safe error category, trigger context, correlation
id, attempt, retry action). Idempotent on execution_id via the same
audit-event precheck pattern used by /return-callback, so a
redelivered error report is not registered twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:40:36 +02:00
NuklearRabbitandClaude Sonnet 5 e0c107a94a n8n: store cleaned workflow definitions as repo source of truth
Move the two live-validated workflows into n8n/workflows/ (credential-
based auth referenced by name only, no secret values), add a manifest
covering all 4 canonical workflows and a read-only drift-check script
against n8n's Public API. Retire the pre-integration root-level starter
files that still carried the literal-token pattern, and repoint the
Unraid deploy scripts, Makefile targets and runbook at the new files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:34:51 +02:00
NuklearRabbit 59cb4c062e docs: record n8n corrections applied and failure-history triage
Appends a follow-up section to the current-state audit: both existing
workflows renamed to their canonical Fleet Ops names and republished
(IDs/history preserved), and all 6 error executions in the return-
processing workflow's entire history triaged -- the 4 original ones
were the workflow's own author testing against the local test webhook
during initial setup on 2 August, the 2 newest are this session's own
deliberate auth-fix validation calls. Zero unexplained failures remain.
2026-08-04 09:15:08 +02:00
NuklearRabbit b79d485ef1 docs+fix: audit live n8n state, require auth on the return webhook
Inspected the shared n8n instance (n8n.itworx.tech) live: both existing
Fleet Ops workflows are genuinely active and structurally match the repo,
but the shared X-Service-Token secret was stored as plaintext literal
text in both HTTP Request nodes (exportable in the clear), and the
production return webhook had n8n-level Authentication set to "None"
(publicly callable by anyone who discovered the URL). Findings recorded
in docs/live-ai-integration/n8n-current-state.md.

Fixed on the n8n side (both workflows published): the shared token now
lives in a single Header Auth credential instead of two literal copies;
the return webhook now requires a second, distinct Header Auth
credential.

Fixed on the Fleet Ops side to match: the outbox dispatcher now sends
the new X-Fleet-Ops-Trigger-Token header (new
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN setting) when calling the webhook.
Live-verified against the real webhook: a request with no header is now
rejected (403); a request with the correct header passes n8n's auth and
reaches Fleet Ops's own business logic.

That same live test also 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 made
response.json() raise an uncaught exception, potentially leaving the
outbox event stuck in "delivering". Now treated as an explicit,
retryable failure (error_code=malformedResponse), with a regression
test reproducing the exact case.
2026-08-04 05:03:33 +02:00
NuklearRabbit c0995b762e docs(release): final Fleet Ops localization correction evidence
Final evidence for the small correction round merged in 5f0eaa5:
commits, translation fixes, API-error-localization result, greeting
logic and edge-case evidence, clean-checkout drill, Unraid deployment
evidence, repository/runtime hash comparison, known limitations
(including the transient document.lang anomaly observed during
interactive testing, root-caused as far as possible and not
reproduced in any automated run), and rollback procedure.
2026-08-04 04:01:55 +02:00
NuklearRabbit 5f0eaa59b0 merge: finalize Fleet Ops localization 2026-08-04 03:46:20 +02:00
NuklearRabbit 09173a4740 fix: correct fr-BE audit column label Actor -> Auteur
Caught during live browser validation on Unraid: fr-BE had "Acteur" for
the audit trail's actor column, but the brief's minimum-required French
corrections specify "Actor" -> "Auteur" explicitly.
2026-08-04 03:32:48 +02:00
NuklearRabbit 9468cc3e21 docs: update PROJECT_STATE and README for the final localization round
PROJECT_STATE.md: fix the stale "Product name: MobilityOps."/"PoC only"
locked-decisions lines (predate the Fleet Ops rebrand), fix the "Fleet
Ops correction" section header still reading "IN PROGRESS .../Not yet
merged to master" when it was in fact already merged (de0bdea, evidence
commit f780557), and append a new dated entry for this correction round
with commits and gate evidence so far.

README.md: reference docs/fleet-ops-final-localization/ alongside the
existing docs/fleet-ops-correction/ link, refresh the stale Playwright
test count (113 -> 138).
2026-08-04 03:10:37 +02:00
NuklearRabbit f0d641198c fix: serve the missing Fleet Ops favicon
There was no favicon at all -- index.html never linked one, and the
frontend Dockerfile's build stage never copied the public/ directory
into the build context, so even after adding public/favicon.svg
locally, the containerized build silently dropped it (nginx fell back
to serving index.html for that path). Fixed both: index.html links
/favicon.svg, and the Dockerfile now copies public/ alongside src/.
The favicon reuses the existing BrandMark glyph (petrol background,
teal accent) for visual consistency with the in-app brand mark.
A regression test for this lives in the earlier translation-fix commit
(frontend/e2e/fleet-ops-correction.spec.ts), added together with the
fix at the time.
2026-08-04 03:10:11 +02:00
NuklearRabbit 77208b857a fix: prevent topbar overflow from an unbreakable Dutch role-name translation
Correctly translating auth.json's roleOperationsManager from the old
two-word "Operations Manager" (which could wrap at the space) to the
single Dutch compound word "Operationsmanager" (which cannot) pushed the
topbar's .operator block past its 1024px-breakpoint budget, caught by
the existing responsive-i18n.spec.ts overflow test. Fixed with
overflow-wrap: anywhere on the role/name text and min-width: 0 on their
flex-item wrapper, rather than reverting the correct translation.
2026-08-04 03:09:17 +02:00
NuklearRabbit e427313bce feat: add time-dependent Europe/Brussels dashboard greeting
The dashboard greeting was a fully static "Goedemorgen..." regardless of
actual time of day. New frontend/src/i18n/greeting.ts::getGreetingPeriod
is a pure, clock-injectable function resolving one of 4 periods (05:00-
11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59
night) against Europe/Brussels wall-clock time via
Intl.DateTimeFormat({ timeZone, hourCycle: "h23" }), which is DST-safe
by construction.

useGreetingPeriod.ts wires this into React with a 30s poll so the
greeting rolls over live while the app stays open, no reload required.
Each period now has its own greeting word and accompanying sentence in
all 3 languages (dashboard.json), replacing both the fixed "Goedemorgen"
and the fixed "Here's the fleet" follow-up sentence. Night never says
"Goedenacht" (used as a farewell, not a welcome, in Dutch).
2026-08-04 03:08:45 +02:00
NuklearRabbit d17af1c52a feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
2026-08-04 03:08:07 +02:00
NuklearRabbit 94cfb7bcbb test: tighten i18n allowlist, add substring and brand-leak guards
Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title,
auth.roleOperationsManager, auth.roleRentalEmployee,
demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/
rental_employee, navigation.items.audit) now that they are genuinely
translated -- their old comments describing them as "deliberately
untranslated" were no longer true.

Add two new checks: one closing the embedded-English/Dutch-substring
blind spot the whole-string identity test structurally cannot catch (a
mid-sentence phrase surviving inside otherwise-translated prose), one
asserting no locale file contains "MobilityOps" or the word "PoC".
2026-08-04 03:04:14 +02:00
NuklearRabbit 37a362c4a0 fix: translate remaining NL/FR interface gaps
Role names, audit/scenario labels, and status text were previously either
left in English or only partially translated:
- auth.json/demo.json role labels actually translated (not just labelled
  as translated): Operationsmanager/Verhuurmedewerker,
  Responsable des operations/Collaborateur de location.
- "Audit trail" -> Auditgeschiedenis/Piste d'audit (title, column header,
  and every mid-sentence occurrence across demo.json, quality.json,
  returns.json -- these embedded leaks were previously invisible to the
  whole-string identity check).
- "Open" (status) -> Openstaand, "Recent" -> Recentste,
  "Start scenario" -> Scenario starten / Demarrer le scenario.

Matching Playwright spec text updated in the same commit so the suite
never regresses through a broken intermediate state.
2026-08-04 03:03:46 +02:00
NuklearRabbit 1fbb20b1ab docs: audit remaining Fleet Ops localization gaps
Documents every remaining untranslated/incorrect NL/FR string, raw-backend-
error call site, over-permissive i18n allowlist entry, the static-greeting
bug, and doc staleness found by a dedicated read-only sweep before any file
was touched, per the Fleet Ops final localization brief.
2026-08-04 03:02:50 +02:00
NuklearRabbitandClaude Sonnet 5 f7805579f7 docs(release): final Fleet Ops correction evidence and screenshots
Full acceptance evidence for the Fleet Ops correction milestone: commits, branding,
translation coverage, status-preview/apply/manual-review/MO-016-ordering results,
knowledge grounding per language, audit/automation localization, backend/frontend
test results, clean-checkout drill, Unraid deployment (both fix-branch and
post-merge master), responsive/accessibility results, known limitations, and
rollback procedure. Includes live screenshots (nl-BE and fr-BE login, and the
localized data-quality evidence summary that live validation caught and fixed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:15:31 +02:00
NuklearRabbit de0bdea84f merge: complete Fleet Ops localization and status resolution 2026-08-04 00:54:31 +02:00
140 changed files with 8257 additions and 978 deletions
+12 -2
View File
@@ -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,9 +42,14 @@ 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. Registration itself is catalog-driven on the Hub's own
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
# health check surfaced on the integration status page.
MCP_HUB_REGISTRATION_ENABLED=false MCP_HUB_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops MCP_PROVIDER_ID=fleet-ops
+2
View File
@@ -14,3 +14,5 @@ test-results/
.idea/ .idea/
.vscode/ .vscode/
*.tsbuildinfo *.tsbuildinfo
*.zip
*.tar.gz
+4 -1
View File
@@ -60,7 +60,10 @@
- `knowledge/procedures/09-booking-conflicts.md` - `knowledge/procedures/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md` - `knowledge/procedures/10-roles-and-escalation.md`
- `n8n/README.md` - `n8n/README.md`
- `n8n/mobilityops-return-processing.json` - `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py`
- `seed/README.md` - `seed/README.md`
- `seed/bookings.csv` - `seed/bookings.csv`
- `seed/customers.csv` - `seed/customers.csv`
+7 -4
View File
@@ -26,16 +26,19 @@ reset:
# One-time per environment: imports and activates the n8n return-processing workflow. # One-time per environment: imports and activates the n8n return-processing workflow.
# The n8n owner account itself cannot be scripted safely and must be created once at # The n8n owner account itself cannot be scripted safely and must be created once at
# http://localhost:5678/setup (any email/password, no verification required) before # http://localhost:5678/setup (any email/password, no verification required) before
# this target's activation takes effect. See docs/17-runbook.md. # this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
n8n-setup: n8n-setup:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n docker compose restart n8n
# One-time per environment: imports and activates the scheduled quality-scan workflow. # One-time per environment: imports and activates the scheduled quality-scan workflow.
# Same owner-account precondition as n8n-setup above. # Same owner-account and credential preconditions as n8n-setup above (this workflow only
# needs "Fleet Ops Service Token").
n8n-setup-scan: n8n-setup-scan:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n docker compose restart n8n
+1247 -7
View File
File diff suppressed because it is too large Load Diff
+18 -9
View File
@@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical
identifier only — the repository name, local directory, package/module names, Docker identifier only — the repository name, local directory, package/module names, Docker
Compose project, deployment directory, and database names. The UI is fully trilingual Compose project, deployment directory, and database names. The UI is fully trilingual
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization (nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
architecture, the vehicle-status decision table, and the correction evidence. architecture, the vehicle-status decision table, and the correction evidence, and
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
NL/FR translation gaps, centralized API-error localization, the time-dependent
Europe/Brussels dashboard greeting).
The web application uses the premium responsive **Control Rail** interface: a compact The web application uses the premium responsive **Control Rail** interface: a compact
operations-first workspace with persisted readiness metrics, evidence-led exceptions, operations-first workspace with persisted readiness metrics, evidence-led exceptions,
@@ -65,15 +68,21 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
delivery counts, not just the most recent event. delivery counts, not just the most recent event.
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval - **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
implemented and unit-tested, including its unavailable-degradation path, but was never `RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
exercised against a live RAGcore instance in this environment. exercised live against the deployed RAGcore instance: a real filesystem-permission bug
that caused every live retrieval to return zero candidates was found and fixed
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
`demo` until that is resolved on the RAGcore side.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and - **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
directly `curl`-verified with correct auth enforcement and audit logging. directly `curl`-verified with correct auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously `MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
declared in `.env.example` but silently dropped) and reported honestly by the by the integration-status endpoint (evidence-based: real tool-call audit history, not
integration-status endpoint. No live Hub instance was reachable in this environment to just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
verify an actual Hub round trip. production deployment (Tower), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
See `artifacts/functional-completion/final-summary.md` for the functional-completion See `artifacts/functional-completion/final-summary.md` for the functional-completion
audit evidence (supersedes the design-validation summary below for integration status), audit evidence (supersedes the design-validation summary below for integration status),
@@ -123,7 +132,7 @@ All defaults are configurable via `.env` (see `.env.example`).
```bash ```bash
make test # backend: pytest (151 tests) make test # backend: pytest (151 tests)
make lint # backend: ruff + mypy (strict, zero errors) make lint # backend: ruff + mypy (strict, zero errors)
make e2e # frontend: Playwright end-to-end (113 tests, live stack required) make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
``` ```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
@@ -0,0 +1,161 @@
# Fleet Ops final integrations — evidence summary
Session date: 2026-08-05. Branch `feat/fleet-ops-final-integrations`.
## Repository state
| Repo | Start | End | Branch | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `3ebca9e` (from `feat/live-n8n-ragcore-integration`) | `727c19a` (+ e2e test fixes, uncommitted at write time) | `feat/fleet-ops-final-integrations`, pushed to `origin` | 3 commits: `34df66d`, `2ae2044`, `727c19a` |
| RAGcore | `64a908a` | `64a908a` (+1 isolated commit `ce0ad56`) | `main` | Only a backlog handoff entry committed; no code changes (36-file concurrent-session collision — see below) |
| ITWorx MCP Hub | not modified this session | — | `feature/wp240-final-acceptance` | Connector already live in production before this session started; not touched |
## Deployed revisions
- Fleet Ops: `http://192.168.10.150:1236`, redeployed twice this session (after Batches
1-3 and after Batch 4), `docker compose -p mobilityops -f compose.yaml -f
compose.unraid.yaml up --build -d db api web`, `.deploy/source-revision` = `727c19a...`.
- RAGcore: `http://192.168.10.150:1237`, `ragcore-app-1`. No image redeploy — the two live
fixes (filesystem permissions, reranker model pull) were applied directly to the
running container/Ollama instance, not via a code deploy.
- ITWorx MCP Hub: `http://192.168.10.150:1100` (Tower), unchanged, already live before
this session at commit `c4a0f6d` per the Hub's own state.
## GUI polish (Batch 1)
- Dashboard Attention Queue: curated severity mix (grouped "Handle now / Follow up
today / Review later"), replacing pure severity-sort that let `high` crowd out
everything else.
- Today's Movements: seed data curated (`seed/bookings.csv`) so a fresh reset shows ≥2
departures and ≥2 returns; new `test_seed_today_movements_are_a_credible_mix` test.
Live-verified after a real demo reset: 2 returns + 2 departures shown.
- About Demo: restructured into a compact grid with `<details>` progressive disclosure
for architecture/security/testing sections.
- Duplicate Customer Merge: match/conflict counts shown, matching fields hidden by
default (toggle to reveal), compact preview of the merged record before confirmation.
- Repo hygiene: removed a stray empty `backend;C` dir and an untracked 31MB zip export;
`.gitignore` now excludes future archive exports.
- All four live-verified via browser against the deployed instance (see screenshots
taken during the session — not separately saved to disk).
## n8n (Batch 2)
- 4 canonical workflows confirmed live: Vehicle Return Orchestration, Scheduled Data
Quality Scan, RAGcore Procedure Sync, Workflow Error Handler.
- Fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json` (a
missing `},` between two node objects — the file could not be parsed).
- Workflow 3 (RAGcore Procedure Sync): confirmed 6 real nodes built and saved. Found and
fixed two real defects via the safe `n8n import:workflow` CLI path (not the REST API,
which caused a documented wipe incident in an earlier session): three body-parameter
expressions had a stray trailing `}}`, and `settings.errorWorkflow` was unset. Exported
the corrected definition to `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
added to `MANIFEST.md` and `check_drift.py`.
- **Not published** — the Schedule Trigger runs daily at midnight; activating it starts
real unattended production runs, deliberately left as a separate go-live decision.
- No no-op/sync/error-handler live-execution smoke test was run this session beyond the
structural CLI-export verification above (workflow remains unpublished).
## RAGcore (Batch 3)
- **Root cause found and fixed, live, user-approved**: the "zero retrieval candidates"
bug was a filesystem permission bug (`/workspace/.state/models/embedding_profiles.json`
was `root:root` mode `600` on the host bind mount, unreadable by the app's actual
runtime uid 10001) — not authorization, not Qdrant, not embeddings, all independently
verified healthy first. Fixed via `chown`/`chmod`; re-verified in-process (5 real hits,
up from 0).
- **Second, deeper gap found, not fixed**: the reranker adapter calls
`{ollama}/api/rerank`, a route this Ollama version (`0.32.5`) does not serve (404).
Pulled a working model (`xitao/bge-reranker-v2-m3:latest`, 1.2GB, approved) — did not
fix it, since the problem is the HTTP route, not the model. `/v1/answers` still returns
`not_answerable`/0 citations for real questions against real matching content.
- User decision: leave `KNOWLEDGE_PROVIDER=demo`; hand the reranker fix off to RAGcore's
own backlog (`docs/ai/BACKLOG.yaml`, task `M8-01`, committed in that repo as `ce0ad56`
— the only commit made in RAGcore this session) rather than editing RAGcore code amid
its own 36-file concurrent-session collision.
- Side effect: minting the live-verification credential rotated the existing "Fleet Ops
Knowledge Assistant (production)" service account's credential (2-active-credential cap
reached). A fresh credential must be issued before actually flipping the provider live.
## MCP Hub (Batch 4)
- Confirmed the Fleet Ops connector is already live in production on the Hub side
(Tower, commit `c4a0f6d`), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to camelCase `vehicleRef`).
- Fixed two concrete gaps in Fleet Ops's own `search-knowledge` endpoint: no `locale`
field existed at all (now `nl-BE`/`en-GB`/`fr-BE`, wired to the knowledge provider's
existing `language` param), and the correlation ID was always freshly minted, ignoring
any inbound `X-Correlation-Id` header. Added `get_correlation_id`, applied to all four
MCP endpoints.
- `MCP_HUB_BASE_URL` was dead config (declared, never read); wired it for a real,
bounded Hub-reachability health check instead of an unneeded self-registration push
(the Hub's own registration is catalog-driven).
- Renamed Fleet Ops's own internal audit tool labels `mobilityops_*``fleet_ops_*`
(mirrored in `contracts/mcp-tools.json`, `mobilityops_*` kept as deprecated aliases).
The live Hub connector's own dotted tool namespace (`mobilityops.operations.summary`
etc.) is a separate, Hub-owned naming layer, deliberately not touched.
- Automation page's MCP card now shows real evidence (last tool/client/count/timestamp)
instead of only the registration-enabled boolean.
## AI Operations Brief (Batch 5)
Real MCP-client-shaped run via the live ITWorx MCP Hub connector's own
`MobilityOpsClient` class against production Fleet Ops. Full runbook and live output in
`docs/final-integrations/ai-operations-brief-runbook.md`. Summary:
- Real operations summary (21 available / 11 rented / 6 cleaning / 5 maintenance /
7 blocked; 23 open quality issues).
- Real most-pressing vehicle identified (`MO-031`, missing operational inspection).
- Real vehicle detail lookup.
- Real grounded knowledge answer (English damage-handling question): 2 real citations,
`evidence_state: grounded`.
- Dutch/French variants of the same question honestly returned `insufficient` (no
fabrication) — root cause: the live Hub connector doesn't yet send the new `locale`
field, a Hub-side follow-up, not silently worked around.
- Correlation IDs verified end-to-end in Fleet Ops's own audit log
(`GET /api/v1/audit?action=mcp_tool_request`), matching the response payloads exactly.
- No write actions performed at any point.
## Testing per batch
- Backend: **176 passed**, `ruff check .` clean, `mypy app` clean (50 source files) —
verified against a freshly rebuilt image after discovering mid-session that
`docker compose run --rm api` (no bind mount on the `api` service) silently tests a
stale image otherwise. One genuinely stale test assertion found and fixed as a result.
- Frontend: `tsc -b && vite build` clean.
- E2e (Playwright, against the live deployed instance,
`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`): every spec file run this
session passed — `demo.spec.ts`, `interactive-elements.spec.ts` (26),
`responsive-i18n.spec.ts` + `demo-accessibility.spec.ts` + `guided-demo-full.spec.ts`
(28), `i18n-coverage.spec.ts` + `error-messages.spec.ts` + `clickable-rows.spec.ts` +
`demo-guide.spec.ts` + `demo-entry.spec.ts` + `demo-legibility.spec.ts` +
`fleet-ops-correction.spec.ts` + `ui-redesign.spec.ts` + `greeting.spec.ts` +
`greeting-live.spec.ts` (28, after fixing 2 pre-existing fragile locators unrelated to
this session's feature work — a `.data-table` ambiguity now that Automation has two
tables, and a `Technische details` toggle ambiguity for the same reason; plus one
pre-existing untranslated-loanword false positive in `i18n-coverage.spec.ts`).
## Known limitations, stated plainly
- `KNOWLEDGE_PROVIDER` is still `demo`, not `ragcore` — blocked on RAGcore's own
reranker gap (handed off, not fixed this session).
- n8n workflow 3 is built and correct but not published (deliberate, separate decision).
- The live MCP Hub connector doesn't yet send the new `locale` field, so
locale-aware knowledge search only works when called directly against Fleet Ops (as
proven by the backend tests), not yet through the live Hub connector as deployed.
- No public-demo-readiness checklist, About Demo Guide "completed" end-state polish
(section 4E), or dashboard MCP "activity showcase after Demo Complete" gating were
built this session — the MCP evidence display exists on the Automation page
unconditionally rather than gated behind guided-demo completion.
- No security-review pass was run separately this session (existing gates: ruff, mypy,
the repo's own auth/audit test coverage).
## Rollback
- Fleet Ops: prior working revision `0571a40` remains in `.deploy/` as
`source-0571a40.tar.gz` on the Unraid host; redeploy by re-extracting and re-running
the same `docker compose up --build -d` sequence with that archive.
- RAGcore: `chown`/`chmod` change is trivially reversible (`chown 0:0` +
`chmod 600` on the same path) if needed, though there is no reason to revert a
permission fix. Ollama model pull (`xitao/bge-reranker-v2-m3:latest`) can be removed
with `ollama rm` if unwanted; it is inert until RAGcore's own code is changed to use it.
- MCP Hub: not modified this session.
@@ -0,0 +1,284 @@
# Fleet Ops correction and release — final evidence
**Result: PASS**
## Commits
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
- Fix branch: `fix/fleet-ops-i18n-status-flow`
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
- Live URL: `http://192.168.10.150:1236`
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
## What this correction fixed
1. **Status-recommendation flow redesigned** (sections 8A8F). The old single opaque
"calculate and apply recommended status" action is replaced by a single shared, pure
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
identically by the scanner, a non-mutating preview endpoint
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
review recommendations, and re-validates post-write before resolving the issue.
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
fact proves maintenance is actually finished — release stays a manual decision).
- Frontend: "Review recommendation" → a localized decision panel (current/
recommended status, why, evidence, consequences) → an exact "Change status to
&lt;status&gt;" confirm action → result, or a distinct "Manual review required"
state offering no generic apply button.
2. **MO-016 order independence** (section 9). Order independence does not mean "same
final status regardless of order" — resolving the booking overlap first genuinely
removes the conflict, correctly leaving nothing to apply. What holds either way: the
recommendation always reflects real current facts (never a stale proxy), and nothing
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
necessarily MO-016's) and a browser-level Playwright test covering both orders.
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
brand appeared in locale prose. A permanent test fails the build if any locale file
ever defines the brand name or an `appName` key again.
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
return status reasons, audit field/actor-type labels, automation `last_error` (new
`last_error_code` column, migration `799d8800e241`), search results (sections/
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
summary. Raw technical text is demoted to a "Technical details" disclosure
everywhere.
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
scoring so a body match (real substance) outranks a heading/title match (a shallow
structural hint) — the old weighting misranked the damage procedure behind an
unrelated document for the brief's exact validation question in all 3 languages.
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
backend returns stable codes + params only; the frontend localizes section labels,
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
automation error explanations, and maintenance/inspection type labels.
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
route, no console errors, correct `html[lang]`, real page headings).
## Live-caught bug (the deployment validation earning its keep)
Live validation on the freshly-deployed fix branch directly caught a real defect: every
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
always-English text (e.g. *"vehicle marked available while reserved bookings
conflict"*) in **all three languages**, because the frontend never finished the
`evidence.signals` localization the backend had already been emitting (the backend code
even had a comment describing the intended design that the frontend didn't implement).
Fixed in commit `2e4fb43`:
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
locale as the primary evidence text.
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
carry real, accurate signals computed at seed time (the duplicate-customer similarity
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
- Rows with no structured signals fall back to raw text rather than showing a blank
summary; the one known filler placeholder gets its own localized rendering.
- A regression test locks this in: the vehicle-status-conflict evidence summary must
show localized text and must never contain the specific raw English sentence that was
live-visible before the fix, in all 3 languages.
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
"manual review required" (both carry `safe_to_apply: false`), which showed a false
"manual review required" panel for MO-016 after its booking overlap was resolved
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
## Translation coverage
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
- No locale file contains an empty string value.
- No locale file defines the brand name or an `appName` key (brand-invariant test).
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
genuine proper nouns/cognates (23 entries, each with a documented reason).
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
against both false positives — TypeScript generics — and a deliberately-injected-
then-reverted false negative).
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
`html[lang]`, and a real page heading.
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
data-quality evidence summary) was found live and fixed before merge.
## Branding
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
base). Verified structurally (brand-invariant test) and live (branding test across
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
visual screenshots of the login screen in nl-BE and fr-BE).
- Technical identifier retained (by design, per the brief): repository name, local
directory, package/module names, Compose project, deployment directory, database
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
"mobilityops" — none of these are visible UI text.
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
procedure file for both strings).
## Status-preview / apply / manual-review / MO-016 ordering
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
calling the preview endpoint and re-fetching it via a fresh request.
- **Apply**: the confirm button names the exact target status ("Change status to
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
applying resolves the issue and updates the vehicle atomically.
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
contradiction) shows "Manual review required" with no generic apply button rendered
at all.
- **Stale token**: simulated by resolving the underlying booking overlap after the
preview was fetched but before applying — the apply call is correctly rejected
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
user must review again before a new apply is possible.
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
conflict first safely blocks the vehicle; resolving the now-redundant overlap
afterwards does not disturb it. Neither order ever produces "rented".
## Knowledge (per language)
The brief's exact validation question, in each language, grounds on the damage
procedure as the **primary** (not just top-3) source:
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
procedure, Dutch source, Dutch excerpt.
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
English source, English excerpt.
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
procedure, French source, French excerpt.
This required two real fixes: a tokenizer bug that silently dropped accented
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
matches now outrank heading/title matches).
## Audit / automation
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
localized (`operational_status` → "Operationele status" / "Operational status" /
"Statut opérationnel"), actor types localized, raw technical codes only inside
"Technical details". Verified live and via a dedicated Playwright test.
- Automation: the seeded synthetic failure shows a localized primary explanation
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
and via a dedicated Playwright test.
## Backend tests / lint / types
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
— run four times across this correction, always 151/151).
- `ruff check .`: all checks passed, every run.
- `mypy app` (strict): no issues found in 49 source files, every run.
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
round-trip verified.
## Frontend build / Playwright
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
isolated clean-checkout stack, the live fix-branch deployment, and the live
post-merge master deployment — **116/116 passed** on the final master-deployment run
and on the final local run. A handful of transient, sequential-run-only flakes
occurred at various points across ~10 full-suite runs today (different test each
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
this branch); every single one was confirmed to pass cleanly in isolation.
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
"run the guided tour end-to-end in French" script exists beyond what those specs plus
the branding/route-matrix tests already exercise, since the guided tour's steps route
through the same pages already covered per-language.
## Clean-checkout drill
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
colliding with the working dev stack). From empty volumes: build → up → `alembic
upgrade head``reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
frontend build green → full Playwright suite green → final reset →
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
torn down afterward; working dev environment confirmed untouched.
## Unraid deployment
Deployed via `git archive``scp` → extract into `/mnt/user/appdata/mobilityops`
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
`api`+`web``alembic upgrade head` → reset/reseed. Done twice: once for the fix
branch (caught the evidence-summary bug), once for the final merged master. Both times:
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
green against the live server, `scenario_integrity.all_ready: true` after final reset.
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
local retrieval — remains what's live, per the brief's constraint against activating
unvalidated live integrations).
## Responsive / accessibility
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
360×800) × 3 languages: no horizontal overflow, localized headings visible
(`responsive-i18n.spec.ts`).
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
"Change status to X" verified via focus assertions (not just click); reduced-motion
emulated during the flow; status never conveyed by colour alone (the badge always
carries its own localized text); `aria-live="polite"` added so the applied
confirmation is announced to screen readers.
## Known limitations
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
one specific Playwright test only under heavy sequential load; not introduced by this
branch, not fixed (out of this branch's scope), always passes in isolation.
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
localized generic placeholder rather than rich structured evidence, since they carry
no real underlying data gap to describe accurately (the CSV's placeholder text
doesn't correspond to an actually-missing field on the referenced vehicles).
- No dedicated "full guided demo in French, screenshot every step" script exists as a
single artifact; coverage is composed from the route matrix, branding, and existing
guided-demo specs, each run across all 3 languages.
## Screenshots
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
`http://192.168.10.150:1236`:
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
by Google Translate, not the app), re-triggering specifically on React DOM mutations
from clicking through the panel. Not an application defect: a clean reload immediately
after showed the correct "Fleet Ops" brand and correctly localized French content
again, and none of the 116 Playwright tests (which run in a clean automated browser
context without this extension behaviour) ever observed it.
## Rollback procedure
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
rolled back (not required for a same-schema rollback within this correction's own
history, only if reverting past the whole correction).
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -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:0011:59 | Goedemorgen | Good morning | Bonjour |
| afternoon | 12:0017:59 | Goedemiddag | Good afternoon | Bonjour |
| evening | 18:0022:59 | Goedenavond | Good evening | Bonsoir |
| night | 23:0004: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.
+43 -8
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -14,7 +15,7 @@ from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, CurrentUser from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
return refs return refs
@router.get("", response_model=list[AuditEventOut]) @router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
def list_audit_events( def list_audit_events(
actor_label: str | None = Query(default=None), actor_label: str | None = Query(default=None),
action: str | None = Query(default=None), action: str | None = Query(default=None),
entity_type: str | None = Query(default=None), entity_type: str | None = Query(default=None),
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
correlation_id: str | None = Query(default=None), correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500), occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[AuditEventOut]: ) -> list[AuditEventOut] | AuditEventPageOut:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
if actor_label: if actor_label:
stmt = stmt.where(AuditEvent.actor_label == actor_label) stmt = stmt.where(AuditEvent.actor_label == actor_label)
if action: if action:
stmt = stmt.where(AuditEvent.action == action) stmt = stmt.where(AuditEvent.action == action)
if entity_type: if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type) stmt = stmt.where(AuditEvent.entity_type == entity_type)
if entity_ref:
matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values():
matched_ids.update(
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
)
if not matched_ids:
if page is None:
return []
return AuditEventPageOut(
items=[], page=1, page_size=page_size, total=0, total_pages=1
)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all() if occurred_from:
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
if occurred_to:
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
events = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
entity_refs = _resolve_entity_refs(db, events) entity_refs = _resolve_entity_refs(db, events)
out = [] out = []
@@ -94,4 +120,13 @@ def list_audit_events(
metadata=e.metadata_json, metadata=e.metadata_json,
) )
) )
return out if page is None:
return out
total_pages = max(1, (total + page_size - 1) // page_size)
return AuditEventPageOut(
items=out,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
+19 -3
View File
@@ -19,6 +19,7 @@ from app.schemas import (
AutomationRunOut, AutomationRunOut,
CurrentUser, CurrentUser,
DashboardOut, DashboardOut,
EvidenceSignalOut,
TodayItem, TodayItem,
) )
from app.services.operations import compute_metrics from app.services.operations import compute_metrics
@@ -61,19 +62,34 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id) entity = customers_by_id.get(issue.entity_id)
link_type = "customer" link_type = "customer"
link_ref = entity.public_ref if entity else "" link_ref = entity.public_ref if entity else ""
# The backend never emits prose for the attention queue -- only stable signal
# codes + raw data params, exactly like the issue detail page's evidence list
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
# that turns these into the operator's selected language; `evidence_json["summary"]`
# is a technical fallback only, never rendered here.
signals = [
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
for s in issue.evidence_json.get("signals", [])
]
attention_items.append( attention_items.append(
AttentionItem( AttentionItem(
kind="quality_issue", kind="quality_issue",
severity=issue.severity, severity=issue.severity,
rule_type=issue.rule_type, rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""), evidence_signals=signals,
link_type=link_type, link_type=link_type,
link_ref=link_ref, link_ref=link_ref,
issue_ref=issue.public_ref, issue_ref=issue.public_ref,
) )
) )
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3)) # Curate a credible severity mix instead of letting `high` dominate every slot:
attention_items = attention_items[:8] # each item's real severity is unchanged, only the display selection is capped per
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
# issues doesn't crowd out medium/low ones the operator should still see.
high_items = [i for i in attention_items if i.severity == "high"]
medium_items = [i for i in attention_items if i.severity == "medium"]
low_items = [i for i in attention_items if i.severity == "low"]
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
today = _today() today = _today()
bookings = db.scalars(select(Booking)).all() bookings = db.scalars(select(Booking)).all()
+23 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -16,6 +16,7 @@ from app.schemas import (
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
DataQualityIssuePageOut,
MergeCustomersRequest, MergeCustomersRequest,
MergeCustomersResult, MergeCustomersResult,
ProvideFieldsRequest, ProvideFieldsRequest,
@@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
) )
@router.get("/issues", response_model=list[DataQualityIssueOut]) @router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
def list_issues( def list_issues(
status: str | None = Query(default=None), status: str | None = Query(default=None),
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut]: ) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
if status: if status:
stmt = stmt.where(DataQualityIssue.status == status) stmt = stmt.where(DataQualityIssue.status == status)
@@ -69,8 +72,22 @@ def list_issues(
stmt = stmt.where(DataQualityIssue.rule_type == rule_type) stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity: if severity:
stmt = stmt.where(DataQualityIssue.severity == severity) stmt = stmt.where(DataQualityIssue.severity == severity)
issues = db.scalars(stmt).all() total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
return [_to_out(i) for i in issues] page_number = page or 1
issues = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [_to_out(i) for i in issues]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return DataQualityIssuePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
# Every public reference in this system carries its entity type in its own prefix # Every public reference in this system carries its entity type in its own prefix
@@ -115,6 +132,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
return { return {
"entity_type": "vehicle", "entity_type": "vehicle",
"public_ref": vehicle.public_ref, "public_ref": vehicle.public_ref,
"registration_number": vehicle.registration_number,
"make": vehicle.make, "make": vehicle.make,
"model": vehicle.model, "model": vehicle.model,
"location": vehicle.location, "location": vehicle.location,
+3 -12
View File
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings from app.schemas import CurrentUser, IntegrationStatusOut
from app.schemas import ( from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
CurrentUser,
IntegrationStatusOut,
McpHubIntegrationStatus,
)
from app.services.integration_status import derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
settings = get_settings()
@router.get("/status", response_model=IntegrationStatusOut) @router.get("/status", response_model=IntegrationStatusOut)
@@ -23,8 +17,5 @@ def integration_status(
) -> IntegrationStatusOut: ) -> IntegrationStatusOut:
return IntegrationStatusOut( return IntegrationStatusOut(
n8n=derive_n8n_status(db), n8n=derive_n8n_status(db),
mcp_hub=McpHubIntegrationStatus( mcp_hub=derive_mcp_hub_status(db),
registration_enabled=settings.mcp_hub_registration_enabled,
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
),
) )
+132 -1
View File
@@ -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),
)
+43 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings() settings = get_settings()
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None: def get_correlation_id(
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
) -> str:
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
if x_correlation_id:
try:
return str(uuid.UUID(x_correlation_id))
except ValueError:
pass
return str(uuid.uuid4())
def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
) -> None:
record_audit_event( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, actor_label=client_id,
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.uuid4(), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label}, metadata={"tool": tool, "status": status_label},
) )
db.commit() db.commit()
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
def operations_summary( def operations_summary(
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut: ) -> OperationsSummaryOut:
metrics = compute_metrics(db) metrics = compute_metrics(db)
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
) )
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics) return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
@@ -59,12 +80,17 @@ def attention_vehicles(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]: ) -> list[AttentionVehicleOut]:
results = list_attention_vehicles( results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
) )
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
) )
return [AttentionVehicleOut(**r) for r in results] return [AttentionVehicleOut(**r) for r in results]
@@ -74,14 +100,16 @@ def vehicle_details(
vehicle_ref: str, vehicle_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut: ) -> McpVehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id,
) )
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +127,11 @@ def vehicle_details(
) )
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok" db,
client_id=client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
) )
return McpVehicleDetailOut( return McpVehicleDetailOut(
public_ref=vehicle.public_ref, public_ref=vehicle.public_ref,
@@ -120,15 +152,16 @@ def search_knowledge(
body: McpKnowledgeSearchRequest, body: McpKnowledgeSearchRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer: ) -> GroundedAnswer:
provider = get_knowledge_provider() provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4()) answer = provider.ask(body.question, correlation_id, language=body.locale)
answer = provider.ask(body.question, correlation_id)
answer.sources = answer.sources[: body.max_sources] answer.sources = answer.sources[: body.max_sources]
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id,
) )
return answer return answer
+38 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
@@ -19,6 +19,7 @@ from app.schemas import (
MaintenanceOut, MaintenanceOut,
VehicleDetailOut, VehicleDetailOut,
VehicleOut, VehicleOut,
VehiclePageOut,
) )
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
return set(rows) return set(rows)
@router.get("", response_model=list[VehicleOut]) @router.get("", response_model=list[VehicleOut] | VehiclePageOut)
def list_vehicles( def list_vehicles(
status: str | None = Query(default=None), status: str | None = Query(default=None),
attention_only: bool = Query(default=False), attention_only: bool = Query(default=False),
query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]: ) -> list[VehicleOut] | VehiclePageOut:
stmt = select(Vehicle).order_by(Vehicle.public_ref) stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status: if status:
stmt = stmt.where(Vehicle.operational_status == status) stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all() if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.location.ilike(term),
Vehicle.registration_number.ilike(term),
)
)
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
out = [ if attention_only:
stmt = stmt.where(
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [
VehicleOut( VehicleOut(
public_ref=v.public_ref, public_ref=v.public_ref,
make=v.make, make=v.make,
@@ -62,9 +85,16 @@ def list_vehicles(
) )
for v in vehicles for v in vehicles
] ]
if attention_only: if page is None:
out = [v for v in out if v.attention] return items
return out total_pages = max(1, (total + page_size - 1) // page_size)
return VehiclePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.get("/{public_ref}", response_model=VehicleDetailOut) @router.get("/{public_ref}", response_model=VehicleDetailOut)
+15 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.errors import AppError from app.core.errors import AppError
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent, is_demo_scenario_failure
from app.schemas import AutomationRunOut, CurrentUser from app.schemas import AutomationRunOut, CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -24,6 +24,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
attempts=event.attempts, attempts=event.attempts,
last_error=event.last_error, last_error=event.last_error,
last_error_code=event.last_error_code, last_error_code=event.last_error_code,
is_demo_scenario=is_demo_scenario_failure(event),
occurred_at=event.occurred_at, occurred_at=event.occurred_at,
) )
@@ -63,15 +64,27 @@ def retry_workflow(
status_code=409, status_code=409,
) )
# Captured before the status flips, so the audit records what was actually retried.
was_demo_scenario = is_demo_scenario_failure(event)
event.delivery_status = "pending" event.delivery_status = "pending"
event.next_attempt_at = None event.next_attempt_at = None
# The retry itself is real either way: the event goes back on the outbox and the
# dispatcher delivers it to the configured n8n webhook like any other. The only
# difference recorded here is *what* was retried -- a staged demo failure or a real
# one -- so the audit trail never implies a production incident was resolved when a
# prop was.
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
actor_label=user.display_name, actor_label=user.display_name,
action="workflow_retry", action="workflow_retry",
entity_type="outbox_event", entity_type="outbox_event",
metadata={"event_id": event_id, "previous_attempts": event.attempts}, metadata={
"event_id": event_id,
"previous_attempts": event.attempts,
"demo_scenario": was_demo_scenario,
},
) )
db.commit() db.commit()
return _to_out(event) return _to_out(event)
+7
View File
@@ -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
@@ -37,6 +39,11 @@ class Settings(BaseSettings):
knowledge_dir: str = "/app/knowledge/procedures" knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False mcp_hub_registration_enabled: bool = False
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
# these are only used for an honest reachability health check, not self-registration.
mcp_hub_base_url: str = ""
mcp_provider_id: str = "fleet-ops"
cors_allow_origins: str = "http://localhost:1228" cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility" demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels" demo_timezone: str = "Europe/Brussels"
+19
View File
@@ -10,6 +10,25 @@ from app.models.mixins import TimestampMixin
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed") DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
# be read as an integration-health problem: it is a scripted prop, not evidence that
# n8n is unhealthy. A dedicated error code -- rather than the generic
# "connectionError" a real timeout produces -- is what lets every reader tell the two
# apart without guessing from the message text.
#
# It is deliberately a `last_error_code` value and not a new column: the code is
# already persisted, already surfaced to the UI, and already localizable, so no schema
# change or migration is needed. A genuine later failure of this same event overwrites
# the code with the real one, which is exactly right -- from that moment it *is* a real
# failure.
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
"""True for the prepared demo failure, false for every real one."""
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
class OutboxEvent(TimestampMixin, Base): class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events" __tablename__ = "outbox_events"
+115 -3
View File
@@ -32,6 +32,14 @@ class VehicleOut(BaseModel):
attention: bool = False attention: bool = False
class VehiclePageOut(BaseModel):
items: list[VehicleOut]
page: int
page_size: int
total: int
total_pages: int
class BookingSummaryOut(BaseModel): class BookingSummaryOut(BaseModel):
public_ref: str public_ref: str
customer_ref: str customer_ref: str
@@ -122,6 +130,14 @@ class DataQualityIssueOut(BaseModel):
resolved_at: datetime | None = None resolved_at: datetime | None = None
class DataQualityIssuePageOut(BaseModel):
items: list[DataQualityIssueOut]
page: int
page_size: int
total: int
total_pages: int
class DataQualityIssueDetailOut(DataQualityIssueOut): class DataQualityIssueDetailOut(DataQualityIssueOut):
entity_snapshot: dict[str, Any] | None = None entity_snapshot: dict[str, Any] | None = None
related_snapshots: list[dict[str, Any]] = Field(default_factory=list) related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
@@ -143,6 +159,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,21 +265,52 @@ 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
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
pending: int pending: int
delivering: int #: Every failed delivery, staged and real together -- the number a viewer sees in
#: the run list.
failed: int failed: int
#: Failures that were not planted by the demo seed. This is the only failure count
#: that may influence `state`.
unexpected_failed: int = 0
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
#: Present so the UI can label them instead of implying the automation is broken.
demo_scenario_failed: int = 0
delivering: int
succeeded: int succeeded: int
latest_success_at: datetime | None latest_success_at: datetime | None
#: Most recent *real* failure; a staged one never sets this.
latest_failure_at: datetime | None latest_failure_at: datetime | None
latest_demo_scenario_at: datetime | None = None
expected_workflow_count: int
known_workflow_count: int
workflows: list[N8nWorkflowEvidence]
error_handler: N8nErrorHandlerStatus
class McpHubIntegrationStatus(BaseModel): class McpHubIntegrationStatus(BaseModel):
registration_enabled: bool registration_enabled: bool
state: Literal["not_configured", "configured"] state: Literal["not_configured", "no_evidence", "operational"]
total_calls: int
last_tool: str | None = None
last_client: str | None = None
last_called_at: datetime | None = None
hub_reachable: bool | None = None
class IntegrationStatusOut(BaseModel): class IntegrationStatusOut(BaseModel):
@@ -272,11 +366,16 @@ class DashboardMetrics(BaseModel):
pending_or_failed_workflows: int pending_or_failed_workflows: int
class EvidenceSignalOut(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class AttentionItem(BaseModel): class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"] kind: Literal["quality_issue", "vehicle"]
severity: str severity: str
rule_type: str rule_type: str
detail: str evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
link_type: Literal["vehicle", "booking", "customer"] link_type: Literal["vehicle", "booking", "customer"]
link_ref: str link_ref: str
issue_ref: str | None = None issue_ref: str | None = None
@@ -297,6 +396,10 @@ class AutomationRunOut(BaseModel):
attempts: int attempts: int
last_error: str | None last_error: str | None
last_error_code: str | None last_error_code: str | None
#: True for the deliberately seeded demo failure. The UI uses this to label the run
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
#: an unexplained production error.
is_demo_scenario: bool = False
occurred_at: datetime occurred_at: datetime
@@ -336,6 +439,7 @@ class McpVehicleDetailOut(BaseModel):
class McpKnowledgeSearchRequest(BaseModel): class McpKnowledgeSearchRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
max_sources: int = Field(default=4, ge=1, le=8) max_sources: int = Field(default=4, ge=1, le=8)
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
class AuditEventOut(BaseModel): class AuditEventOut(BaseModel):
@@ -352,3 +456,11 @@ class AuditEventOut(BaseModel):
before: dict[str, Any] | None = None before: dict[str, Any] | None = None
after: dict[str, Any] | None = None after: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
class AuditEventPageOut(BaseModel):
items: list[AuditEventOut]
page: int
page_size: int
total: int
total_pages: int
+114 -33
View File
@@ -18,7 +18,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
vehicle_id_by_ref: dict[str, uuid.UUID] = {} vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = [] vehicle_rows = []
vehicle_row_by_ref: dict[str, dict] = {}
for row in _read_csv("vehicles.csv"): for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4() vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append( vehicle_row = {
{ "id": vid,
"id": vid, "public_ref": row["public_ref"],
"public_ref": row["public_ref"], "make": row["make"],
"make": row["make"], "model": row["model"],
"model": row["model"], "model_year": int(row["model_year"]),
"model_year": int(row["model_year"]), "registration_number": row["registration_number"],
"registration_number": row["registration_number"], "location": row["location"],
"location": row["location"], "operational_status": row["operational_status"],
"operational_status": row["operational_status"], "odometer_km": int(row["odometer_km"]),
"odometer_km": int(row["odometer_km"]), "next_service_km": int(row["next_service_km"]),
"next_service_km": int(row["next_service_km"]), "active": _parse_bool(row["active"]),
"active": _parse_bool(row["active"]), "version": 1,
"version": 1, }
} vehicle_rows.append(vehicle_row)
) vehicle_row_by_ref[row["public_ref"]] = vehicle_row
db.execute(insert(Vehicle), vehicle_rows) db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows) counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {} booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = [] booking_rows = []
booking_row_by_ref: dict[str, dict] = {}
for row in _read_csv("bookings.csv"): for row in _read_csv("bookings.csv"):
bid = uuid.uuid4() bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append( booking_row = {
{ "id": bid,
"id": bid, "public_ref": row["public_ref"],
"public_ref": row["public_ref"], "customer_id": customer_id_by_ref[row["customer_ref"]],
"customer_id": customer_id_by_ref[row["customer_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], "starts_at": _parse_dt(row["starts_at"]) + shift,
"starts_at": _parse_dt(row["starts_at"]) + shift, "ends_at": _parse_dt(row["ends_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift, "status": row["status"],
"status": row["status"], "start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]), "requirements_complete": _parse_bool(row["requirements_complete"]),
"requirements_complete": _parse_bool(row["requirements_complete"]), }
} booking_rows.append(booking_row)
) booking_row_by_ref[row["public_ref"]] = booking_row
db.execute(insert(Booking), booking_rows) db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows) counts["bookings"] = len(booking_rows)
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref] return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref] return "vehicle", vehicle_id_by_ref[entity_ref]
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
# handful of seed-only rows below -- none of them carry an active rental or a
# real booking conflict (verified against the fixed seed dataset), only a
# genuinely-crossed service threshold or none at all, so those two fields are
# the only ones that vary per vehicle.
vehicle = vehicle_row_by_ref[vehicle_ref]
return {
"active_booking_refs": [],
"overlapping_booking_pairs": [],
"service_threshold_reached": service_threshold_reached,
"odometer_km": vehicle["odometer_km"],
"next_service_km": vehicle["next_service_km"],
"open_booking_overlap_issue_ref": None,
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
},
}
]
def _missing_field_signal(field: str) -> list[dict]:
return [{"code": "missing_field", "params": {"field": field}}]
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
# used to carry no structured signal at all -- just the placeholder summary
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
# pair of booking odometer readings engineered into seed/bookings.csv), using the
# exact same signal vocabulary the live scan (app.services.data_quality) already
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
"DQ-0005": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
}
],
"DQ-0006": _missing_field_signal("location"),
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
"DQ-0008": [
{
"code": "vehicle.rental_ended",
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
}
],
"DQ-0009": _missing_field_signal("location"),
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
"DQ-0011": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
}
],
"DQ-0012": _missing_field_signal("registration_number"),
"DQ-0013": _missing_field_signal("location"),
"DQ-0014": _missing_field_signal("registration_number"),
"DQ-0015": _missing_field_signal("location"),
"DQ-0016": _missing_field_signal("location"),
"DQ-0017": _missing_field_signal("location"),
"DQ-0018": _missing_field_signal("registration_number"),
"DQ-0019": _missing_field_signal("location"),
"DQ-0020": _missing_field_signal("location"),
"DQ-0021": _missing_field_signal("location"),
}
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]: def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so # The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English # they carry real, accurate structured signals (not just a legacy English
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
"params": {"booking_ref": related_refs[0] if related_refs else ""}, "params": {"booking_ref": related_refs[0] if related_refs else ""},
} }
] ]
return [] return _SEED_SIGNALS_BY_REF.get(public_ref, [])
dq_rows = [] dq_rows = []
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -323,7 +401,10 @@ def load_seed(db: Session) -> SeedResult:
"last_error": row["last_error"] or None, "last_error": row["last_error"] or None,
# The seed dataset's one synthetic failure (BK-H-0020) models a # The seed dataset's one synthetic failure (BK-H-0020) models a
# connection-timeout-style delivery failure -- see workflow_runs.csv. # connection-timeout-style delivery failure -- see workflow_runs.csv.
"last_error_code": "connectionError" if row["last_error"] else None, # It is coded as a *prepared demo scenario*, not as a real
# connectionError, so integration health never degrades because of a
# prop and a viewer is told plainly that this failure is staged.
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
"external_run_id": None, "external_run_id": None,
} }
) )
+13 -6
View File
@@ -11,7 +11,7 @@ from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_n8n_status from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
from app.services.knowledge import get_knowledge_provider from app.services.knowledge import get_knowledge_provider
settings = get_settings() settings = get_settings()
@@ -124,6 +124,7 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
n8n = derive_n8n_status(db) n8n = derive_n8n_status(db)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
mcp_hub = derive_mcp_hub_status(db)
return [ return [
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
@@ -141,17 +142,23 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
detail_code="ragcoreDetail", detail_code="ragcoreDetail",
detail_params={ detail_params={
"count": knowledge_health.document_count, "count": (
knowledge_health.document_count
if knowledge_health.document_count is not None
else "unknown"
),
"collection": knowledge_health.collection, "collection": knowledge_health.collection,
}, },
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="mcp_hub", key="mcp_hub",
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected", # `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
# to be called. Only real recorded `mcp_tool_request` calls make this
# "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=( detail_code=(
"mcpDetailEnabled" "mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
if settings.mcp_hub_registration_enabled
else "mcpDetailNotConnected"
), ),
detail_params={}, detail_params={},
), ),
+21 -4
View File
@@ -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}"
+172 -5
View File
@@ -2,15 +2,31 @@ from __future__ import annotations
from typing import Literal from typing import Literal
import httpx
from sqlalchemy import func, select 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.outbox import OutboxEvent from app.models.audit import AuditEvent
from app.schemas import N8nIntegrationStatus from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.schemas import (
McpHubIntegrationStatus,
N8nErrorHandlerStatus,
N8nIntegrationStatus,
N8nWorkflowEvidence,
)
settings = get_settings() settings = get_settings()
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# built (all with their full node set saved).
_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(
@@ -23,25 +39,111 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
failed = counts.get("failed", 0) failed = counts.get("failed", 0)
succeeded = counts.get("succeeded", 0) succeeded = counts.get("succeeded", 0)
# Prepared demo failures are props, not health signals. They stay visible and
# counted -- hiding them would be its own kind of lie -- but they are counted
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
# "operational". Without this split the demo seed's single staged failure pins the
# integration to "degraded" forever, which tells a viewer something untrue about
# the automation.
demo_scenario_failed = (
db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
)
or 0
)
unexpected_failed = max(failed - demo_scenario_failed, 0)
latest_success_at = db.scalar( latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
) )
# Health talks about real failures only, so the "latest failure" a health reader
# sees must exclude the staged one too.
latest_failure_at = db.scalar( latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
)
)
latest_demo_scenario_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
) )
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled: if not settings.n8n_dispatch_enabled:
state = "disabled" state = "disabled"
elif failed > 0 and succeeded == 0: elif unexpected_failed > 0 and succeeded == 0:
state = "unavailable" state = "unavailable"
elif failed > 0: elif unexpected_failed > 0:
state = "degraded" state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0: elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational" state = "operational"
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",
)
)
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
# procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(
AuditEvent.action == "n8n_procedures_synced"
)
)
# 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": latest_procedure_sync_at,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
}
workflows = [
N8nWorkflowEvidence(
name=name,
built=True,
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,
@@ -49,7 +151,72 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
pending=pending, pending=pending,
delivering=delivering, delivering=delivering,
failed=failed, failed=failed,
unexpected_failed=unexpected_failed,
demo_scenario_failed=demo_scenario_failed,
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,
latest_demo_scenario_at=latest_demo_scenario_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,
),
) )
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = (
db.scalar(
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
)
or 0
)
latest_call_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
.where(AuditEvent.action == "mcp_tool_request")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
last_called_at = latest_call_row[0] if latest_call_row else None
last_client = latest_call_row[1] if latest_call_row else None
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
state: Literal["not_configured", "no_evidence", "operational"]
if not settings.mcp_hub_registration_enabled:
state = "not_configured"
elif total_calls > 0:
state = "operational"
else:
state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
total_calls=total_calls,
last_tool=last_tool,
last_client=last_client,
last_called_at=last_called_at,
hub_reachable=hub_reachable,
)
def _check_hub_reachable() -> bool | None:
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
its side, so this is the only thing Fleet Ops itself can honestly check).
`None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url:
return None
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
return response.status_code == 200
except httpx.HTTPError:
return False
+3 -1
View File
@@ -33,7 +33,9 @@ class KnowledgeHealth(BaseModel):
tenant: str tenant: str
workspace: str workspace: str
collection: str collection: str
document_count: int # A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures".
document_count: int | None
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
+2 -18
View File
@@ -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
+164 -43
View File
@@ -3,18 +3,51 @@ 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"}
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
# is deliberately left out of this sentence (it still appears on the source card itself).
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}',
}
_DEFAULT_LANGUAGE = "en-GB"
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.
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
classifying the question as insufficiently answerable), `ask()` falls back to
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
stage with no generation step to fail. The fallback answer is always an extractive
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
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 +68,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 +86,135 @@ 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,
document_count=0, # RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
# so the UI never turns this into the misleading claim "0 procedures".
document_count=None,
) )
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
answered = self._ask_via_answers(question, correlation_id)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
# real retrieval rather than degrading straight to "unavailable". This never
# fabricates an answer to the question: it only ever shows an actually-cited
# excerpt RAGcore's own search already found, using the same extractive
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
final result, not a reason to fall back."""
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 None
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
return None
try:
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
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(
answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, KeyError, ValueError):
return None
def _ask_via_search_fallback(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
with self._client() as client:
response = client.post(
"/v1/search",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"max_results": 5,
},
)
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return unavailable
try:
results = body.get("results", [])
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
]
except (TypeError, KeyError, ValueError):
return unavailable
if not sources:
return GroundedAnswer( return GroundedAnswer(
answer="", answer="",
evidence_state="unavailable", evidence_state="insufficient",
sources=[], sources=[],
provider=self.name, provider=self.name,
correlation_id=correlation_id, correlation_id=correlation_id,
) )
try: template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
sources = [SourceCard(**s) for s in body.get("sources", [])] lead = sources[0]
evidence_state = body.get("evidence_state", "insufficient") answer = template.format(title=lead.title, excerpt=lead.excerpt)
if evidence_state not in ("grounded", "insufficient", "unavailable"): return GroundedAnswer(
evidence_state = "insufficient" answer=answer,
return GroundedAnswer( evidence_state="grounded",
answer=body.get("answer", ""), sources=sources,
evidence_state=evidence_state, provider=self.name,
sources=sources, correlation_id=correlation_id,
provider=self.name, )
correlation_id=correlation_id,
)
except (TypeError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
+11
View File
@@ -23,6 +23,17 @@ def test_audit_requires_operations_manager(employee_client):
assert response.status_code == 403 assert response.status_code == 403
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total"] >= len(body["items"])
assert body["total_pages"] >= 1
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str: def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db = SessionLocal() db = SessionLocal()
try: try:
+16
View File
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
assert item["severity"] in ("low", "medium", "high") assert item["severity"] in ("low", "medium", "high")
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
The API must never emit prose here -- only stable signal codes + params, exactly
like the data-quality issue detail page, for the frontend to localize."""
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["attention_items"]) > 0
for item in body["attention_items"]:
assert "detail" not in item
assert len(item["evidence_signals"]) > 0
for signal in item["evidence_signals"]:
assert signal["code"]
assert signal["code"] != "Synthetic deterministic seed issue"
def test_dashboard_recent_automation_capped_at_five(ops_client): def test_dashboard_recent_automation_capped_at_five(ops_client):
response = ops_client.get("/api/v1/dashboard") response = ops_client.get("/api/v1/dashboard")
body = response.json() body = response.json()
+27
View File
@@ -53,6 +53,18 @@ def test_list_issues_requires_operations_manager(employee_client):
assert response.status_code == 403 assert response.status_code == 403
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get(
"/api/v1/data-quality/issues",
params={"severity": "high", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(issue["severity"] == "high" for issue in body["items"])
assert body["total"] >= len(body["items"])
def test_get_issue_requires_operations_manager(employee_client): def test_get_issue_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
assert response.status_code == 403 assert response.status_code == 403
@@ -220,6 +232,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
assert vehicle["registration_number"] == "TST-999" assert vehicle["registration_number"] == "TST-999"
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
"""The snapshot used to omit registration_number entirely, so the 'provide missing
fields' form always showed it blank -- even for a vehicle whose plate was actually
on file, and even when a *different* field was the genuinely missing one."""
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = next(i for i in issues if i["entity_type"] == "vehicle")
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
def test_resolve_overlap_requires_operations_manager(employee_client): def test_resolve_overlap_requires_operations_manager(employee_client):
response = employee_client.post( response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", "/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
+33 -6
View File
@@ -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": {}},
+154 -5
View File
@@ -1,3 +1,24 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_integration_status_requires_operations_manager(employee_client): def test_integration_status_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/integrations/status") response = employee_client.get("/api/v1/integrations/status")
assert response.status_code == 403 assert response.status_code == 403
@@ -9,6 +30,7 @@ def test_integration_status_requires_authentication(client):
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client): def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
_reseed()
response = ops_client.get("/api/v1/integrations/status") response = ops_client.get("/api/v1/integrations/status")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
@@ -16,20 +38,65 @@ def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
n8n = body["n8n"] n8n = body["n8n"]
assert n8n["dispatch_enabled"] is True assert n8n["dispatch_enabled"] is True
assert n8n["succeeded"] >= 1 assert n8n["succeeded"] >= 1
# The seeded failure stays visible and counted...
assert n8n["failed"] >= 1 assert n8n["failed"] >= 1
# The seed deliberately carries both failed and succeeded events, so a single most- assert n8n["demo_scenario_failed"] == 1
# recent-event read would misreport health -- the aggregate must call this "degraded", # ...but it is a prepared prop, so it is not an unexpected failure and must not
# not "operational" or "unavailable". # move the integration off "operational". A staged failure that degrades the
assert n8n["state"] == "degraded" # health badge tells a viewer something untrue about the automation.
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
assert n8n["latest_success_at"] is not None assert n8n["latest_success_at"] is not None
assert n8n["latest_failure_at"] is not None # "latest failure" is a health signal, so the staged one never sets it; it is
# reported separately instead.
assert n8n["latest_failure_at"] is None
assert n8n["latest_demo_scenario_at"] is not None
mcp_hub = body["mcp_hub"] mcp_hub = body["mcp_hub"]
assert mcp_hub["registration_enabled"] is False assert mcp_hub["registration_enabled"] is False
assert mcp_hub["state"] == "not_configured" assert mcp_hub["state"] == "not_configured"
def test_a_real_failure_still_degrades_the_integration(ops_client):
"""The demo carve-out must be narrow: a failure that is not the prepared scenario
still degrades n8n, otherwise this change would hide real breakage."""
_reseed()
db = SessionLocal()
try:
real_failure = db.scalar(
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
)
assert real_failure is not None
restore = (real_failure.delivery_status, real_failure.last_error_code)
real_failure.delivery_status = "failed"
real_failure.last_error_code = "connectionError"
db.commit()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["unexpected_failed"] == 1
assert n8n["state"] == "degraded"
assert n8n["latest_failure_at"] is not None
finally:
real_failure.delivery_status, real_failure.last_error_code = restore
db.commit()
db.close()
def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client):
"""A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the
scenario can be shown again after it has been retried away."""
_reseed()
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert n8n["succeeded"] == 19
assert n8n["failed"] == 1
assert n8n["demo_scenario_failed"] == 1
assert n8n["unexpected_failed"] == 0
assert n8n["state"] == "operational"
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client): def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
for run in failed: for run in failed:
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry") retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
@@ -39,3 +106,85 @@ 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"])
# All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run
# has reported no real sync result yet, so -- like the other three workflows before
# their own first real signal -- there is no run evidence yet either.
assert ragcore_sync["built"] is True
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
+142
View File
@@ -122,3 +122,145 @@ 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}
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
# error handler already use -- so this real report must now show up as run evidence
# in the integration status, not stay hardcoded to "no evidence yet".
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["last_seen_at"] is not None
+287 -4
View File
@@ -158,12 +158,295 @@ 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, post_responses=None, raise_on=None):
self._get_response = get_response
self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
# /v1/answers and /v1/search to behave differently in the same call. Falls back
# to the single post_response when a path has no specific entry, so every
# existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {}
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_responses.get(path, 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")
# A malformed /v1/answers body triggers the same search fallback a real outage
# would, so the fallback's own /v1/search response must also be malformed here to
# exercise "the whole backend is misbehaving, not just one endpoint" honestly.
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}),
"/v1/search": _FakeResponse(200, {"results": "not-a-list"}),
}
),
)
answer = provider.ask("Anything?", "test-correlation-malformed")
assert answer.evidence_state == "unavailable"
def _search_body(**overrides) -> dict:
body = {
"results": [
{
"chunk_id": "chunk-1",
"citation": {
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Vehicle return procedure",
"section": "Return",
"excerpt": "Register the return odometer reading before releasing the vehicle.",
},
"rank": 1,
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
}
],
"degraded": False,
}
body.update(overrides)
return body
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
not a real 'insufficient evidence' classification) must not silently degrade
straight to 'unavailable' when RAGcore's own retrieval still works -- it should show
the real, cited excerpt search actually found instead."""
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
assert answer.evidence_state == "grounded"
assert "Register the return odometer reading" in answer.answer
assert "Vehicle return procedure" in answer.answer
assert len(answer.sources) == 1
assert answer.sources[0].title == "Vehicle return procedure"
assert answer.sources[0].excerpt == (
"Register the return odometer reading before releasing the vehicle."
)
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask(
"Wat is de procedure voor een voertuigretour?",
"test-correlation-fallback-nl",
language="nl-BE",
)
assert answer.evidence_state == "grounded"
assert answer.answer.startswith("Volgens ")
def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body(results=[])),
}
),
)
answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
+42
View File
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
assert events[0]["actor_type"] == "service" assert events[0]["actor_type"] == "service"
def test_search_knowledge_respects_requested_locale(client):
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"max_sources": 1,
"locale": "nl-BE",
},
headers=_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["evidence_state"] == "grounded"
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
inbound = "11111111-1111-1111-1111-111111111111"
response = client.post(
"/api/v1/integrations/mcp/search-knowledge",
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
)
assert response.status_code == 200
assert response.json()["correlation_id"] == inbound
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["correlation_id"] == inbound]
assert len(matching) == 1
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
response = client.get(
"/api/v1/integrations/mcp/operations-summary",
headers=_headers(client_id="no-correlation-probe"),
)
assert response.status_code == 200
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
assert len(matching) >= 1
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
def test_no_write_endpoints_exist_under_mcp_namespace(client): def test_no_write_endpoints_exist_under_mcp_namespace(client):
for method, path in [ for method, path in [
("post", "/api/v1/integrations/mcp/vehicles/MO-016"), ("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
+111 -6
View File
@@ -7,7 +7,7 @@ from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
@@ -22,13 +22,16 @@ def test_seed_counts_match_deterministic_dataset():
reset_and_seed(db) reset_and_seed(db)
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50 assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180 assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246 # 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The # real day of traffic rather than the same fixed 4 rows on every reset.
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches assert db.scalar(select(func.count()).select_from(Booking)) == 254
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
# real open issue) plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has # MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
# already crossed its service-due odometer threshold -- a genuine conflict the # already crossed its service-due odometer threshold -- a genuine conflict the
# previous hand-rolled scanner never checked for. # previous hand-rolled scanner never checked for.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27 assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20 assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2 assert db.scalar(select(func.count()).select_from(User)) == 2
finally: finally:
@@ -142,7 +145,10 @@ def test_seed_scenario_s5_failed_workflow_run():
assert failed.delivery_status == "failed" assert failed.delivery_status == "failed"
assert failed.attempts >= 1 assert failed.attempts >= 1
assert failed.last_error assert failed.last_error
assert failed.last_error_code == "connectionError" # Coded as a prepared demo scenario, not as a real connectionError: the whole
# point of this row is to demonstrate retry and audit, so nothing downstream
# may read it as evidence that the n8n integration is unhealthy.
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
finally: finally:
db.close() db.close()
@@ -174,3 +180,102 @@ def test_seed_dates_are_anchored_to_reset_moment():
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
finally: finally:
db.close() db.close()
def test_seed_today_movements_are_a_credible_mix():
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
status/date rule the dashboard router uses to build the today list."""
db = SessionLocal()
try:
reset_and_seed(db)
today = datetime.now(UTC).date()
bookings = db.scalars(select(Booking)).all()
departures = [
b
for b in bookings
if b.starts_at.date() == today and b.status in ("reserved", "active")
]
returns = [
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
]
assert len(departures) >= 5
assert len(returns) >= 5
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
assert len(vehicles_involved) > 4
finally:
db.close()
def test_every_blocked_vehicle_has_a_real_open_issue():
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
vehicle seeded as 'blocked' must now have at least one real, currently open
DataQualityIssue an operator can click through to."""
db = SessionLocal()
try:
reset_and_seed(db)
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
assert len(blocked) > 0
for vehicle in blocked:
open_issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
)
)
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
finally:
db.close()
def test_seed_scenario_mo024_service_conflict_is_flagged():
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
with nothing surfacing the contradiction. The shared vehicle-status evaluator
already catches this (a real conflict, not a fabricated third status) -- this test
exists so a future change can't silently regress it back to unexplained."""
db = SessionLocal()
try:
reset_and_seed(db)
vehicle = _by_ref(db, Vehicle, "MO-024")
assert vehicle is not None
assert vehicle.operational_status == "rented"
assert vehicle.odometer_km >= vehicle.next_service_km
issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "vehicle_status_conflict",
)
)
assert issue is not None
signals = issue.evidence_json.get("signals", [])
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
finally:
db.close()
def test_seed_evidence_has_no_placeholder_summary():
"""Every seed-only data-quality issue used to carry the vacuous evidence
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
had no way to understand why it needed attention. Every issue must now carry a real
summary and at least one localizable signal (code + params)."""
db = SessionLocal()
try:
reset_and_seed(db)
issues = db.scalars(select(DataQualityIssue)).all()
assert len(issues) > 0
for issue in issues:
summary = issue.evidence_json.get("summary", "")
assert summary != "Synthetic deterministic seed issue", (
f"{issue.public_ref} still has the meaningless placeholder summary"
)
signals = issue.evidence_json.get("signals", [])
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
finally:
db.close()
+12
View File
@@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
assert all(v["attention"] for v in vehicles) assert all(v["attention"] for v in vehicles)
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
response = ops_client.get(
"/api/v1/vehicles",
params={"status": "maintenance", "page": 1, "page_size": 25},
)
assert response.status_code == 200
body = response.json()
assert len(body["items"]) <= 25
assert all(v["operational_status"] == "maintenance" for v in body["items"])
assert body["total"] >= len(body["items"])
def test_vehicle_detail_includes_related_records(ops_client): def test_vehicle_detail_includes_related_records(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-016") response = ops_client.get("/api/v1/vehicles/MO-016")
assert response.status_code == 200 assert response.status_code == 200
+59
View File
@@ -1,9 +1,28 @@
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def _reseed() -> None:
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
The suite shares one session-scoped database and earlier files legitimately mutate
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
rather than depend on file ordering.
"""
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
def test_list_workflows_requires_operations_manager(employee_client): def test_list_workflows_requires_operations_manager(employee_client):
response = employee_client.get("/api/v1/workflows") response = employee_client.get("/api/v1/workflows")
assert response.status_code == 403 assert response.status_code == 403
def test_list_workflows_includes_seeded_failed_run(ops_client): def test_list_workflows_includes_seeded_failed_run(ops_client):
_reseed()
response = ops_client.get("/api/v1/workflows", params={"status": "failed"}) response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
assert response.status_code == 200 assert response.status_code == 200
runs = response.json() runs = response.json()
@@ -20,6 +39,7 @@ def test_retry_requires_failed_status(ops_client):
def test_retry_failed_run_moves_to_pending_and_audits(ops_client): def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json() failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
target = failed[0]["event_id"] target = failed[0]["event_id"]
@@ -36,3 +56,42 @@ def test_retry_requires_operations_manager(employee_client):
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry" "/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
) )
assert response.status_code == 403 assert response.status_code == 403
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
"""The one seeded failure must announce itself as staged. An unexplained red row in
a demo reads as a broken product; a labelled one reads as the retry story it is."""
_reseed()
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_runs = [run for run in runs if run["is_demo_scenario"]]
assert len(demo_runs) == 1
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
assert runs
assert all(run["is_demo_scenario"] is False for run in runs)
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
"""The retry is a real redelivery either way; the audit records which kind of
failure it resolved so a staged retry is never mistaken for a production fix."""
_reseed()
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
demo_run = next(run for run in failed if run["is_demo_scenario"])
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
assert response.status_code == 200
assert response.json()["status"] == "pending"
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
entry = next(
event
for event in audit
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
== demo_run["event_id"]
)
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
assert metadata["demo_scenario"] is True
+4
View File
@@ -31,9 +31,13 @@ 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}
MCP_HUB_REGISTRATION_ENABLED: ${MCP_HUB_REGISTRATION_ENABLED:-false}
MCP_HUB_BASE_URL: ${MCP_HUB_BASE_URL:-}
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
+14 -8
View File
@@ -1,17 +1,20 @@
{ {
"_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.",
"provider_id": "mobilityops", "provider_id": "mobilityops",
"version": "1.0.0", "version": "1.1.0",
"required_scope": "mobilityops.read", "required_scope": "mobilityops.read",
"tools": [ "tools": [
{ {
"name": "mobilityops_get_operations_summary", "name": "fleet_ops_get_operations_summary",
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.", "deprecated_aliases": ["mobilityops_get_operations_summary"],
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.",
"read_only": true, "read_only": true,
"inputSchema": {"type": "object", "additionalProperties": false}, "inputSchema": {"type": "object", "additionalProperties": false},
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"}
}, },
{ {
"name": "mobilityops_list_attention_vehicles", "name": "fleet_ops_list_attention_vehicles",
"deprecated_aliases": ["mobilityops_list_attention_vehicles"],
"description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.", "description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -26,7 +29,8 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"}
}, },
{ {
"name": "mobilityops_get_vehicle_details", "name": "fleet_ops_get_vehicle_details",
"deprecated_aliases": ["mobilityops_get_vehicle_details"],
"description": "Return a read-only operational view of one vehicle by its stable public reference.", "description": "Return a read-only operational view of one vehicle by its stable public reference.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -38,15 +42,17 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
}, },
{ {
"name": "mobilityops_search_knowledge", "name": "fleet_ops_search_knowledge",
"description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.", "deprecated_aliases": ["mobilityops_search_knowledge"],
"description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["question"], "required": ["question"],
"properties": { "properties": {
"question": {"type": "string", "minLength": 3, "maxLength": 1000}, "question": {"type": "string", "minLength": 3, "maxLength": 1000},
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4} "max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4},
"locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"}
}, },
"additionalProperties": false "additionalProperties": false
}, },
+14 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}" callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}" source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "Missing deployment .env" >&2 echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)" # The workflow file no longer carries the callback token as a literal header value -- both
if [ -z "$callback_token" ]; then # the webhook trigger and the outbound callback authenticate via named n8n Header Auth
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2 # credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
exit 1 # exist in the target n8n instance before this workflow is activated; see the echo below.
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)" temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json" container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() { cleanup() {
@@ -32,15 +30,17 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" ' jq --arg callback_url "$callback_url" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url | (.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow" ' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow" docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}" echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
echo "Before activating: in the n8n UI, create Header Auth credentials named"
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "those secret values, and does not restart the container -- restart it yourself once"
echo "credentials are wired up and the workflow is published, if required."
+13 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}" container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}" scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}" source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "Missing deployment .env" >&2 echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)" # The workflow file no longer carries the callback token as a literal header value -- the
if [ -z "$callback_token" ]; then # scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2 # Token"), which must exist in the target n8n instance before this workflow is activated;
exit 1 # see the echo below.
fi
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)" temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json" container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
cleanup() { cleanup() {
@@ -32,15 +30,16 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" ' jq --arg scan_url "$scan_url" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url | (.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
' "$source_workflow" > "$temporary_workflow" ' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow" docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker restart "$container_name" >/dev/null
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}" echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
echo "Before activating: in the n8n UI, create a Header Auth credential named"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "that secret value, and does not restart the container -- restart it yourself once"
echo "the credential is wired up and the workflow is published, if required."
+33 -9
View File
@@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW
## Provider registration ## Provider registration
- provider ID: `mobilityops` - provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is
- API base: configurable internal MobilityOps API URL catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a
- authentication: scoped service token registration call)
- API base: internal Fleet Ops API URL, reached via `MCP_HUB_SERVICE_TOKEN` auth
- authentication: scoped service token (`X-Service-Token`), plus `X-Client-Id`
- mode: read-only - mode: read-only
- required scope: `mobilityops.read` - **Confirmed live in production** on the ITWorx MCP Hub's own deployment (Tower), with
a real contract fix already applied there (`vehicle.get`'s wire parameter normalized to
camelCase `vehicleRef`). `docs/final-integrations/current-state-audit.md` has the full
evidence.
## Tools ## Tools
The machine-readable definitions are in `contracts/mcp-tools.json`. The machine-readable definitions are in `contracts/mcp-tools.json`. The Hub's own live
connector (`ITWorx_MCP_Hub` repo, `connectors/mobilityops/`) publishes these under its
own dotted namespace — that naming is the Hub's to own, not Fleet Ops's:
1. `mobilityops_get_operations_summary` 1. `mobilityops.operations.summary`
2. `mobilityops_list_attention_vehicles` 2. `mobilityops.attention.list`
3. `mobilityops_get_vehicle_details` 3. `mobilityops.vehicle.get`
4. `mobilityops_search_knowledge` 4. `mobilityops.knowledge.search`
Fleet Ops's own internal audit trail (`AuditEvent.metadata_json.tool`, visible on
`/api/v1/audit?action=mcp_tool_request`) labels these calls `fleet_ops_get_operations_summary`,
`fleet_ops_list_attention_vehicles`, `fleet_ops_get_vehicle_details`,
`fleet_ops_search_knowledge` — a separate, Fleet-Ops-owned naming layer for its own audit
log, not the wire-level MCP tool name a client calls.
`search-knowledge` accepts a `locale` field (`nl-BE` | `en-GB` | `fr-BE`, default
`en-GB`) that is passed straight through to the active knowledge provider.
## Correlation ID
The inbound `X-Correlation-Id` header (set by the Hub, itself either forwarding the
MCP client's ID or minting one) is preserved through Fleet Ops's own handling and audit
log; Fleet Ops only mints a fresh correlation ID when none is supplied or the supplied
value isn't a valid UUID. See `get_correlation_id` in
`backend/app/api/routers/mcp_integrations.py`.
## Routing ## Routing
+46 -15
View File
@@ -16,7 +16,7 @@ Steps:
4. return a stable workflow result; 4. return a stable workflow result;
5. on errors, fail visibly so the outbox dispatcher can retry. 5. on errors, fail visibly so the outbox dispatcher can retry.
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency. The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
## Second live workflow: scheduled quality scan ## Second live workflow: scheduled quality scan
@@ -39,24 +39,24 @@ Steps:
open, so a duplicate or overlapping trigger (a manual test run firing close to the open, so a duplicate or overlapping trigger (a manual test run firing close to the
scheduled one, or a retried HTTP call) does no duplicate domain work. scheduled one, or a retried HTTP call) does no duplicate domain work.
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
published the same way as the return-processing workflow (see (see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
`"active": false` so it cannot fire against any environment until deliberately active on the live instance; a fresh import ships inactive until credentials are wired up
published with a real service token. and it is deliberately published.
## Deferred: knowledge sync ## RAGcore procedure sync (in progress)
Input: manual trigger or manifest-changed event. RAGcore is now reachable in this environment; a live inspection of its real contract is
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
RAGcore Procedure Sync", is being built against that real contract (not the sketch
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
Steps: ## Workflow error handler (in progress)
1. read the fixed knowledge manifest; Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
2. call RAGcore ingestion/sync API; to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
3. record per-document results through MobilityOps integration status API. secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
Deferred until RAGcore's live ingestion API is available in this environment; must not
delay or block the core demo.
## Outbox dispatcher ## Outbox dispatcher
@@ -67,3 +67,34 @@ delay or block the core demo.
- supports explicit manual retry; - supports explicit manual retry;
- preserves last error and response metadata; - preserves last error and response metadata;
- does not hold a database transaction open during network I/O. - does not hold a database transaction open during network I/O.
## Prepared demo failure versus real failure
The demo seed deliberately plants exactly one failed delivery (`BK-H-0020`, see
`seed/workflow_runs.csv`). It exists to demonstrate retry and audit, so it must never be
read as evidence that the automation is unhealthy.
It is distinguished by its `last_error_code`, `demoScenarioTimeout`
(`app.models.outbox.DEMO_SCENARIO_ERROR_CODE`) — not by a new column, so no migration is
involved. A real timeout produces `connectionError`; the two are never confused.
Consequences, all enforced by tests:
- `/api/v1/integrations/status` reports `failed` (everything), `unexpected_failed` (real
failures only) and `demo_scenario_failed` separately.
- Only `unexpected_failed` can move n8n off `operational`. A prepared failure alone
leaves the integration **operational** — a staged prop may not raise a red flag.
- `latest_failure_at` is a health signal and therefore ignores the prepared failure;
`latest_demo_scenario_at` reports it separately.
- `/api/v1/workflows` marks the run with `is_demo_scenario: true`. The Automation page
labels it "Prepared demo scenario", explains that it is a simulated temporary failure,
and offers a distinct "Retry demo scenario" action.
- A genuine later failure of that same event overwrites the code with the real one, and
from that moment it counts as a real failure — the carve-out is narrow by construction.
The retry itself is real in both cases: the event goes back on the outbox and the
dispatcher delivers it to the configured n8n webhook like any other, so 19 succeeded +
1 failed becomes 20 succeeded + 0 failed only when n8n genuinely accepts the delivery.
Nothing is marked succeeded without a real round trip. The audit entry records
`demo_scenario: true/false` so a staged retry is never mistaken for a production fix.
A demo reset recreates the original 19 + 1 scenario.
+6 -1
View File
@@ -36,7 +36,12 @@ Vehicle `MO-016` has two imported overlapping reservations. Expected: visible qu
### S5 — Failed workflow ### S5 — Failed workflow
One seeded outbox/workflow record is failed with a safe simulated connection error. Expected: dashboard and Automation page show it; Operations Manager can retry. One seeded outbox/workflow record is failed with a safe simulated connection error, coded
`demoScenarioTimeout` so it is recognisable as a prepared scenario rather than a real
incident. Expected: dashboard and Automation page show it, labelled as a prepared demo
scenario; n8n stays "Operational"; the Operations Manager can retry it, after which the
overview reads 20 succeeded and 0 failed. See `docs/11-n8n-integration.md`, "Prepared demo
failure versus real failure".
### S6 — Grounded damage question ### S6 — Grounded damage question
+11 -2
View File
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
which runs: which runs:
```bash ```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n docker compose restart n8n
``` ```
@@ -56,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
(`n8n import:workflow` always leaves the workflow deactivated regardless of its (`n8n import:workflow` always leaves the workflow deactivated regardless of its
`"active"` field; `publish:workflow` + a restart is what actually activates it.) `"active"` field; `publish:workflow` + a restart is what actually activates it.)
Before it will actually process a return, create two Header Auth credentials in the n8n
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
the workflow's webhook trigger and outbound HTTP call reference these credentials by
name; no secret value is embedded in the workflow file itself.
Verify the full round trip: Verify the full round trip:
```bash ```bash
@@ -77,11 +83,14 @@ make n8n-setup-scan
which runs: which runs:
```bash ```bash
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n docker compose restart n8n
``` ```
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
the n8n UI before a run will succeed.
Verify: Verify:
```bash ```bash
+509
View File
@@ -0,0 +1,509 @@
# MobilityOps visual product roadmap
## Purpose
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
implementation plan. It improves the existing operational workflows without
expanding the locked product scope.
The intended outcome is a platform that:
- lets an operator identify and act on urgent work within thirty seconds;
- remains efficient with the current synthetic data set and larger realistic data sets;
- keeps status, context and primary actions visible on desktop and mobile;
- uses a readable, consistent visual hierarchy;
- communicates integration and knowledge health without contradictory signals;
- preserves the existing domain rules, audit guarantees and graceful degradation.
## Scope guardrails
Included:
- information architecture and visual hierarchy;
- list density, pagination, filtering and responsive presentation;
- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows;
- accessibility, perceived performance and UI observability;
- regression tests, visual evidence and rollout safeguards.
Excluded:
- new accounting, payments, CRM, inventory, HR or reservation modules;
- maintenance work orders, calendar views or advanced analytics;
- changes to RAGcore or ITWorx MCP Hub repositories;
- autonomous write actions or write-capable MCP tools;
- a redesign of backend domain rules that is not required by the UI work.
## Audit baseline
The audit was performed against the deployed application at desktop, tablet and
mobile widths. The current strengths to preserve are the professional visual
identity, consistent status language, strong login page, semantic page structure,
visible keyboard focus, absence of horizontal overflow and clear return and
data-quality narratives.
The principal improvement signals are:
| Area | Baseline observation | Consequence |
|---|---|---|
| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling |
| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens |
| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears |
| Typography | Many metadata labels and badges are approximately 911 px | Readability is weaker than the visual quality suggests |
| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation |
| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory |
| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan |
These values are baselines, not permanent acceptance thresholds. Each affected
phase must record a before-and-after measurement.
## Delivery principles
1. Fix operational friction before decorative polish.
2. Prefer progressive disclosure over removing useful evidence.
3. Keep the primary status and next action visible at every viewport.
4. Paginate or virtualise unbounded collections; never solve them only with CSS.
5. Derive every displayed count and state from persisted or external evidence.
6. Preserve URLs, permissions, audit semantics and keyboard operation.
7. Ship each phase as a coherent, independently reversible commit.
8. Validate every phase at 390 px, 768 px and 1440 px.
## Target measures
The roadmap is complete when the following targets are met:
| Measure | Target |
|---|---|
| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used |
| Mobile status visibility | Primary record status visible on every detail page |
| Horizontal overflow | None at 360 px and above |
| Default text | At least 14 px |
| Secondary metadata | At least 12 px; exceptions require an accessibility justification |
| Touch target | At least 44 by 44 CSS px for primary mobile controls |
| Keyboard access | All actions reachable with visible focus and logical order |
| List navigation | Filter, paging and search state represented in the URL where practical |
| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 |
| Browser console | No application errors during the five-minute demo |
| Performance | No avoidable rendering of more than one page of list records |
| Automated acceptance | Existing backend, frontend and Playwright gates remain green |
## Roadmap overview
| Phase | Theme | Priority | Indicative effort | Depends on |
|---|---|---:|---:|---|
| R0 | Baseline and design-system guardrails | P0 | 23 days | None |
| R1 | Operational lists and audit log | P0 | 57 days | R0 |
| R2 | Responsive shell and readable hierarchy | P0 | 46 days | R0 |
| R3 | Dashboard and record-detail efficiency | P1 | 57 days | R1, R2 |
| R4 | Guided workflows and decision surfaces | P1 | 57 days | R2, R3 |
| R5 | Knowledge, automation and trust signals | P1 | 35 days | R2 |
| R6 | Accessibility, performance and release evidence | P0 gate | 46 days | R1R5 |
Indicative total: 2841 focused engineering days. Phases can span multiple
calendar sprints, but their exit gates should not be split across releases.
## R0 — Baseline and design-system guardrails
### Objective
Create shared UI rules and reproducible measurements before changing individual
pages.
### Work packages
#### R0.1 — Visual regression baseline
- Capture authenticated reference screenshots for the seven principal routes and
the login page at 390 × 844, 768 × 1024 and 1440 × 900.
- Add stable screenshot fixtures using the deterministic seed.
- Mask only genuinely variable timestamps; do not mask operational content.
- Record viewport width, page height, visible item count and horizontal overflow.
#### R0.2 — Typography and spacing tokens
- Replace scattered sub-12 px declarations with named type tokens.
- Establish body, metadata, label, badge, table-header and navigation sizes.
- Define compact and comfortable density variants without duplicating page CSS.
- Preserve the existing petrol/teal palette and status colours.
#### R0.3 — Shared responsive patterns
- Define a mobile page-header contract: title, visible status, primary action and
overflow actions.
- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns.
- Establish 44 px mobile target sizing and a consistent sticky-offset system.
### Acceptance criteria
- Visual baselines exist for all principal routes and viewports.
- No normal operational metadata is smaller than 12 px.
- Shared components document their responsive behaviour.
- Existing Playwright demo and frontend build pass unchanged.
## R1 — Operational lists and audit log
### Objective
Make high-volume operational information scannable and bounded on every device.
### Work packages
#### R1.1 — Audit API pagination and filtering
- Replace the default fixed `limit=100` response with cursor or page-based pagination.
- Add filters for date range, action, actor, entity type, entity reference and
correlation ID.
- Retain stable newest-first ordering and service-token protections.
- Return total or continuation metadata suitable for the selected paging model.
#### R1.2 — Compact audit timeline
- Render 2025 compact events per page.
- Group correlated events and repetitive scheduled events without hiding their count.
- Move full UUIDs, before/after data and technical metadata into a details drawer.
- Keep human-readable action, actor, target, outcome and timestamp in the main row.
- Preserve direct navigation to related records.
#### R1.3 — Vehicle-list pagination
- Add server-compatible pagination to the vehicle list.
- Preserve status, location, attention and search filters across pages.
- Store filter and page state in the URL.
- Provide a prominent “attention required” view without inventing new metrics.
- Use compact mobile rows rather than full table-to-card expansion for every vehicle.
#### R1.4 — Data-quality queue controls
- Paginate the open issue list.
- Add severity and rule grouping/filtering.
- Add “previous issue” and “next issue” navigation to review pages.
- Keep scan and resolution actions permission-aware.
### Acceptance criteria
- No default list renders more than 25 records.
- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with
the deterministic seed.
- Paging and filters survive reload, back navigation and direct links.
- Empty, loading, error and end-of-results states are explicit.
- API tests cover invalid cursors/pages, combined filters and authorization.
## R2 — Responsive shell and readable hierarchy
### Objective
Reduce navigation noise and keep essential context visible on small screens.
### Work packages
#### R2.1 — Top-bar simplification
- Keep global search and current operational context primary.
- Move language, timezone and sign-out into the operator menu.
- Combine demo disclosure and demo progress into one compact control.
- Replace the visually empty mobile search field with an explicit search button or
a full-width command surface.
#### R2.2 — Mobile page actions
- Stop hiding the complete page-action region.
- Always show record status next to or below the title.
- Keep one primary action visible and move secondary actions to an accessible menu.
- Verify booking, vehicle, data-quality and automation detail headers independently.
#### R2.3 — Navigation refinement
- Review whether the desktop sidebar should remain available at wider tablet widths.
- Increase mobile navigation label size and touch area.
- Keep four or five primary destinations visible and put lower-frequency destinations
under “More” if six items cannot meet readability targets.
- Preserve current routes and role-based visibility.
#### R2.4 — Typography rollout
- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and
mobile navigation.
- Rebalance padding where larger text would otherwise increase page height excessively.
- Verify Dutch, French and English labels for clipping and wrapping.
### Acceptance criteria
- Status remains visible on every sampled mobile detail page.
- No essential action is hover-only or hidden solely because of viewport width.
- Top-bar controls fit without clipping at 390, 768 and 1440 px.
- Language changes do not introduce horizontal overflow.
- Touch-target and typography targets are met.
## R3 — Dashboard and record-detail efficiency
### Objective
Put operational decisions above supporting detail and make desktop space work harder.
### Work packages
#### R3.1 — Dashboard hierarchy
- Keep readiness and highest-severity attention items visible in the first viewport.
- Reduce the vertical weight of the demo-scenario panel after the scenario starts.
- Limit the initial attention queue and link to a complete filtered view.
- Balance “movements today” against attention content when only a few movements exist.
- Keep integrations and recent activity available without duplicating status copy.
#### R3.2 — Mobile dashboard summary
- Convert readiness into a compact, horizontally accessible summary or disclosure.
- Show the highest-priority attention items first with an explicit remaining count.
- Surface the next departure and return before secondary integration information.
#### R3.3 — Vehicle-detail workspace
- Replace the sparse overview with a two-column desktop composition.
- Surface current status, location, odometer, next booking and active attention items.
- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs.
- Avoid new maintenance-domain functionality; link only to existing evidence.
#### R3.4 — Booking-detail summary
- Rebalance the facts grid so incomplete final rows do not create large empty bands.
- On mobile, show status and return readiness before secondary booking facts.
- Keep the return form close to the task entry point while retaining validation context.
### Acceptance criteria
- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement
context without scrolling.
- Sparse states remain intentional and balanced rather than appearing unfinished.
- All values remain derived from persisted data.
- Existing return and status-domain tests remain unchanged and green.
## R4 — Guided workflows and decision surfaces
### Objective
Reduce the distance between evidence and the decision an operator must make.
### Work packages
#### R4.1 — Return flow refinement
- Keep the step indicator visible while completing or reviewing a return.
- Use a compact mobile booking summary above the form.
- Present validation errors next to fields and in a short focusable summary.
- Preserve the post-submit explanation of inspection, vehicle status, quality issue,
queued automation and next-booking risk.
#### R4.2 — Duplicate-customer decision layout
- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop.
- Keep matching fields collapsed by default and differences expanded.
- Keep irreversible-action language and audit preview adjacent to the merge button.
- On mobile, use a deliberate sequence: evidence, differences, survivor, preview,
confirmation.
#### R4.3 — Other data-quality resolutions
- Standardise evidence, proposed resolution, consequence and audit-preview sections.
- Keep resolve, reject and defer actions visually distinct and permission-aware.
- Provide clear success feedback and navigation to the next issue.
#### R4.4 — Guided demo integration
- Reduce competition between the guide and normal navigation.
- Persist progress without obscuring page controls.
- Make “go to this step” land on the relevant element or focused task state.
- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet.
### Acceptance criteria
- Primary workflow action is visible or reachable with one obvious interaction.
- A user never needs to scroll past repeated explanatory content to discover the
required decision.
- Focus moves to validation failures and success results appropriately.
- The five-minute demo remains deterministic.
## R5 — Knowledge, automation and trust signals
### Objective
Make external-system health understandable to an operator without exposing irrelevant
technical detail.
### Work packages
#### R5.1 — Knowledge status semantics
- Separate provider availability, indexed-source count and last successful sync.
- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale.
- Label extractive fallback answers honestly while preserving source cards.
- Reduce empty-state height and show a compact explanation of what a cited answer contains.
- Keep suggested questions concise and prioritised.
#### R5.2 — Source and freshness presentation
- Surface source title, version, section and freshness consistently.
- Keep document UUIDs and raw retrieval metadata behind technical details.
- Provide explicit grounded, insufficient-evidence and unavailable states.
#### R5.3 — Integration summary redesign
- Structure each integration around state, last success, affected workflow and next action.
- Move client IDs, raw endpoints and UUIDs to a detail disclosure.
- Remove unnecessary fixed card height, especially at tablet widths.
- Keep genuine mixed states visible without presenting them as contradictions.
#### R5.4 — Workflow evidence table
- Improve status and recency scanning.
- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”.
- Keep retry actions safe, audited and available only where already supported.
### Acceptance criteria
- Knowledge and integration states cannot make mutually contradictory claims.
- An operator can identify the current state and next action within one card scan.
- Technical identifiers do not dominate default views.
- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green.
## R6 — Accessibility, performance and release evidence
### Objective
Turn the improvements into a verified, releasable quality baseline.
### Work packages
#### R6.1 — Accessibility review
- Test complete keyboard traversal of login, navigation, filters, return, merge,
knowledge, workflow retry and audit details.
- Verify focus order, focus restoration, dialog labelling and error announcements.
- Run automated accessibility checks on all principal routes.
- Manually verify status is not encoded by colour alone.
- Verify zoom at 200% and 360 px responsive reflow.
#### R6.2 — Rendering and interaction performance
- Measure list rendering before and after pagination.
- Check layout shift and interaction latency on dashboard and long-list routes.
- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees.
- Test with reduced motion and a throttled CPU profile.
#### R6.3 — Cross-browser and localisation pass
- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where
supported by the test environment.
- Run Dutch, French and English visual checks at all target widths.
- Verify Europe/Brussels date and time rendering.
#### R6.4 — Final automated journey and evidence
- Extend the existing Playwright demo with pagination, mobile status and accessible
details checks.
- Capture final screenshots for all principal pages.
- Add before-and-after measurements and known PoC limitations to final evidence.
- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build.
### Acceptance criteria
- No critical or serious automated accessibility violations on principal routes.
- All target measures in this document pass.
- `docs/14-testing-and-acceptance.md` passes from a clean checkout.
- `artifacts/evidence/final-summary.md` contains the final commands, counts,
screenshots and limitations.
## Suggested release slices
### Release A — Operational scale
Contains R0 and R1. This release eliminates the most severe scroll and list-density
problems without materially changing workflow structure.
Release gate:
- paginated audit, vehicles and data-quality queues;
- URL-backed filters;
- unchanged audit and authorization semantics;
- visual regression baseline green.
### Release B — Responsive operations
Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy
and detail-page use of space.
Release gate:
- visible mobile statuses and primary actions;
- readable typography and touch targets;
- dashboard first-viewport target met;
- Dutch, French and English responsive checks green.
### Release C — Guided decisions and trust
Contains R4 and R5. This release refines the return, merge, knowledge, automation and
demo-guide experiences.
Release gate:
- decision actions remain near their evidence;
- honest and non-contradictory external health states;
- five-minute demo passes end to end.
### Release D — Quality certification
Contains R6 and only defects found by its gates. It adds no new product scope.
Release gate:
- full clean-checkout acceptance;
- accessibility and performance targets met;
- final evidence updated;
- release tag and deployed revision recorded.
## Implementation sequence and dependencies
```text
R0 shared tokens and baselines
├─ R1 pagination and compact lists ─┐
└─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows
└─ R5 trust surfaces
R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate
```
R1 and R2 may be developed in parallel after R0. R3 should consume both shared list
and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can
start after R2 but must share its disclosure and status patterns. R6 is a gate, not a
cleanup bucket; defects discovered there return to the owning phase.
## Per-phase engineering checklist
Every phase must include:
1. a short before-state measurement;
2. API and data-contract impact assessment;
3. implementation using shared components where applicable;
4. unit/API tests for changed contracts;
5. Playwright coverage for the changed user journey;
6. manual checks at 390, 768 and 1440 px in all supported languages;
7. accessibility and console check;
8. updated evidence and `PROJECT_STATE.md` entry;
9. one coherent commit after validation passes;
10. deployment verification before starting the next release slice.
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary |
| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability |
| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again |
| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states |
| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility |
| External health data is stale | Show last successful evidence and distinguish unknown from healthy |
| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` |
## Definition of roadmap completion
This roadmap is complete only when Releases A through D are deployed and verified,
all target measures pass, the original five-minute demo remains green, and the final
evidence records the exact deployed revision. Completing only the visual styling or one
high-priority page does not complete the roadmap.
+5 -3
View File
@@ -65,9 +65,11 @@ generated and kept fresh across resets.
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over `RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
the same interface once a real RAGcore backend is available — swapping providers is a the same interface once a real RAGcore backend is available — swapping providers is a
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change. configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
- **ITWorx MCP Hub**: not connected. Registration is disabled by default - **ITWorx MCP Hub**: the UI reports "Operational" only when Fleet Ops has really
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" — recorded `mcp_tool_request` calls in its own audit log. `MCP_HUB_REGISTRATION_ENABLED`
never a fabricated successful registration. on its own never makes it operational — registration is catalog-driven on the Hub's
side, so the flag alone is not evidence of anything, and a successful registration is
never fabricated.
## Where to go next ## Where to go next
@@ -0,0 +1,87 @@
# AI Operations Brief — runbook and live evidence
Answers, using a real MCP client through the real ITWorx MCP Hub connector against live
production Fleet Ops: *"Which vehicles need the most attention today, why, and which
internal procedure should be followed for the most important issue?"*
## What this is not
Not a chatbot. No write actions exist under `/api/v1/integrations/mcp/*` (verified by
`test_no_write_endpoints_exist_under_mcp_namespace`). Every call below is a plain
read-only tool invocation, exactly as the deployed Hub connector performs them.
## Reproducible command
Run from inside the live `itworx-mcp-hub-connector-mobilityops-1` container (has the
real `MOBILITYOPS_ENDPOINT`, `MOBILITYOPS_SERVICE_TOKEN_FILE` and the real
`packages.connector_kit.mobilityops.MobilityOpsClient` already available — the same
client class the deployed connector uses):
```sh
docker exec -e PYTHONPATH=/opt/hub -w /opt/hub \
itworx-mcp-hub-connector-mobilityops-1 python3 - <<'PY'
from packages.connector_kit.mobilityops import FileTokenProvider, MobilityOpsClient, MobilityOpsSettings
import os, json
settings = MobilityOpsSettings(
base_url=os.environ["MOBILITYOPS_ENDPOINT"],
token_reference=os.environ["MOBILITYOPS_SERVICE_TOKEN_FILE"],
)
client = MobilityOpsClient(settings, FileTokenProvider())
client_id = "ai-ops-brief"
summary = client.operations_summary(client_id)
attention = client.attention_vehicles(client_id, minimum_severity="high", date=None, limit=20)
top_ref = attention[0]["vehicle_ref"] if attention else None
details = client.vehicle_details(client_id, top_ref) if top_ref else None
answer = client.search_knowledge(client_id, "What must I do when a vehicle returns with damage?", max_sources=2)
print(json.dumps({"summary": summary, "top_attention": attention[0] if attention else None, "vehicle": details, "answer": answer}, indent=2))
client.close()
PY
```
This calls, in order: `fleet_ops_get_operations_summary``fleet_ops_list_attention_vehicles`
`fleet_ops_get_vehicle_details``fleet_ops_search_knowledge`. No result is invented —
every field printed is exactly what Fleet Ops's live API returned.
## Live run, 2026-08-05
- **Operations summary**: 21 available, 11 rented, 6 cleaning, 5 maintenance,
**7 blocked**; 23 open quality issues; 1 pending/failed workflow.
- **Most pressing vehicle**: `MO-031``missing_required_field`, "near-future booking;
required operational inspection missing", detected `2026-08-05T11:08:33Z`. (16 vehicles
currently carry a `high`-severity open issue; `MO-031` was the earliest-detected.)
- **Vehicle detail (`MO-031`)**: Adria Matrix, 2022, Geel, `operational_status: blocked`,
41,149 km (service due at 50,000 km), no current booking.
- **Grounded procedure (English)**: *Damage handling procedure* v1.3, section "1.
Immediate actions" — "When a vehicle returns with visible or reported damage, mark
damage in the return inspection, add a concise factual description and keep the
vehicle blocked. Do not promise the customer a repair cost or liability decision."
Second source: *Vehicle return procedure* v2.0, section "3. Determine next state".
`evidence_state: grounded`, 2 real citations, `provider: demo`.
- **Dutch and French variants of the damage question** (`Wat moet ik doen wanneer een
voertuig beschadigd terugkomt?`, `Que dois-je faire lorsqu'un véhicule revient
endommagé ?`) both returned `evidence_state: insufficient` — an honest, non-fabricated
"no match" rather than a wrong or invented answer. Root cause: the currently-deployed
Hub connector does not yet send the new `locale` field this session added to
`search-knowledge` (Fleet Ops defaults to `en-GB`), so non-English question text
doesn't match the demo provider's English-tokenized index. A real fix needs a Hub-side
connector update to pass `locale`, tracked as a follow-up, not silently worked around.
- **Correlation ID, end to end, verified**: each call's response `correlation_id` (e.g.
`abd643a7-2aeb-4af3-806a-da04acb3e444` for the English grounded answer) appears
verbatim in Fleet Ops's own audit log (`GET /api/v1/audit?action=mcp_tool_request`),
alongside `actor_label: ai-ops-brief-2026-08-05` and the real tool name
(`fleet_ops_search_knowledge`). No write actions were performed; only `mcp_tool_request`
audit rows were created, matching every other live MCP call this integration makes.
## Known limitation, stated plainly
The demo knowledge provider (not RAGcore — `KNOWLEDGE_PROVIDER=demo`, see
`docs/final-integrations/current-state-audit.md` for why) is what grounds the English
answer here. It is deterministic, extractive, and never fabricates — but it is not the
live RAGcore integration the brief brief for this task set out to exercise; that
remains blocked on RAGcore's own reranker gap. This run is honest about that: it proves
the full MCP-client → Hub → Fleet Ops → knowledge-provider → audit chain works for real,
live, in production, with real data and real citations — using the knowledge provider
that is actually configured live today.
@@ -0,0 +1,152 @@
# Current-state audit — Fleet Ops final integrations
Date: 2026-08-05. Compiled from direct repository inspection (git log/status/diff across
all three repos), `PROJECT_STATE.md` history, and read-only investigation of the sibling
repos' own state docs. No live server SSH/curl evidence is included in this pass yet —
see `integration-release-state.md` for the live-verification checklist as it is executed.
## Repository revisions at audit time
| Repo | Path | Branch | HEAD | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `C:\Projects\MobilityOps` | `feat/fleet-ops-final-integrations` (new, branched from `feat/live-n8n-ragcore-integration`) | `3ebca9e` | `feat/live-n8n-ragcore-integration` was pushed to `origin` at `0571a40` and deployed live; `3ebca9e` (logo rebrand) is one commit ahead, not yet deployed. `master` is 19 commits behind and stale (localization-round only). |
| RAGcore | `C:\Projects\RAGcore` | `main` | `64a908a` | Up to date with `origin/main`. Uncommitted local work in progress (see below) — not Fleet-Ops-related, left untouched. |
| ITWorx MCP Hub | `C:\Projects\ITWorx_MCP_Hub` | `feature/wp240-final-acceptance` | `26e6bd8` (+ later `75bb16a`) | Contains `f107544` (MobilityOps connector) as a direct ancestor, plus a real contract fix (`96de385`, vehicleRef camelCase). Already deployed live to Tower at `c4a0f6d`. |
## Branch-name correction (recorded assumption)
The task brief names the working branch `feat/fleet-ops-final-integrations` as already
selected and "branched from the most recently validated, localized, deployed master
branch." That literal branch did not exist. `master` is in fact stale (19 commits behind,
last touched for a localization round only) — the actually-validated, deployed line of
work is `feat/live-n8n-ragcore-integration` (pushed to `origin`, deployed to
`http://192.168.10.150:1236` at `0571a40`, one commit behind current HEAD). Created
`feat/fleet-ops-final-integrations` from that branch's HEAD (`3ebca9e`) instead of from
`master`, since that satisfies the actual intent (continue from the validated/deployed
line) even though the literal branch name in the brief was inaccurate.
## What is actually already done (contradicts "not yet live" framing in places)
- **n8n**: 3 of 4 canonical workflows are live and active in the shared instance
(`n8n.itworx.tech`): Vehicle Return Orchestration, Scheduled Data Quality Scan,
Workflow Error Handler. The 4th, RAGcore Procedure Sync, has all 6 nodes built and
saved but is **not published** (deliberately left for an explicit activation decision,
since publishing starts real unattended daily runs against production). The root cause
of an earlier "auth"-looking failure (`N8N_PROXY_HOPS=0` behind the TLS-terminating
reverse proxy, breaking the browserId CSRF check on every mutating REST call) was found
and fixed at the infrastructure level (Unraid template), not worked around.
- **RAGcore**: deployed to `http://192.168.10.150:1237`, application wiring for
search/context/answer is real (commit `a2905cc`, confirmed present in RAGcore's own
history at `13 commits behind HEAD`). `KNOWLEDGE_PROVIDER` is still `demo` in Fleet Ops
because real queries against the "Fleet Ops Procedures" space return **zero dense and
zero sparse candidates** at the raw retrieval stage — confirmed not a Fleet-Ops-side
wiring bug (RAGcore's own trusted Query Lab tool reproduces the identical zero-candidate
result against the same space). Root cause not yet found as of this audit; ruled out so
far: point count/scoping (83 published, correctly scoped), embedding digest mismatch
(matches), collection alias resolution (resolves correctly). One separate, confirmed,
pre-existing bug: `_DEFAULT_LANGUAGE = "en"` is hardcoded in RAGcore's ingestion handler
— every chunk is stamped `language: "en"` regardless of actual content; RAGcore has never
done real language detection. Not the cause of zero candidates, but must be fixed for
trilingual retrieval (task 6A) once the space is answerable at all.
- **MCP Hub**: the Fleet Ops read-only connector (4 tools, `mobilityops.*`) is **already
live in production** on Tower (commit `c4a0f6d`), reachable via `fleetops.itworx.tech`,
end-to-end verified once already per the Hub's own `CLAUDE.md`/`BUILD_STATE.json`. A
real contract bug was found and fixed there (`vehicle.get`'s input schema disagreed with
the actual wire parameter name — `vehicle_ref` vs `vehicleRef`). What is **not** yet done:
the Hub's own formal production-acceptance checklist row for MobilityOps (`CON-P04`) has
not been executed, and Fleet Ops's own `MCP_HUB_REGISTRATION_ENABLED`/base-URL
configuration has not been confirmed as actually wired and flipped on from the Fleet Ops
side (open item for this audit's Batch 4).
## Confirmed contradictions to resolve (task section 3)
- "Twee versus vier n8n-workflows": resolved above — 3 active + 1 built-but-unpublished.
Canonical set is 4; only 3 are live.
- "Demo-provider versus live RAGcore": Fleet Ops is still on the demo knowledge provider
by deliberate, documented decision (not an oversight) pending the retrieval root cause.
- "MCP Hub-status": prior Fleet Ops docs (`.env.example`, `MCP_HUB_REGISTRATION_ENABLED`)
predate the Hub-side deployment and need reconciling against the fact that the connector
is already live on the Hub side.
- Repo hygiene: removed an untracked, empty `backend;C` directory and an untracked 31 MB
`MobilityOps.zip` stray export; added `*.zip`/`*.tar.gz` to `.gitignore`. No accidentally
committed `__pycache__`/`.pytest_cache`/`test-results` were found in git history.
## RAGcore retrieval root cause — found and partially fixed (2026-08-05, this session)
Investigated live against production (`192.168.10.150`, containers `ragcore-app-1`,
`ragcore-qdrant-1`, `ragcore-postgres-1`, `ollama`), read-only first, then two approved
live changes.
**Root cause #1 (FIXED): filesystem permission bug, not authorization/data.** Verified,
in order, that every earlier suspect was actually healthy: the `control.grants` row
(active, `editor` role, correct application/space), the real
`ControlPlaneAuthorizationInputsProvider` + `RetrievalAuthorizationService.resolve()` code
path run in-process against the live DB (resolves a non-empty `effective_space_ids`), the
exact production Qdrant filter run directly against the live collection (returns real
matching points), and a real ANN vector query under that filter (real hits, sensible
scores). The actual break: `/workspace/.state/models/embedding_profiles.json` — the file
`RetrievalPipeline.run()` reads on every single query to resolve the active embedding
profile — was owned by container-side `root:root` mode `600` on the bind-mounted
`/mnt/cache/appdata/ragcore/state/models` host path, while the real running app process
is uid 10001 (`ragcore`). Every retrieval call hit a `PermissionError` reading its own
state file before ever reaching Qdrant — a plain filesystem-ownership bug, invisible to
every DB/Qdrant-level check. **Fixed live**: `chown 10001:10001` +
`chmod 644`/`755` on that file/directory (approved by the user beforehand). Re-verified
in-process: `RetrievalPipeline.run()` now returns 5 real, relevant hits for an English
damage-procedure question (previously 0).
**Root cause #2 (found, NOT fixed — needs a design decision): reranking is
architecturally unavailable.** `DEFAULT_RERANKER_PROFILE.model_identifier` is
`bge-reranker-v2-m3:v1`, which was never actually present in Ollama's model list (0 of 14
installed models matched). With the user's approval, pulled a working GGUF
(`xitao/bge-reranker-v2-m3:latest`, 1.2 GB) into the shared Ollama instance. **This did
not fix reranking**: `OllamaRerankAdapter` posts to `{ollama_base_url}/api/rerank`, and
this Ollama server (version `0.32.5`) returns a plain `404` for that route — it has no
rerank endpoint at all. This is not a missing-model problem, it is that RAGcore's
reranker adapter was built against an Ollama HTTP API that does not exist in the deployed
version (matches the code's own comment that no reranker-profile registry or live
validation existed yet). The retrieval pipeline degrades gracefully on rerank failure
(RRF-fusion-only hits still returned, confirmed above), but the `/v1/answers` endpoint's
answerability classifier still returns `not_answerable`/0 citations for real NL/EN/FR
questions against real matching content, live-verified after fix #1 with a freshly
minted, correctly-scoped credential.
Options for #2, not decided yet: (a) find/confirm whether a newer Ollama version adds a
real `/api/rerank` route and upgrade the shared instance (affects every other project on
this Ollama — needs its own explicit approval and blast-radius review); (b) change
RAGcore's reranker adapter to call a route Ollama actually supports (e.g. score via
`/api/embed` + a manual similarity/cross-encoder computation, or drop the separate
rerank step and let the answerability classifier trust RRF-fused scores) — a RAGcore
code/design change, out of Fleet Ops's own mandate to decide unilaterally; (c) leave
`KNOWLEDGE_PROVIDER=demo` until RAGcore's own team/session resolves this.
**Side effect to flag**: minting the live-verification credential used `rotate=True` on
the existing "Fleet Ops Knowledge Assistant (production)" service account (a second
credential would have exceeded RAGcore's own 2-active-credential cap), which invalidates
whatever token was previously issued for that account. Since Fleet Ops is still on
`KNOWLEDGE_PROVIDER=demo`, this has no live user-facing impact today, but a fresh
credential must be issued and wired into Fleet Ops's `RAGCORE_API_TOKEN` at actual
cutover time — do not assume the old one still works.
**Concurrency note**: `C:\Projects\RAGcore` had substantial uncommitted local changes
from what appears to be a different, actively-running session (36 modified/untracked
files by the end of this investigation, including files this investigation also read).
No commits or file edits were made in that checkout this session precisely because of
that collision risk — the two live fixes above were applied directly to the running
containers/Ollama instance (approved), not to the RAGcore git repository. **Follow-up
required**: once the concurrent session's work lands, the reranker-profile fix (whichever
option above is chosen) still needs an actual code change + commit + redeploy in
`C:\Projects\RAGcore`, which was not safe to do mid-collision this session.
## Minimal remaining implementation order
1. Root-cause the RAGcore zero-candidate retrieval bug (blocks flipping `KNOWLEDGE_PROVIDER`
and blocks the trilingual live-acceptance and AI Operations Brief tasks).
2. Fix RAGcore's hardcoded `language: "en"` chunk metadata for trilingual retrieval.
3. Decide on and execute n8n workflow 3 publication, with live no-op-on-rerun verification.
4. Confirm/complete Fleet Ops-side MCP Hub registration wiring and run the Hub's own
CON-P04 acceptance row.
5. Build the AI Operations Brief runbook once RAGcore and MCP Hub are both live-green.
6. GUI polish batch (dashboard Today/Attention presentation, duplicate-merge presentation,
About Demo scannability, Demo Guide completion state).
7. Final regression gates and evidence write-up.
+130
View File
@@ -0,0 +1,130 @@
# Fleet Ops final localization — gap audit
Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`,
working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy.
## 1. Remaining untranslated/incorrect text
### nl-BE
| File | Key | Current | Fix |
|---|---|---|---|
| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" |
| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" |
| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" |
| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" |
| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" |
| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" |
| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" |
| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" |
| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" |
| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" |
| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" |
`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate,
siblings are single-word labels too.
### fr-BE
Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly
"Acteur" (no fix needed). French role translations: "Responsable des opérations" /
"Collaborateur de location", per the correction brief.
### Hardcoded JSX (bypasses i18n entirely)
`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English,
never localized. Fix: reuse the already-existing, already-translated
`detail.duplicateCustomer.fieldColumn` key (same table's `<thead>` seven lines above already uses it
correctly) — zero locale-file changes needed, pure JSX fix.
No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`.
## 2. Raw backend errors shown directly
13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`,
`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow:
`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real,
structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the
translated fallback only fires for network-level failures where no `ApiError` could even be
constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases
`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather
than staying a one-off.
Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status
fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via
`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same
`{"error": {"code", "message", "correlation_id"}}` shape):
`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED,
CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND,
IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID,
INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND,
ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE,
NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE,
NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE,
UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`.
Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only
in the raw English `message` string — so localized messages will be generic per-code (title +
explanation + optional next step), not parameterized with extracted specifics, with the raw string
preserved verbatim under "Technical details".
`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`,
`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired
to `ApiError.code` at all — only used as the non-`ApiError` fallback string.
## 3. i18n allowlist over-permissiveness
`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that
must be removed once role labels are translated: `auth.roleOperationsManager`,
`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`,
`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer
true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term)
also need removing once translated to "Auditgeschiedenis".
Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit
agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay.
Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB,
not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a
real blind spot the new tests (section 8 of the correction brief) need to close with a targeted,
explicit check for known English substrings appearing in nl-BE/fr-BE prose.
## 4. Dashboard greeting
No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static**
string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici
votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central,
testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per
the brief, updating on language change and on period rollover while the app stays open.
## 5. Documentation staleness
- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."`
predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file.
- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on
fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` —
**false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git
history, neither is mentioned in the file, and the branch name has moved on to
`fix/fleet-ops-final-i18n-ux`.
- A separate, older `"## Demo productization (in progress, same branch
feat/mobilityops-functional-completion)"` section header was also never marked complete.
- README.md is accurate and current — no fix needed there beyond a version-count refresh after this
round's test additions.
- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest.
## Plan
1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor",
"Start scenario", "Recent", "Open", embedded mid-sentence mentions).
2. Fix the one hardcoded `data-label="Field"` JSX bug.
3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call
sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks,
raw text demoted to "Technical details".
4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring
test, a `describeApiError` coverage test, and greeting boundary tests.
5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for
4 periods × 3 languages + a localized description line replacing the current static one.
6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries).
7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages →
merge to master → redeploy → final evidence.
@@ -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:3903: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).
+1
View File
@@ -1,6 +1,7 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./ COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src COPY src ./src
RUN npm ci && npm run build RUN npm ci && npm run build
@@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => {
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` }); await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible(); await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true }); await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
+1 -1
View File
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
await page.goto("/login"); await page.goto("/login");
await page.screenshot({ path: `${OUT}/1-login.png` }); await page.screenshot({ path: `${OUT}/1-login.png` });
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
+3 -3
View File
@@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => { test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ }); const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
@@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => {
page.on("pageerror", (err) => errors.push(err.message)); page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios"); await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
@@ -96,7 +96,7 @@ test("status-recommendation panel is fully keyboard operable, respects reduced m
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-STATUS"); await page.goto("/data-quality/DQ-DEMO-STATUS");
+4 -4
View File
@@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy
await expect(page.getByText(/Northstar Mobility/)).toBeVisible(); await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
await expect(page.getByText(/Synthetische demo/)).toBeVisible(); await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible(); await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible(); await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
await expect(page.locator('input[type="password"]')).toHaveCount(0); await expect(page.locator('input[type="password"]')).toHaveCount(0);
}); });
@@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ }); const trigger = page.getByRole("button", { name: /Synthetische demo/ });
@@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
test("badge popover closes on Escape and outside click", async ({ page }) => { test("badge popover closes on Escape and outside click", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ }); const trigger = page.getByRole("button", { name: /Synthetische demo/ });
+4 -4
View File
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => { test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios"); await page.goto("/scenarios");
@@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async
test("starting a scenario navigates to its fixed record", async ({ page }) => { test("starting a scenario navigates to its fixed record", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios"); await page.goto("/scenarios");
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
await duplicateCard.getByRole("link", { name: "Start scenario" }).click(); await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
}); });
@@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it",
test("demo guide is not shown to a rental employee", async ({ page }) => { test("demo guide is not shown to a rental employee", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0); await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
}); });
+5 -5
View File
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => { test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
@@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
await page.getByRole("button", { name: "Retour bevestigen" }).click(); await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible(); await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible(); await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible();
}); });
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
@@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async
test("data quality list can filter to demo scenarios only", async ({ page }) => { test("data quality list can filter to demo scenarios only", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality"); await page.goto("/data-quality");
@@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => { test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge"); await page.goto("/knowledge");
+1 -1
View File
@@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("1. login as Operations Manager", async () => { await test.step("1. login as Operations Manager", async () => {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByText(/Synthetische demo/)).toBeVisible(); await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ApiError } from "../src/api/apiError";
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
// Pure Node-context checks for the central API-error-localization function (section 7 /
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
// only depends on a `t` function and a caught error, so it's tested here against the
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
// known-HTTP-status paths never leak raw backend English as the primary message, and
// that the raw text is always still available via `.technical` for "Technical details".
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
// resolving "namespace:dotted.path" against the real locale files for the given language.
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
return (key: string, options?: Record<string, unknown>) => {
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
const data = loadNamespace(language, ns);
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") return value;
if (options && "defaultValue" in options) return String(options.defaultValue);
return key;
};
}
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
for (const code of KNOWN_CODES) {
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
expect(
codes[code]?.explanation?.trim().length ?? 0,
`${language}/errors.json:codes.${code}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
for (const status of KNOWN_HTTP_STATUSES) {
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
expect(
http[status]?.explanation?.trim().length ?? 0,
`${language}/errors.json:http.${status}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "IntegrityError: duplicate key value violates unique constraint";
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
const info = describeApiError(t, err);
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
expect(info.title).not.toBe(raw);
expect(info.explanation).not.toBe(raw);
// The raw backend text must still be reachable, just demoted to `.technical`.
expect(info.technical).toBe(raw);
}
});
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
const t = makeT("nl-BE");
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
expect(withNextStep.nextStep).toBeTruthy();
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
expect(withoutNextStep.nextStep).toBeUndefined();
});
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "Some brand-new backend code nobody localized yet";
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
const info = describeApiError(t, err);
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
expect(info.title).toBe(expected404.title);
expect(info.explanation).toBe(expected404.explanation);
expect(info.title).not.toBe(raw);
expect(info.technical).toBe(raw);
}
});
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "418 I'm a teapot (never mapped)";
const err = new ApiError(418, "418", raw, "corr-3");
const info = describeApiError(t, err);
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
expect(info.technical).toBe(raw);
}
});
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
const t = makeT("fr-BE");
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
const info = describeApiError(t, err);
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
expect(info.title).toBe(expected401.title);
expect(info.title).not.toBe("Not authenticated");
});
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
const t = makeT("nl-BE");
const networkFailure = new TypeError("Failed to fetch");
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
// already scoped via useTranslation("integrations"); this stub's default namespace is
// "errors", so the fallback key is qualified explicitly here to match.
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
expect(info.explanation).toBe(expectedFallback.retryFailed);
expect(info.explanation).not.toBe("Failed to fetch");
expect(info.technical).toBe("Failed to fetch");
});
// --- Backend/frontend AppError code drift guard ---
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
// a missed opportunity for a more specific message. This test surfaces that drift instead
// of letting it go unnoticed indefinitely.
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
function collectPyFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectPyFiles(full);
return entry.name.endsWith(".py") ? [full] : [];
});
}
function collectBackendAppErrorCodes(): Set<string> {
const codes = new Set<string>();
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
const source = fs.readFileSync(file, "utf-8");
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
codes.add(match[1]);
}
}
return codes;
}
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
const backendCodes = collectBackendAppErrorCodes();
const frontendCodes = KNOWN_CODES;
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
expect(
missingFromFrontend,
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
).toEqual([]);
expect(
staleInFrontend,
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
`removed on the backend side: ${staleInFrontend.join(", ")}`,
).toEqual([]);
});
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const info = describeApiError(t, new TypeError("Failed to fetch"));
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
}
});
+17 -3
View File
@@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) {
} }
const EXPLORE_OPS_MANAGER: Record<string, string> = { const EXPLORE_OPS_MANAGER: Record<string, string> = {
"nl-BE": "Verken als Operations Manager", "nl-BE": "Verken als Operationsmanager",
"en-GB": "Explore as Operations Manager", "en-GB": "Explore as Operations Manager",
"fr-BE": "Explorer en tant qu'Operations Manager", "fr-BE": "Explorer en tant que Responsable des opérations",
}; };
const REVIEW_RECOMMENDATION: Record<string, string> = { const REVIEW_RECOMMENDATION: Record<string, string> = {
@@ -74,6 +74,16 @@ test.describe("branding", () => {
} }
}); });
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
const href = await page.locator('link[rel="icon"]').getAttribute("href");
expect(href).toBe("/favicon.svg");
const response = await page.request.get(href as string);
expect(response.ok()).toBeTruthy();
expect(response.headers()["content-type"]).toContain("svg");
});
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => { test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force // Deliberately not using loginAsOpsManager here: its addInitScript would re-force
@@ -292,7 +302,11 @@ test("automation shows a localized error explanation with the raw error only und
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible(); await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible(); await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
await page.getByText("Technische details").first().click(); // Scope to the failed job's own row -- the workflow-evidence table above it also has
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
// the wrong one.
const failedJobRow = page.locator("tr", { has: page.getByText(/tijdelijk niet bereikbaar/) });
await failedJobRow.getByText("Technische details").click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible(); await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
}); });
+107
View File
@@ -0,0 +1,107 @@
import { expect, type Page, test } from "@playwright/test";
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
// control the browser's Date without waiting on real wall-clock time. Also proves the
// greeting updates live (no reload) when the period rolls over while the app stays open.
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
function cet(hour: number, minute = 0, second = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
}
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
{ time: cet(4, 59), label: "04:59", period: "night" },
{ time: cet(5, 0), label: "05:00", period: "morning" },
{ time: cet(11, 59), label: "11:59", period: "morning" },
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
{ time: cet(18, 0), label: "18:00", period: "evening" },
{ time: cet(22, 59), label: "22:59", period: "evening" },
{ time: cet(23, 0), label: "23:00", period: "night" },
];
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
"nl-BE": {
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
},
"en-GB": {
morning: "Good morning. Here's today's fleet status.",
afternoon: "Good afternoon. Here's the current overview of your fleet.",
evening: "Good evening. Here's this evening's fleet overview.",
night: "Welcome back. Here's the latest overview of your fleet.",
},
"fr-BE": {
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
},
};
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
// At the default desktop viewport, only the topbar's compact switcher is visible --
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
await page.locator(".language-switcher-compact select").selectOption(language);
}
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
await page.clock.install({ time: BOUNDARY_CASES[0].time });
await loginAsOperationsManager(page);
if (language !== "nl-BE") {
await switchLanguage(page, language);
}
for (const { time, label, period } of BOUNDARY_CASES) {
await page.clock.setFixedTime(time);
await page.reload();
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
EXPECTED_TITLE[language][period],
);
}
});
}
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
await page.clock.install({ time: cet(11, 59, 31) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
// recomputes the period -- no page.reload() call anywhere in this test.
await page.clock.fastForward(30_000);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
});
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
await page.clock.install({ time: cet(9, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
await switchLanguage(page, "en-GB");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
await switchLanguage(page, "fr-BE");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
});
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
await page.clock.install({ time: cet(2, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
await expect(dashboardHeading(page)).toContainText("Welkom terug");
});
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
// Pure Node-context boundary tests for the central, clock-injectable greeting function
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
// by picking instants either side of the CET/CEST transition.
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
function cet(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
}
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
function cest(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
}
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
});
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
});
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
expect(getBrusselsHour(beforeTransition)).toBe(1);
expect(getGreetingPeriod(beforeTransition)).toBe("night");
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
expect(getBrusselsHour(afterTransition)).toBe(11);
expect(getGreetingPeriod(afterTransition)).toBe("morning");
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
expect(getBrusselsHour(beforeFallBack)).toBe(2);
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
expect(getBrusselsHour(afterFallBack)).toBe(10);
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
});
test("default argument uses the real current time when no clock is injected", () => {
const period = getGreetingPeriod();
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
});
+74 -7
View File
@@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
} }
}); });
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const raw = JSON.stringify(loadNamespace(language, namespace));
expect(
raw.includes("MobilityOps"),
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
).toBe(false);
expect(
/\bPoC\b/.test(raw),
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
`in user-facing copy`,
).toBe(false);
}
}
});
// --- Translation-quality: prove values were actually translated, not copy-pasted --- // --- Translation-quality: prove values were actually translated, not copy-pasted ---
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain // Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
// the literal English string under the right key and still pass). For every "real prose" // the literal English string under the right key and still pass). For every "real prose"
@@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly // a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
// hide an unrelated real mistranslation under the same key in a different namespace. // hide an unrelated real mistranslation under the same key in a different namespace.
const IDENTICAL_VALUE_ALLOWLIST = new Set([ const IDENTICAL_VALUE_ALLOWLIST = new Set([
"audit.title", // "Audit trail" kept as an established cross-language compliance term
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch "audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
"auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles)
"auth.roleRentalEmployee",
"demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels
"demo.scenarios.roles.rental_employee",
"common.language.nl-BE", // language-picker options show each language's own endonym "common.language.nl-BE", // language-picker options show each language's own endonym
"common.language.fr-BE", "common.language.fr-BE",
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text "common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3 "common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative "dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3 "demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
"demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French "demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun "demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
"fleet.list.columns.attention", // "Attention" -- identical spelling in French "fleet.list.columns.attention", // "Attention" -- identical spelling in French
@@ -131,8 +143,8 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
"knowledge.questionLabel", // "Question" -- identical spelling in French "knowledge.questionLabel", // "Question" -- identical spelling in French
"knowledge.retrievalFlow.question", "knowledge.retrievalFlow.question",
"knowledge.questionLabelExchange", "knowledge.questionLabelExchange",
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
"returns.result.inspection", // "Inspection" -- identical spelling in French "returns.result.inspection", // "Inspection" -- identical spelling in French
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French
]); ]);
function isTranslatableProse(value: unknown): value is string { function isTranslatableProse(value: unknown): value is string {
@@ -185,6 +197,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea
} }
}); });
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
// The whole-string identity check above only catches a value that is IDENTICAL to
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
// goedgekeurd." This scan flags known English fragments appearing literally inside any
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
const FORBIDDEN_ENGLISH_FRAGMENTS = [
"Operations Manager",
"Operations Managers",
"Rental Employee",
"Rental Employees",
"Audit trail",
"Start scenario",
];
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
"Operationsmanager",
"Verhuurmedewerker",
"Auditgeschiedenis",
"Scenario starten",
];
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
if (typeof value === "string") return [{ path: prefix, value }];
if (value === null || typeof value !== "object") return [];
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
);
}
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
const findings: string[] = [];
for (const namespace of namespaces) {
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
if (value.includes(fragment)) {
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
}
}
}
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
if (value.includes(fragment)) {
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
}
}
}
}
expect(findings, findings.join("\n")).toEqual([]);
});
// --- Hardcoded JSX text (section 11D) --- // --- Hardcoded JSX text (section 11D) ---
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a // A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
// `{...}` expression) containing two or more real words are almost always user-facing // `{...}` expression) containing two or more real words are almost always user-facing
+5 -4
View File
@@ -243,11 +243,12 @@ test("data quality: manual scan runs and shows a result summary", async ({ page,
test("automation page: status filter and retry button work", async ({ page, request }) => { test("automation page: status filter and retry button work", async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.goto("/automation"); await page.goto("/automation");
await expect(page.locator(".data-table")).toBeVisible(); const jobsTable = page.getByRole("table", { name: "Automation jobs" });
await expect(jobsTable).toBeVisible();
await page.getByLabel("Status").selectOption("failed"); await page.getByLabel("Status").selectOption("failed");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
const failedRowCountBefore = await page.locator(".data-table tbody tr").count(); const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
const retryButton = page.getByRole("button", { name: "Retry" }).first(); const retryButton = page.getByRole("button", { name: "Retry" }).first();
await expect(retryButton).toBeVisible(); await expect(retryButton).toBeVisible();
await retryButton.click(); await retryButton.click();
@@ -256,7 +257,7 @@ test("automation page: status filter and retry button work", async ({ page, requ
// filtered to "failed" — the row correctly disappears from this view rather than // filtered to "failed" — the row correctly disappears from this view rather than
// showing "pending" in place. Confirm the filtered list shrank by one. // showing "pending" in place. Confirm the filtered list shrank by one.
await expect(async () => { await expect(async () => {
const count = await page.locator(".data-table tbody tr").count(); const count = await jobsTable.locator("tbody tr").count();
expect(count).toBe(failedRowCountBefore - 1); expect(count).toBe(failedRowCountBefore - 1);
}).toPass({ timeout: 5000 }); }).toPass({ timeout: 5000 });
}); });
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
expect(vehicles.ok()).toBeTruthy();
const vehiclePage = await vehicles.json();
expect(vehiclePage.items).toHaveLength(25);
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
expect(audit.ok()).toBeTruthy();
const auditPage = await audit.json();
expect(auditPage.items.length).toBeLessThanOrEqual(25);
await page.goto("/vehicles?status=maintenance&page=1");
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
await expect(page).toHaveURL(/status=maintenance/);
});
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.locator(".page-actions .badge")).toBeVisible();
await page.goto("/vehicles");
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
});
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fleet Ops synthetic-data operations demo" /> <meta name="description" content="Fleet Ops synthetic-data operations demo" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Fleet Ops</title> <title>Fleet Ops</title>
</head> </head>
<body> <body>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a" />
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="white" />
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg>

After

Width:  |  Height:  |  Size: 344 B

+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
+4 -13
View File
@@ -1,3 +1,7 @@
import { ApiError } from "./apiError";
export { ApiError } from "./apiError";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void; type UnauthorizedListener = () => void;
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
return () => unauthorizedListeners.delete(listener); return () => unauthorizedListeners.delete(listener);
} }
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
...init, ...init,
+100
View File
@@ -0,0 +1,100 @@
import { ApiError } from "./apiError";
export interface ApiErrorInfo {
title: string;
explanation: string;
nextStep?: string;
technical: string;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// Backend AppError codes this frontend knows how to present with a localized title,
// explanation and (where useful) a next step -- see
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
// a newly-introduced backend code never regresses to raw English -- it just falls back
// to a generic-but-localized message until this list is extended.
export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
"CONFLICT_STILL_PRESENT",
"OVERLAP_STILL_PRESENT",
"EMPTY_VALUE",
"NO_FIELDS_PROVIDED",
"INVALID_FIELD",
"INVALID_FIELD_OVERRIDE",
"INVALID_SURVIVOR",
"INVALID_BOOKING_REFERENCE",
"INVALID_EVENT_ID",
"INVALID_IDEMPOTENCY_KEY",
"IDEMPOTENCY_KEY_REUSED",
"CORRECTED_VALUE_REQUIRED",
"CORRECTION_BELOW_CANONICAL",
"NOT_A_DUPLICATE_ISSUE",
"NOT_A_MISSING_FIELD_ISSUE",
"NOT_AN_ODOMETER_ISSUE",
"NOT_AN_OVERLAP_ISSUE",
"NOT_A_STATUS_CONFLICT_ISSUE",
"UNSUPPORTED_ENTITY",
"MANUAL_REVIEW_REQUIRED",
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
/**
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
* for display. The raw backend/network text is only ever exposed as `technical`
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
*
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
* existed) and errors:generic doesn't fit the specific action being attempted.
*/
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
if (!(err instanceof ApiError)) {
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err instanceof Error ? err.message : String(err),
};
}
if (KNOWN_CODES.has(err.code)) {
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:codes.${err.code}.title`),
explanation: t(`errors:codes.${err.code}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:http.${httpKey}.title`),
explanation: t(`errors:http.${httpKey}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err.message,
};
}
+48 -3
View File
@@ -20,6 +20,14 @@ export interface Vehicle {
attention: boolean; attention: boolean;
} }
export interface Page<T> {
items: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
}
export interface BookingSummary { export interface BookingSummary {
public_ref: string; public_ref: string;
customer_ref: string; customer_ref: string;
@@ -85,11 +93,16 @@ export interface DashboardMetrics {
pending_or_failed_workflows: number; pending_or_failed_workflows: number;
} }
export interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
export interface AttentionItem { export interface AttentionItem {
kind: string; kind: string;
severity: "low" | "medium" | "high"; severity: "low" | "medium" | "high";
rule_type: string; rule_type: string;
detail: string; evidence_signals: EvidenceSignal[];
link_type: "vehicle" | "booking" | "customer"; link_type: "vehicle" | "booking" | "customer";
link_ref: string; link_ref: string;
issue_ref: string | null; issue_ref: string | null;
@@ -110,6 +123,10 @@ export interface AutomationRun {
attempts: number; attempts: number;
last_error: string | null; last_error: string | null;
last_error_code: string | null; last_error_code: string | null;
/** True for the failure the demo seed plants on purpose (see the backend's
* DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of
* showing it as an unexplained production error. */
is_demo_scenario: boolean;
occurred_at: string; occurred_at: string;
} }
@@ -251,7 +268,19 @@ export interface KnowledgeHealth {
tenant: string; tenant: string;
workspace: string; workspace: string;
collection: string; collection: string;
document_count: number; document_count: number | null;
}
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 {
@@ -260,15 +289,31 @@ export interface N8nIntegrationStatus {
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence"; state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
pending: number; pending: number;
delivering: number; delivering: number;
/** Every failed delivery, prepared and real together. */
failed: number; failed: number;
/** Failures that were not planted by the demo seed -- the only ones that affect state. */
unexpected_failed: number;
/** Prepared demo failures. Visible and counted, but never a health signal. */
demo_scenario_failed: number;
succeeded: number; succeeded: number;
latest_success_at: string | null; latest_success_at: string | null;
/** Most recent real failure; a prepared one never sets this. */
latest_failure_at: string | null; latest_failure_at: string | null;
latest_demo_scenario_at: string | null;
expected_workflow_count: number;
known_workflow_count: number;
workflows: N8nWorkflowEvidence[];
error_handler: N8nErrorHandlerStatus;
} }
export interface McpHubIntegrationStatus { export interface McpHubIntegrationStatus {
registration_enabled: boolean; registration_enabled: boolean;
state: "not_configured" | "configured"; state: "not_configured" | "no_evidence" | "operational";
total_calls: number;
last_tool: string | null;
last_client: string | null;
last_called_at: string | null;
hub_reachable: boolean | null;
} }
export interface IntegrationStatus { export interface IntegrationStatus {
+6 -4
View File
@@ -1,13 +1,15 @@
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { useViewportTier } from "../hooks/useViewportTier"; import { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons"; import { Icon } from "./Icons";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product"; import { PRODUCT_NAME } from "../product";
export function DemoGuideTrigger() { export function DemoGuideTrigger() {
@@ -68,7 +70,7 @@ export function DemoGuide() {
setCollapsedToChip, setCollapsedToChip,
} = useDemoGuide(); } = useDemoGuide();
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null); const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null); const pendingTarget = useRef<string | null>(null);
@@ -119,7 +121,7 @@ export function DemoGuide() {
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed")); setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally { } finally {
setResetting(false); setResetting(false);
} }
@@ -227,7 +229,7 @@ export function DemoGuide() {
</nav> </nav>
)} )}
{resetError && <p className="error" role="alert">{resetError}</p>} <ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer"> <footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}> <button type="button" className="button button-secondary" onClick={goToStepRoute}>
+3 -2
View File
@@ -64,8 +64,9 @@ export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGEle
export function BrandMark({ className = "" }: { className?: string }) { export function BrandMark({ className = "" }: { className?: string }) {
return ( return (
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none"> <svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="currentColor" /> <path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
<path d="M3 15.5h26" stroke="#2dd4bf" strokeWidth="2.5" /> <circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg> </svg>
); );
} }
+19 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types"; import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons"; import { BrandMark, Icon, type IconName } from "./Icons";
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product"; import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = { const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet", vehicle: "fleet",
@@ -86,7 +88,7 @@ export function Layout() {
const [activeIndex, setActiveIndex] = useState(-1); const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false); const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null); const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null); const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null); const searchBox = useRef<HTMLDivElement>(null);
@@ -163,7 +165,7 @@ export function Layout() {
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
setResetError(err instanceof ApiError ? err.message : t("resetFailed")); setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false); setResetConfirming(false);
} finally { } finally {
setResetting(false); setResetting(false);
@@ -231,7 +233,7 @@ export function Layout() {
</div> </div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && ( {user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset"> <div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>} <ApiErrorNotice error={resetError} />
{!resetConfirming ? ( {!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}> <button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")} {t("resetDemoData")}
@@ -311,19 +313,23 @@ export function Layout() {
)} )}
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger /> <DemoGuideTrigger />
<DemoBadge /> <DemoBadge />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && ( {user && (
<div className="operator"> <details className="operator-menu">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span> <summary className="operator">
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span> <span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
</div> <span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)} )}
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div> </div>
</header> </header>
+20
View File
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
); );
} }
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({ export function EmptyState({
icon = "check", icon = "check",
title, title,
+25
View File
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
export function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const { t } = useTranslation("common");
if (totalPages <= 1) return null;
return (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
+7 -5
View File
@@ -1,7 +1,8 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoGuide } from "../context/DemoGuideContext";
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons"; import { Icon } from "./Icons";
import { StatusBadge } from "./Badge"; import { StatusBadge } from "./Badge";
import { ApiErrorNotice } from "./PageChrome";
function newIdempotencyKey(): string { function newIdempotencyKey(): string {
return typeof crypto.randomUUID === "function" return typeof crypto.randomUUID === "function"
@@ -103,7 +105,7 @@ export function ReturnForm({
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false); const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<ApiErrorInfo | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey); const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture"); const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null); const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
@@ -132,7 +134,7 @@ export function ReturnForm({
setPreview(evaluated); setPreview(evaluated);
setStep("review"); setStep("review");
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic")); setError(describeApiError(t, err));
} finally { } finally {
setPreviewing(false); setPreviewing(false);
} }
@@ -148,7 +150,7 @@ export function ReturnForm({
); );
onRegistered(registered); onRegistered(registered);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic")); setError(describeApiError(t, err));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -158,7 +160,7 @@ export function ReturnForm({
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading"> <form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div> <div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div> <div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
{error && <p className="error" role="alert">{error}</p>} <ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture"> {step === "capture" ? <div className="return-capture">
<div className="form-grid"><label> <div className="form-grid"><label>
+42
View File
@@ -0,0 +1,42 @@
import type { EvidenceSignal } from "../api/types";
type TFunction = (key: string, options?: Record<string, unknown>) => string;
// The backend never emits prose for data-quality evidence -- only stable signal codes
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
// the one place that turns them into the operator's selected language, shared by the
// issue detail page and the dashboard attention queue so the same code always reads the
// same way everywhere it appears.
export function describeEvidenceSignal(
t: TFunction,
formatNumber: (value: number) => string,
signal: EvidenceSignal,
): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("quality:detail.evidence.missingField", {
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("quality:detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("quality:detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
}
}
+12 -1
View File
@@ -15,5 +15,16 @@ export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusC
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = { export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
not_configured: { statusClass: "not_configured", labelKey: "notConnected" }, not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
configured: { statusClass: "no_events", labelKey: "prepared" }, no_evidence: { statusClass: "no_events", labelKey: "prepared" },
operational: { statusClass: "available", labelKey: "operational" },
};
// 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",
}; };
+28
View File
@@ -0,0 +1,28 @@
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
export function getBrusselsHour(date: Date): number {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: BRUSSELS_TIME_ZONE,
hour: "numeric",
hourCycle: "h23",
});
return Number(formatter.format(date));
}
// Boundaries per the Fleet Ops final localization brief (section 9):
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
// a fixed clock instead of depending on the real wall clock.
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
const hour = getBrusselsHour(date);
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}
@@ -4,6 +4,12 @@
"description": "Trace important state changes, actors and correlation references.", "description": "Trace important state changes, actors and correlation references.",
"actionFilterLabel": "Action", "actionFilterLabel": "Action",
"actionFilterPlaceholder": "e.g. demo_login", "actionFilterPlaceholder": "e.g. demo_login",
"actorFilterLabel": "Actor",
"actorFilterPlaceholder": "Name or service",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "e.g. MO-016",
"fromFilterLabel": "From",
"toFilterLabel": "To",
"managerOnly": "The audit trail is visible to Operations Managers only.", "managerOnly": "The audit trail is visible to Operations Managers only.",
"managerOnlyDetail": "Audit history is visible to Operations Managers only.", "managerOnlyDetail": "Audit history is visible to Operations Managers only.",
"loading": "Loading audit trail…", "loading": "Loading audit trail…",
@@ -45,6 +45,8 @@
"ends": "Ends", "ends": "Ends",
"startOdometer": "Start odometer", "startOdometer": "Start odometer",
"endOdometer": "End odometer", "endOdometer": "End odometer",
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
"endOdometerPending": "Not yet recorded — not yet closed",
"requirementsComplete": "Requirements complete", "requirementsComplete": "Requirements complete",
"yes": "Yes", "yes": "Yes",
"no": "No" "no": "No"
@@ -22,6 +22,12 @@
"success": "Success", "success": "Success",
"noResults": "No results" "noResults": "No results"
}, },
"pagination": {
"label": "Page navigation",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{page}} of {{total}}"
},
"states": { "states": {
"loadingDefault": "Loading workspace…", "loadingDefault": "Loading workspace…",
"errorTitle": "We couldn't load this workspace." "errorTitle": "We couldn't load this workspace."
+17 -1
View File
@@ -1,6 +1,17 @@
{ {
"eyebrow": "Operations / Live overview", "eyebrow": "Operations / Live overview",
"title": "Good morning. Here's the fleet.", "greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Welcome back"
},
"greetingBody": {
"morning": "Here's today's fleet status.",
"afternoon": "Here's the current overview of your fleet.",
"evening": "Here's this evening's fleet overview.",
"night": "Here's the latest overview of your fleet."
},
"description": "Readiness, exceptions and hand-offs across today's operation.", "description": "Readiness, exceptions and hand-offs across today's operation.",
"viewFleet": "View fleet", "viewFleet": "View fleet",
"demoStart": { "demoStart": {
@@ -25,6 +36,7 @@
"title": "Attention queue", "title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions", "description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue", "reviewQueue": "Review queue",
"remaining": "View {{count}} more attention item(s)",
"filterPlaceholder": "Filter issues…", "filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue", "filterAriaLabel": "Search attention queue",
"severityAll": "All severity", "severityAll": "All severity",
@@ -32,6 +44,9 @@
"severityMedium": "Warning", "severityMedium": "Warning",
"severityLow": "Info", "severityLow": "Info",
"severityAriaLabel": "Severity", "severityAriaLabel": "Severity",
"tierNow": "Handle now",
"tierToday": "Follow up today",
"tierLater": "Review later",
"empty": "No issues match this filter.", "empty": "No issues match this filter.",
"openRecord": "Open {{title}}" "openRecord": "Open {{title}}"
}, },
@@ -54,6 +69,7 @@
"n8nNoEvidence": "No workflow evidence recorded", "n8nNoEvidence": "No workflow evidence recorded",
"knowledgeTitle": "Knowledge assistant", "knowledgeTitle": "Knowledge assistant",
"knowledgeSummary": "{{count}} procedures indexed", "knowledgeSummary": "{{count}} procedures indexed",
"knowledgeIndexUnknown": "Knowledge source available · index size unknown",
"knowledgeUnavailable": "Health check unavailable", "knowledgeUnavailable": "Health check unavailable",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registration enabled", "mcpEnabled": "Registration enabled",
+178 -6
View File
@@ -1,8 +1,180 @@
{ {
"generic": "Something went wrong. Please try again.", "generic": {
"workspaceLoadFailed": "We couldn't load this workspace.", "title": "Something went wrong",
"unauthorized": "Your session has expired. Please log in again.", "explanation": "This action could not be completed. Please try again."
"forbidden": "You don't have access to this section.", },
"notFound": "This record could not be found.", "codes": {
"networkUnavailable": "The connection to the server is currently unavailable." "VEHICLE_NOT_FOUND": {
"title": "Vehicle not found",
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
},
"BOOKING_NOT_FOUND": {
"title": "Booking not found",
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
},
"CUSTOMER_NOT_FOUND": {
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"EVENT_NOT_FOUND": {
"title": "Workflow event not found",
"explanation": "This automation event could not be found."
},
"ISSUE_NOT_FOUND": {
"title": "Data-quality issue not found",
"explanation": "This data-quality issue could not be found."
},
"ISSUE_NOT_OPEN": {
"title": "Issue is no longer open",
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
},
"INVALID_BOOKING_STATE": {
"title": "Booking is in the wrong state",
"explanation": "This booking's current state does not allow this action."
},
"NOT_RETRYABLE": {
"title": "This event cannot be retried",
"explanation": "Only a failed delivery can be retried."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict was not resolved",
"explanation": "Applying this change did not resolve the underlying conflict.",
"nextStep": "Review the recommendation again before retrying."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap was not resolved",
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
},
"EMPTY_VALUE": {
"title": "A required field is blank",
"explanation": "This field cannot be left blank.",
"nextStep": "Enter a value and try again."
},
"NO_FIELDS_PROVIDED": {
"title": "No changes provided",
"explanation": "At least one field must be filled in to continue."
},
"INVALID_FIELD": {
"title": "Field not allowed here",
"explanation": "One of the fields provided is not permitted for this action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Field cannot be merged",
"explanation": "One of the selected fields cannot be used in this merge."
},
"INVALID_SURVIVOR": {
"title": "Invalid selection",
"explanation": "The record you selected to keep must be one of the two records being compared."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Booking reference not valid here",
"explanation": "The selected booking is not one of the bookings related to this issue."
},
"INVALID_EVENT_ID": {
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
"nextStep": "Reload the page and try again."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "This action was already submitted",
"explanation": "An identical request was already processed with a different outcome.",
"nextStep": "Reload the page to see the current state before retrying."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "A corrected value is required",
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correction is below the confirmed reading",
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to possible-duplicate-customer issues."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to missing-required-field issues."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to odometer-regression issues."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to booking-overlap issues."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to vehicle-status-conflict issues."
},
"UNSUPPORTED_ENTITY": {
"title": "Not supported for this record type",
"explanation": "This action is not available for this kind of record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Manual review required",
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
"nextStep": "Defer or reject this issue, or investigate manually."
},
"NO_CONFLICT_DETECTED": {
"title": "Nothing to apply",
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
},
"RECOMMENDATION_STALE": {
"title": "The situation has changed",
"explanation": "The underlying facts changed since this recommendation was shown.",
"nextStep": "Review the recommendation again before applying it."
},
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
}
},
"http": {
"401": {
"title": "Session expired",
"explanation": "Your session has expired.",
"nextStep": "Please log in again."
},
"403": {
"title": "Permission denied",
"explanation": "You don't have permission to perform this action."
},
"404": {
"title": "Not found",
"explanation": "This record could not be found."
},
"409": {
"title": "This action is no longer possible",
"explanation": "The underlying state has changed since this page was loaded.",
"nextStep": "Refresh the page and try again."
},
"422": {
"title": "Validation failed",
"explanation": "The information provided is not valid."
},
"500": {
"title": "Server error",
"explanation": "Something went wrong on our side."
},
"network": {
"title": "Connection unavailable",
"explanation": "The connection to the server is currently unavailable.",
"nextStep": "Check your connection and try again."
}
}
} }
@@ -11,11 +11,17 @@
"knowledgeTitle": "Knowledge assistant", "knowledgeTitle": "Knowledge assistant",
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.", "knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.",
"knowledgeUnavailable": "Health evidence is currently unavailable.", "knowledgeUnavailable": "Health evidence is currently unavailable.",
"gatewayKicker": "Tool gateway", "gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registration is enabled for this deployment.", "mcpEnabled": "Registration is enabled for this deployment.",
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub." "mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.",
"mcpNoEvidence": "Registered, but no tool call has been recorded yet.",
"mcpEvidence": "Last call: {{tool}} by {{client}} · {{count}} total calls",
"mcpHubReachable": "Hub reachable",
"mcpHubUnreachable": "Hub unreachable",
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
}, },
"statusLabels": { "statusLabels": {
"notConnected": "Not connected", "notConnected": "Not connected",
@@ -26,6 +32,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.",
@@ -71,7 +99,11 @@
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.", "malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.", "remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.", "staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
"unknownError": "An unexpected error occurred while delivering this job." "unknownError": "An unexpected error occurred while delivering this job.",
} "demoScenarioTimeout": "Simulated timeout towards the workflow service. This failure is part of the prepared demo scenario, not a real incident."
},
"demoScenarioBadge": "Prepared demo scenario",
"demoScenarioExplanation": "Simulated temporary failure, planted by the demo data on purpose to show retry and audit. It does not affect the health of the automation.",
"retryDemoScenario": "Retry demo scenario"
} }
} }
@@ -7,6 +7,7 @@
"statusAvailable": "Available", "statusAvailable": "Available",
"statusUnavailable": "Unavailable", "statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed", "proceduresIndexed": "{{count}} procedures indexed",
"proceduresIndexUnknown": "Index size is not available through this connection",
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.", "providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question", "askHeading": "Ask a procedure question",
"askSubheading": "Retrieval → evidence check → grounded answer", "askSubheading": "Retrieval → evidence check → grounded answer",
+7 -2
View File
@@ -20,6 +20,8 @@
"statusRejected": "Rejected", "statusRejected": "Rejected",
"ruleTypeLabel": "Rule type", "ruleTypeLabel": "Rule type",
"ruleTypeAll": "All rule types", "ruleTypeAll": "All rule types",
"severityLabel": "Severity",
"severityAll": "All severity levels",
"demoScenariosOnly": "Demo scenarios only", "demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…", "loading": "Loading quality workbench…",
"queueClear": "Queue is clear", "queueClear": "Queue is clear",
@@ -109,8 +111,7 @@
"missingField": "Missing field: {{field}}", "missingField": "Missing field: {{field}}",
"overlapReservedBookings": "Overlapping bookings: {{refs}}", "overlapReservedBookings": "Overlapping bookings: {{refs}}",
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.", "odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.", "upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
"seedPlaceholder": "Synthetic seed data with no further detail."
}, },
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Compare and merge", "heading": "Compare and merge",
@@ -118,6 +119,10 @@
"keepAsSurvivor": "Keep as survivor", "keepAsSurvivor": "Keep as survivor",
"differs": "Differs", "differs": "Differs",
"match": "Match", "match": "Match",
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
"showMatchingFields": "Also show matching fields",
"hideMatchingFields": "Hide matching fields",
"previewTitle": "Preview of the merged record ({{ref}})",
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.", "mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
"mergeInto": "Merge into {{ref}}", "mergeInto": "Merge into {{ref}}",
"confirmMergeTitle": "Confirm merge", "confirmMergeTitle": "Confirm merge",
+9 -3
View File
@@ -4,8 +4,14 @@
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.", "description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
"actionFilterLabel": "Action", "actionFilterLabel": "Action",
"actionFilterPlaceholder": "p. ex. demo_login", "actionFilterPlaceholder": "p. ex. demo_login",
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.", "actorFilterLabel": "Intervenant",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.", "actorFilterPlaceholder": "Nom ou service",
"entityFilterLabel": "Dossier",
"entityFilterPlaceholder": "p. ex. MO-016",
"fromFilterLabel": "À partir du",
"toFilterLabel": "Jusquau",
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement de la piste d'audit…", "loading": "Chargement de la piste d'audit…",
"unavailable": "La piste d'audit est actuellement indisponible.", "unavailable": "La piste d'audit est actuellement indisponible.",
"empty": "Aucun événement d'audit trouvé", "empty": "Aucun événement d'audit trouvé",
@@ -16,7 +22,7 @@
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles", "storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
"columns": { "columns": {
"when": "Quand", "when": "Quand",
"actor": "Acteur", "actor": "Auteur",
"action": "Action", "action": "Action",
"entity": "Entité", "entity": "Entité",
"change": "Changement", "change": "Changement",
+4 -4
View File
@@ -9,15 +9,15 @@
"accessHeading": "Choisissez comment démarrer", "accessHeading": "Choisissez comment démarrer",
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.", "accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
"startGuidedDemo": "Démarrer la démo guidée", "startGuidedDemo": "Démarrer la démo guidée",
"exploreAsOperationsManager": "Explorer en tant qu'Operations Manager", "exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives", "exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee", "exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures", "exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
"safeByDesignTitle": "Conçu pour la sécurité", "safeByDesignTitle": "Conçu pour la sécurité",
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.", "safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.", "loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
"roleOperationsManager": "Operations manager", "roleOperationsManager": "Responsable des opérations",
"roleRentalEmployee": "Rental employee", "roleRentalEmployee": "Collaborateur de location",
"switchRole": "Changer de rôle", "switchRole": "Changer de rôle",
"logout": "Déconnexion" "logout": "Déconnexion"
} }
@@ -45,6 +45,8 @@
"ends": "Fin", "ends": "Fin",
"startOdometer": "Kilométrage de départ", "startOdometer": "Kilométrage de départ",
"endOdometer": "Kilométrage de retour", "endOdometer": "Kilométrage de retour",
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
"requirementsComplete": "Exigences complètes", "requirementsComplete": "Exigences complètes",
"yes": "Oui", "yes": "Oui",
"no": "Non" "no": "Non"
@@ -22,6 +22,12 @@
"success": "Réussi", "success": "Réussi",
"noResults": "Aucun résultat" "noResults": "Aucun résultat"
}, },
"pagination": {
"label": "Navigation entre les pages",
"previous": "Précédent",
"next": "Suivant",
"pageOf": "Page {{page}} sur {{total}}"
},
"states": { "states": {
"loadingDefault": "Chargement de l'espace de travail…", "loadingDefault": "Chargement de l'espace de travail…",
"errorTitle": "Impossible de charger cet espace de travail." "errorTitle": "Impossible de charger cet espace de travail."

Some files were not shown because too many files have changed in this diff Show More