Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d92084489 | ||
|
|
a42012d9c0 | ||
|
|
0de9177e7c | ||
|
|
48147465b5 | ||
|
|
92bdfb421b | ||
|
|
4257fea9d6 | ||
|
|
014caba7d5 | ||
|
|
e531740bb9 | ||
|
|
921c6878a6 | ||
|
|
cd55d6854f | ||
|
|
70e2648b03 | ||
|
|
f8eff37982 | ||
|
|
25cc7a1b0d | ||
|
|
6fd1d5e5e9 | ||
|
|
54b7719759 | ||
|
|
a19613d5e9 | ||
|
|
b62ecf66d8 | ||
|
|
97b8cd1c8c | ||
|
|
50f9bb471a | ||
|
|
719b710926 | ||
|
|
441b3624e1 | ||
|
|
ccae0538d7 | ||
|
|
7d935c5a7d | ||
|
|
51c3c90fa1 | ||
|
|
d6a566e1a1 | ||
|
|
4b727a109f | ||
|
|
1bf72c39e3 | ||
|
|
55e8ebd81e | ||
|
|
b2bdb78baa | ||
|
|
fe92a7f491 | ||
|
|
19df5f7508 | ||
|
|
e8eed3e641 | ||
|
|
2918608240 | ||
|
|
a99aed9a5f | ||
|
|
00e8ec001b | ||
|
|
d20ff7a243 | ||
|
|
bc0951115b | ||
|
|
51d488a634 | ||
|
|
da2f0956c9 | ||
|
|
13f8db7573 | ||
|
|
e6ec89658e | ||
|
|
9d71555135 | ||
|
|
81de78bd8b | ||
|
|
444e61253b | ||
|
|
81e3fd63bd | ||
|
|
b0706989db | ||
|
|
2036e8b4ec |
@@ -1,21 +1,57 @@
|
||||
# Backend image build context is the repository root (see compose.yaml); keep it small.
|
||||
.git
|
||||
.gitea
|
||||
.state
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.github
|
||||
.gitignore
|
||||
.agents
|
||||
.codex
|
||||
.claude
|
||||
.dyad
|
||||
.idea
|
||||
.vscode
|
||||
.vs
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
**/__pycache__
|
||||
**/.venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/playwright-report
|
||||
**/test-results
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
dist
|
||||
artifacts
|
||||
docs
|
||||
deploy
|
||||
n8n
|
||||
n8n/**
|
||||
!n8n/workflows/
|
||||
!n8n/workflows/**
|
||||
frontend
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets
|
||||
credentials
|
||||
.state
|
||||
data
|
||||
backups
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.log
|
||||
*.tmp
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -86,6 +86,10 @@ N8N_ENCRYPTION_KEY=replace-me
|
||||
N8N_BASIC_AUTH_ACTIVE=true
|
||||
N8N_BASIC_AUTH_USER=admin
|
||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||
# Recipient and sender used by the importable alert workflow. Configure real,
|
||||
# monitored addresses in the deployment environment; repository defaults stay synthetic.
|
||||
MOBILITYOPS_ALERT_RECIPIENT=alerts@example.test
|
||||
MOBILITYOPS_ALERT_SENDER=n8n@example.test
|
||||
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
|
||||
@@ -96,6 +100,9 @@ MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||
# RAGcore integration
|
||||
KNOWLEDGE_PROVIDER=demo
|
||||
RAGCORE_BASE_URL=http://ragcore-api:8000
|
||||
# Optional existing Docker network used by hosted deployments to reach RAGcore through
|
||||
# its private service alias instead of exposing RAGcore on the LAN.
|
||||
RAGCORE_DOCKER_NETWORK=
|
||||
RAGCORE_TENANT=northstar-mobility-demo
|
||||
RAGCORE_WORKSPACE=mobilityops
|
||||
RAGCORE_COLLECTION=internal-procedures
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.zip binary
|
||||
|
||||
# Generated operational evidence is not part of a source release archive.
|
||||
/artifacts export-ignore
|
||||
/PROJECT_STATE.md export-ignore
|
||||
/MASTER_BUILD_PROMPT.md export-ignore
|
||||
/FILE_INDEX.md export-ignore
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: MobilityOps browser canary
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "37 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: mobilityops-browser-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
chromium:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install locked Chromium runtime
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Run non-destructive production canary
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: https://fleetops.itworx.tech
|
||||
run: npx playwright test --config=playwright.live.config.ts --project=chromium
|
||||
- name: Upload failure evidence
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||
with:
|
||||
name: browser-canary-failure
|
||||
path: |
|
||||
frontend/playwright-live-report
|
||||
frontend/test-results
|
||||
if-no-files-found: ignore
|
||||
@@ -1,135 +1,141 @@
|
||||
name: MobilityOps acceptance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: mobilityops-ci-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
acceptance:
|
||||
# Never execute code from an untrusted public fork on the private runner.
|
||||
if: ${{ gitea.event.pull_request.head.repo.full_name == gitea.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Secret scan
|
||||
uses: trufflesecurity/trufflehog@b9dd330365132cd2d01dd5dc8a857a056a2544e1 # v3.79.0
|
||||
with:
|
||||
path: ./
|
||||
extra_args: --only-verified
|
||||
fetch-depth: 0
|
||||
- name: Determine validation scope
|
||||
id: scope
|
||||
shell: bash
|
||||
run: |
|
||||
base_sha="${{ gitea.event.pull_request.base.sha }}"
|
||||
if git diff --quiet "$base_sha...HEAD" -- . ':(exclude).gitea/workflows/**'; then
|
||||
echo "full=false" >> "$GITEA_OUTPUT"
|
||||
echo "Workflow-only change: the protected lightweight gate is sufficient."
|
||||
else
|
||||
echo "full=true" >> "$GITEA_OUTPUT"
|
||||
echo "Product or test change: running the complete acceptance gate."
|
||||
fi
|
||||
- name: Secret scan
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository="$PWD"
|
||||
source="file:///repo"
|
||||
workspace=(-v "$repository:/repo" -w /repo)
|
||||
if docker inspect "${HOSTNAME:-}" >/dev/null 2>&1; then
|
||||
source="file://$repository"
|
||||
workspace=(--volumes-from "$HOSTNAME" -w "$repository")
|
||||
fi
|
||||
docker run --rm "${workspace[@]}" \
|
||||
ghcr.io/trufflesecurity/trufflehog@sha256:7104dbb84d1ad2f5f6fa1134e92c6aa6f701f0a4ac2efd5a4c5c96225d899fe3 \
|
||||
git "$source" --fail --no-update --github-actions --only-verified
|
||||
- name: Backend tests in isolated PostgreSQL stack
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: sh scripts/run-isolated-tests.sh
|
||||
- name: Backend static checks
|
||||
- name: Backend static and contract checks
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: |
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
||||
- name: Contract drift gate
|
||||
run: |
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \
|
||||
-v "$PWD:/repo:ro" api python /repo/scripts/check-contracts.py
|
||||
api python scripts/check-contracts.py
|
||||
python scripts/check-source-budgets.py
|
||||
- name: Build production API image for vulnerability scan
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: |
|
||||
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-api-ci --file backend/Dockerfile .
|
||||
- name: Production API image vulnerability scan (HIGH/CRITICAL)
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-api-ci
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: bash scripts/scan-ci-image.sh mobilityops-api-ci
|
||||
- name: Build production web image for vulnerability scan
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: |
|
||||
docker build --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-web-ci frontend
|
||||
- name: Production web image vulnerability scan (HIGH/CRITICAL)
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-web-ci
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
- name: Remove CI stack
|
||||
if: always()
|
||||
run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: bash scripts/scan-ci-image.sh mobilityops-web-ci
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install locked dependencies
|
||||
- name: Install frontend dependencies once
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
working-directory: frontend
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Lint (tsc + ESLint with react-hooks and jsx-a11y)
|
||||
- name: Frontend lint, build, budget and dependency audit
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Typecheck and production build
|
||||
working-directory: frontend
|
||||
run: npm run build && npm run budget
|
||||
- name: Dependency audit
|
||||
working-directory: frontend
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
e2e:
|
||||
# The five-minute Playwright demo is part of the definition of done
|
||||
# (docs/14-testing-and-acceptance.md); run it against the real Compose stack.
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
run: |
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run budget
|
||||
npm audit --audit-level=high
|
||||
- name: Start the demo stack
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: |
|
||||
cp .env.example .env
|
||||
# Acceptance tests intentionally reset their isolated demo dataset per scenario.
|
||||
printf '\nDEMO_RESET_COOLDOWN_SECONDS=0\n' >> .env
|
||||
docker compose -p mobilityops-e2e up --build -d db api web
|
||||
docker network connect mobilityops-e2e_mobilityops "$HOSTNAME"
|
||||
for _attempt in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi
|
||||
if curl -fsS http://web/health/ready >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS http://localhost:1228/health/ready
|
||||
curl -fsS http://web/health/ready
|
||||
docker compose -p mobilityops-e2e exec -T api python -m app.cli seed --reset
|
||||
- name: Install Playwright
|
||||
- name: Install acceptance browsers
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
working-directory: frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Run browser acceptance and live smoke suites
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://web
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium firefox
|
||||
- name: Run browser acceptance suite
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||
run: npx playwright test
|
||||
- name: Run non-destructive Chromium and Firefox smoke suite
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||
run: npx playwright test --config=playwright.live.config.ts
|
||||
# Pixel baselines are workstation/rendering specific; keep the PR gate functional.
|
||||
npx playwright test --grep-invert "visual hierarchy"
|
||||
npx playwright test --config=playwright.live.config.ts --project=chromium
|
||||
- name: Run concurrent persisted-read smoke
|
||||
run: python scripts/run-readonly-load-smoke.py --base-url http://localhost:1228
|
||||
if: steps.scope.outputs.full == 'true'
|
||||
run: python scripts/run-readonly-load-smoke.py --base-url http://web
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: failure() && steps.scope.outputs.full == 'true'
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
frontend/playwright-report
|
||||
frontend/playwright-live-report
|
||||
if-no-files-found: ignore
|
||||
- name: Stack logs on failure
|
||||
if: failure()
|
||||
if: failure() && steps.scope.outputs.full == 'true'
|
||||
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
||||
- name: Remove e2e stack
|
||||
if: always()
|
||||
run: docker compose -p mobilityops-e2e down -v --remove-orphans
|
||||
- name: Remove CI stacks
|
||||
if: always() && steps.scope.outputs.full == 'true'
|
||||
run: |
|
||||
docker network disconnect mobilityops-e2e_mobilityops "$HOSTNAME" 2>/dev/null || true
|
||||
docker compose -p mobilityops-e2e down -v --remove-orphans
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
name: MobilityOps live canary
|
||||
name: MobilityOps live probe
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "7 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: mobilityops-live-probe
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-demo:
|
||||
public-probe:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Verify HTTPS readiness and certificate horizon
|
||||
run: |
|
||||
curl --fail --silent --show-error --retry 3 https://fleetops.itworx.tech/health/ready
|
||||
openssl s_client -servername fleetops.itworx.tech -connect fleetops.itworx.tech:443 </dev/null 2>/dev/null \
|
||||
| openssl x509 -checkend 1209600 -noout
|
||||
- name: Install locked Playwright runtime
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium firefox
|
||||
- name: Run non-destructive cross-browser production canary
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: https://fleetops.itworx.tech
|
||||
run: npx playwright test --config=playwright.live.config.ts
|
||||
- name: Report successful external heartbeat
|
||||
env:
|
||||
HEARTBEAT_URL: ${{ secrets.LIVE_CANARY_HEARTBEAT_URL }}
|
||||
@@ -37,11 +29,3 @@ jobs:
|
||||
if [ -n "$HEARTBEAT_URL" ]; then
|
||||
curl --fail --silent --show-error --retry 3 "$HEARTBEAT_URL"
|
||||
fi
|
||||
- name: Upload failure evidence
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: live-canary-failure
|
||||
path: |
|
||||
frontend/playwright-live-report
|
||||
frontend/test-results
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Managed validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: Allowlisted validation profile
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options: [test, lint, typecheck, build, security, full]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
full:
|
||||
name: full
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Validate repository with a bounded profile
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
profile="${REQUESTED_PROFILE:-full}"
|
||||
case "${profile}" in
|
||||
test|lint|typecheck|build|security|full) ;;
|
||||
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
git diff --check
|
||||
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "Unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||
# Compile only tracked Python sources. Running compileall after a
|
||||
# Node install would otherwise traverse node_modules and turn a
|
||||
# lightweight baseline into a large runner workload.
|
||||
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||
if [[ -f uv.lock ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||
"${managed_uv}" sync --locked
|
||||
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m pytest
|
||||
fi
|
||||
fi
|
||||
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m ruff check .
|
||||
fi
|
||||
fi
|
||||
elif [[ -f requirements.txt ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_python}" -m pytest
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||
# commonly delegate their test script to Python and need the managed
|
||||
# virtual environment to be active first.
|
||||
if [[ -f package.json ]]; then
|
||||
corepack enable
|
||||
if [[ -f pnpm-lock.yaml ]]; then
|
||||
pnpm install --frozen-lockfile
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||
elif [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||
npm run --if-present typecheck
|
||||
fi
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f go.mod ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
go test ./...
|
||||
fi
|
||||
fi
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
cargo test --locked
|
||||
fi
|
||||
fi
|
||||
if compgen -G '*.sln' >/dev/null; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
dotnet test --configuration Release
|
||||
fi
|
||||
fi
|
||||
@@ -23,27 +23,15 @@ jobs:
|
||||
image --scanners vuln --severity HIGH,CRITICAL \
|
||||
--ignore-unfixed --exit-code 1 "$image"
|
||||
done
|
||||
- name: Generate API CycloneDX SBOM
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-api-release
|
||||
format: cyclonedx
|
||||
output: mobilityops-api-sbom.cdx.json
|
||||
- name: Generate web CycloneDX SBOM
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-web-release
|
||||
format: cyclonedx
|
||||
output: mobilityops-web-sbom.cdx.json
|
||||
- name: Generate backup-tools CycloneDX SBOM
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-backup-tools-release
|
||||
format: cyclonedx
|
||||
output: mobilityops-backup-tools-sbom.cdx.json
|
||||
- name: Generate CycloneDX SBOMs with the pinned scanner image
|
||||
run: |
|
||||
for component in api web backup-tools; do
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PWD:/work" -w /work \
|
||||
aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 \
|
||||
image --format cyclonedx --output "mobilityops-${component}-sbom.cdx.json" \
|
||||
"mobilityops-${component}-release"
|
||||
done
|
||||
- name: Record immutable image metadata
|
||||
run: |
|
||||
docker image inspect mobilityops-api-release > mobilityops-api-image.json
|
||||
@@ -52,7 +40,7 @@ jobs:
|
||||
python scripts/generate-release-provenance.py
|
||||
sha256sum mobilityops-*-sbom.cdx.json mobilityops-*-image.json release-provenance.json > SHA256SUMS
|
||||
- name: Upload release evidence
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3; Gitea-compatible artifact protocol
|
||||
with:
|
||||
name: mobilityops-${{ github.ref_name }}-evidence
|
||||
path: |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: MobilityOps security
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: mobilityops-security
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build production images once
|
||||
run: |
|
||||
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-api-ci --file backend/Dockerfile .
|
||||
docker build --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-web-ci frontend
|
||||
- name: Scan production images for fixed HIGH and CRITICAL vulnerabilities
|
||||
run: |
|
||||
for image in mobilityops-api-ci mobilityops-web-ci; do
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
docker.io/aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e \
|
||||
image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed --no-progress "$image"
|
||||
done
|
||||
- name: Scan repository secrets and misconfiguration
|
||||
uses: docker://docker.io/aquasec/trivy@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e
|
||||
with:
|
||||
args: fs --scanners misconfig,secret --exit-code 1 --no-progress .
|
||||
- name: Remove temporary image tags
|
||||
if: always()
|
||||
run: docker image rm mobilityops-api-ci mobilityops-web-ci || true
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Unraid autoredeploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- ".gitea/**"
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unraid-production-mobilityops
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy mobilityops
|
||||
runs-on: unraid-deploy
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Deploy exact Gitea revision
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker exec gitea-deploy-control \
|
||||
/opt/gitea-deploy/deploy.py deploy \
|
||||
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
# Secrets and local configuration
|
||||
.env
|
||||
.venv/
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets/
|
||||
credentials/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
|
||||
# Frontend and test output
|
||||
node_modules/
|
||||
dist/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
playwright-live-report/
|
||||
test-results/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
|
||||
# Runtime data and local infrastructure state
|
||||
.state/
|
||||
data/
|
||||
backups/local/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# Generated release/design evidence. Maintained documentation belongs in docs/.
|
||||
artifacts/**/final-summary.md
|
||||
artifacts/deployment/
|
||||
artifacts/design-validation/current/
|
||||
artifacts/**/screenshots/generated/
|
||||
|
||||
# Local AI/editor state
|
||||
.codex/
|
||||
.claude/
|
||||
.agents/
|
||||
.dyad/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
*.zip
|
||||
*.tar.gz
|
||||
.vs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local Claude/Codex per-user settings and scratch archives
|
||||
.claude/settings.local.json
|
||||
# Archives and local release bundles
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.dump
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
81e3fd63bdbcb2e9c4ae1d709ea46f40537b6f62:backend/tests/test_data_quality.py:generic-api-key:869
|
||||
0091c57c7fd82ffda16da1edfe5fa5589c8f1e13:backend/tests/test_return.py:generic-api-key:93
|
||||
0091c57c7fd82ffda16da1edfe5fa5589c8f1e13:backend/tests/test_return.py:generic-api-key:122
|
||||
0091c57c7fd82ffda16da1edfe5fa5589c8f1e13:backend/tests/test_return.py:generic-api-key:127
|
||||
@@ -1,61 +0,0 @@
|
||||
# Binding instructions for Claude
|
||||
|
||||
## Operating mode
|
||||
|
||||
Work autonomously. Do not ask the user product, architecture, naming, UI, scope or implementation questions already answered in this repository. Record a reasonable assumption in an ADR only when a genuine gap blocks implementation.
|
||||
|
||||
Use normal or medium reasoning for routine work. Reserve high reasoning for an actual cross-service design conflict or a persistent failure after evidence-driven debugging.
|
||||
|
||||
Continue from milestone to milestone until every acceptance criterion is satisfied. Do not stop merely because one milestone is complete.
|
||||
|
||||
## Token and tool efficiency
|
||||
|
||||
1. Read `START_HERE.md`, this file, `PROJECT_STATE.md` and `docs/15-build-plan.md` first.
|
||||
2. Read only the milestone-specific documents named in the build plan.
|
||||
3. Do not repeatedly reread all documentation.
|
||||
4. Keep explanations terse; spend effort on implementation and validation.
|
||||
5. Update `PROJECT_STATE.md` after each milestone with decisions, commands, evidence and the exact next action.
|
||||
6. Prefer focused file inspection and targeted tests over broad repository scans.
|
||||
7. Do not generate large speculative documents after implementation starts.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
- Build the locked PoC only.
|
||||
- Do not add accounting, payments, public reservations, a generic CRM, inventory, HR, a second RAG stack, a separate MCP server or autonomous write actions.
|
||||
- Do not modify the RAGcore or ITWorx MCP Hub repositories. Integrate only through documented contracts and configurable adapters.
|
||||
- Keep critical business rules in MobilityOps code, not in n8n or prompts.
|
||||
- No direct MCP Hub or RAGcore access to the MobilityOps database.
|
||||
|
||||
## Product quality
|
||||
|
||||
- No dead buttons, empty routes, unexplained placeholders or hardcoded dashboard metrics.
|
||||
- Every visible number must derive from persisted data.
|
||||
- All important state changes must be audited.
|
||||
- AI must never invent an answer when RAGcore is unavailable or returns insufficient evidence.
|
||||
- Vehicle returns must commit locally even when n8n is unavailable; orchestration becomes pending and retryable.
|
||||
- External dependencies require timeouts, bounded retries, health state and graceful degradation.
|
||||
- Demo data must be clearly labelled synthetic.
|
||||
|
||||
## Engineering rules
|
||||
|
||||
- Backend: Python, FastAPI, SQLAlchemy 2, Alembic, PostgreSQL.
|
||||
- Frontend: React, TypeScript, Vite, accessible responsive UI.
|
||||
- Validation: Pydantic at API boundaries and database constraints for invariants.
|
||||
- Use UUID primary keys internally and stable human-readable public references.
|
||||
- Store UTC timestamps; render Europe/Brussels in the UI.
|
||||
- API paths start with `/api/v1`.
|
||||
- Use an outbox record for reliable post-commit n8n delivery.
|
||||
- Tests must cover domain rules, API contracts and the five-minute Playwright demo.
|
||||
- Generate and commit dependency lockfiles.
|
||||
|
||||
## Git workflow
|
||||
|
||||
Create one coherent commit per milestone after its validation passes. Suggested message format:
|
||||
|
||||
`M1: implement operational core`
|
||||
|
||||
Never rewrite already accepted milestone history unless necessary to fix a regression.
|
||||
|
||||
## Definition of done
|
||||
|
||||
The project is done only when `docs/14-testing-and-acceptance.md` passes from a clean checkout and `PROJECT_STATE.md` contains the final evidence summary.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contributing
|
||||
|
||||
MobilityOps contributions must preserve fleet-data privacy, deterministic demo behaviour and the fail-closed integration boundaries documented in `SECURITY.md`.
|
||||
|
||||
- use synthetic vehicles, customers, bookings, returns, telematics events and identity claims in tests and screenshots;
|
||||
- never commit production databases, exports, operator inventories, private service URLs, tokens, backups or generated browser evidence;
|
||||
- keep external integrations configurable through environment variables or explicit deployment configuration;
|
||||
- document new personal-data fields, retention, authorization, audit and deletion/export behaviour;
|
||||
- add negative tests for authentication, authorization, duplicate handling, webhook validation, path containment and stale/unavailable providers;
|
||||
- review dependencies, images and browser assets for provenance and redistribution rights.
|
||||
|
||||
Run the relevant backend, frontend, migration, integration, Compose and managed-validation gates before review. Security-sensitive findings belong through the private process in `SECURITY.md`.
|
||||
@@ -1,368 +0,0 @@
|
||||
# File index
|
||||
|
||||
Tracked source, contract, documentation and configuration files. Release evidence and
|
||||
screenshots live under `artifacts/<release>/` and are omitted here for brevity.
|
||||
Regenerate with `git ls-files` when the tree changes.
|
||||
|
||||
- `.env.example`
|
||||
- `.gitattributes`
|
||||
- `.gitea/workflows/ci.yml`
|
||||
- `.gitignore`
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `FILE_INDEX.md`
|
||||
- `MASTER_BUILD_PROMPT.md`
|
||||
- `Makefile`
|
||||
- `PROJECT_STATE.md`
|
||||
- `README.md`
|
||||
- `START_HERE.md`
|
||||
- `backend/Dockerfile`
|
||||
- `backend/alembic.ini`
|
||||
- `backend/alembic/env.py`
|
||||
- `backend/alembic/script.py.mako`
|
||||
- `backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py`
|
||||
- `backend/alembic/versions/799d8800e241_outbox_last_error_code.py`
|
||||
- `backend/alembic/versions/a81d0ce9f662_oidc_identity.py`
|
||||
- `backend/alembic/versions/b7c7b536df85_operational_user_credentials.py`
|
||||
- `backend/alembic/versions/b913a72e8c14_customer_privacy_state.py`
|
||||
- `backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py`
|
||||
- `backend/alembic/versions/c9498525abb5_initial_schema.py`
|
||||
- `backend/alembic/versions/d1f83bc64170_revoked_sessions.py`
|
||||
- `backend/alembic/versions/e7b08389f47f_idempotency_records.py`
|
||||
- `backend/alembic/versions/f43d829ab610_quality_work_queue.py`
|
||||
- `backend/app/__init__.py`
|
||||
- `backend/app/api/__init__.py`
|
||||
- `backend/app/api/deps.py`
|
||||
- `backend/app/api/routers/__init__.py`
|
||||
- `backend/app/api/routers/audit.py`
|
||||
- `backend/app/api/routers/auth.py`
|
||||
- `backend/app/api/routers/bookings.py`
|
||||
- `backend/app/api/routers/customers.py`
|
||||
- `backend/app/api/routers/dashboard.py`
|
||||
- `backend/app/api/routers/data_quality.py`
|
||||
- `backend/app/api/routers/demo.py`
|
||||
- `backend/app/api/routers/integration_status.py`
|
||||
- `backend/app/api/routers/integrations.py`
|
||||
- `backend/app/api/routers/knowledge.py`
|
||||
- `backend/app/api/routers/mcp_integrations.py`
|
||||
- `backend/app/api/routers/observability.py`
|
||||
- `backend/app/api/routers/privacy.py`
|
||||
- `backend/app/api/routers/search.py`
|
||||
- `backend/app/api/routers/users.py`
|
||||
- `backend/app/api/routers/vehicles.py`
|
||||
- `backend/app/api/routers/workflows.py`
|
||||
- `backend/app/cli.py`
|
||||
- `backend/app/core/__init__.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/core/db.py`
|
||||
- `backend/app/core/errors.py`
|
||||
- `backend/app/core/observability.py`
|
||||
- `backend/app/core/ratelimit.py`
|
||||
- `backend/app/core/security.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/models/audit.py`
|
||||
- `backend/app/models/booking.py`
|
||||
- `backend/app/models/customer.py`
|
||||
- `backend/app/models/data_quality.py`
|
||||
- `backend/app/models/idempotency.py`
|
||||
- `backend/app/models/inspection.py`
|
||||
- `backend/app/models/maintenance.py`
|
||||
- `backend/app/models/mixins.py`
|
||||
- `backend/app/models/outbox.py`
|
||||
- `backend/app/models/revoked_session.py`
|
||||
- `backend/app/models/user.py`
|
||||
- `backend/app/models/vehicle.py`
|
||||
- `backend/app/schemas.py`
|
||||
- `backend/app/seed_loader.py`
|
||||
- `backend/app/services/__init__.py`
|
||||
- `backend/app/services/audit.py`
|
||||
- `backend/app/services/data_quality.py`
|
||||
- `backend/app/services/demo_manifest.py`
|
||||
- `backend/app/services/dispatcher.py`
|
||||
- `backend/app/services/integration_status.py`
|
||||
- `backend/app/services/knowledge/__init__.py`
|
||||
- `backend/app/services/knowledge/demo.py`
|
||||
- `backend/app/services/knowledge/procedures.py`
|
||||
- `backend/app/services/knowledge/ragcore.py`
|
||||
- `backend/app/services/operations.py`
|
||||
- `backend/app/services/returns.py`
|
||||
- `backend/app/services/sessions.py`
|
||||
- `backend/app/services/vehicle_status.py`
|
||||
- `backend/entrypoint.sh`
|
||||
- `backend/pyproject.toml`
|
||||
- `backend/requirements.lock`
|
||||
- `backend/scripts/generate_openapi.py`
|
||||
- `backend/tests/conftest.py`
|
||||
- `backend/tests/test_audit.py`
|
||||
- `backend/tests/test_auth.py`
|
||||
- `backend/tests/test_bookings.py`
|
||||
- `backend/tests/test_dashboard.py`
|
||||
- `backend/tests/test_data_quality.py`
|
||||
- `backend/tests/test_database_constraints.py`
|
||||
- `backend/tests/test_demo_manifest.py`
|
||||
- `backend/tests/test_dispatcher.py`
|
||||
- `backend/tests/test_hardening.py`
|
||||
- `backend/tests/test_health.py`
|
||||
- `backend/tests/test_integration_status.py`
|
||||
- `backend/tests/test_integrations.py`
|
||||
- `backend/tests/test_knowledge.py`
|
||||
- `backend/tests/test_mcp_integrations.py`
|
||||
- `backend/tests/test_migrations.py`
|
||||
- `backend/tests/test_observability.py`
|
||||
- `backend/tests/test_operational_auth.py`
|
||||
- `backend/tests/test_privacy.py`
|
||||
- `backend/tests/test_return.py`
|
||||
- `backend/tests/test_search.py`
|
||||
- `backend/tests/test_seed.py`
|
||||
- `backend/tests/test_users.py`
|
||||
- `backend/tests/test_vehicle_status.py`
|
||||
- `backend/tests/test_vehicles.py`
|
||||
- `backend/tests/test_workflows.py`
|
||||
- `compose.observability.yaml`
|
||||
- `compose.test.yaml`
|
||||
- `compose.unraid.yaml`
|
||||
- `compose.yaml`
|
||||
- `contracts/events.schema.json`
|
||||
- `contracts/mcp-tools.json`
|
||||
- `contracts/openapi.yaml`
|
||||
- `contracts/ragcore-contract-assumptions.md`
|
||||
- `deploy/observability/alerts.yml`
|
||||
- `deploy/observability/grafana/dashboards/mobilityops-overview.json`
|
||||
- `deploy/observability/grafana/provisioning/dashboards/mobilityops.yml`
|
||||
- `deploy/observability/grafana/provisioning/datasources/prometheus.yml`
|
||||
- `deploy/observability/prometheus.yml`
|
||||
- `deploy/unraid/README.md`
|
||||
- `deploy/unraid/backup-postgres.sh`
|
||||
- `deploy/unraid/configure-env.sh`
|
||||
- `deploy/unraid/prune-postgres-backups.sh`
|
||||
- `deploy/unraid/restore-postgres.sh`
|
||||
- `deploy/unraid/scheduled-backup.sh`
|
||||
- `deploy/unraid/setup-existing-n8n.sh`
|
||||
- `deploy/unraid/setup-n8n.sh`
|
||||
- `deploy/unraid/setup-scheduled-scan.sh`
|
||||
- `deploy/unraid/verify-postgres-backups.sh`
|
||||
- `docs/00-product-brief.md`
|
||||
- `docs/01-scope-and-non-goals.md`
|
||||
- `docs/02-user-stories.md`
|
||||
- `docs/03-architecture.md`
|
||||
- `docs/04-domain-model.md`
|
||||
- `docs/05-api-contract.md`
|
||||
- `docs/06-ui-ux.md`
|
||||
- `docs/07-data-quality.md`
|
||||
- `docs/08-return-workflow.md`
|
||||
- `docs/09-ragcore-integration.md`
|
||||
- `docs/10-mcp-hub-integration.md`
|
||||
- `docs/11-n8n-integration.md`
|
||||
- `docs/12-security-and-audit.md`
|
||||
- `docs/13-seed-and-demo-scenarios.md`
|
||||
- `docs/14-testing-and-acceptance.md`
|
||||
- `docs/15-build-plan.md`
|
||||
- `docs/16-portfolio-case-study.md`
|
||||
- `docs/17-runbook.md`
|
||||
- `docs/18-privacy-governance.md`
|
||||
- `docs/19-visual-product-roadmap.md`
|
||||
- `docs/deferred.md`
|
||||
- `docs/demo-release/current-demo-gap-audit.md`
|
||||
- `docs/demo-release/demo-concept.md`
|
||||
- `docs/demo-release/demo-data.md`
|
||||
- `docs/demo-release/demo-guide.md`
|
||||
- `docs/demo-release/demo-runbook.md`
|
||||
- `docs/demo-release/demo-scenarios.md`
|
||||
- `docs/design/current-ux-audit.md`
|
||||
- `docs/design/design-directions.md`
|
||||
- `docs/design/design-system.md`
|
||||
- `docs/design/implementation-validation.md`
|
||||
- `docs/design/stitch-manifest.md`
|
||||
- `docs/final-integrations/ai-operations-brief-runbook.md`
|
||||
- `docs/final-integrations/current-state-audit.md`
|
||||
- `docs/final-product-polish/audit.md`
|
||||
- `docs/fleet-ops-correction/current-gap-audit.md`
|
||||
- `docs/fleet-ops-correction/i18n-inventory.md`
|
||||
- `docs/fleet-ops-correction/vehicle-status-decision-table.md`
|
||||
- `docs/fleet-ops-final-localization/audit.md`
|
||||
- `docs/functional-completion/current-functional-audit.md`
|
||||
- `docs/functional-completion/server-baseline.md`
|
||||
- `docs/live-ai-integration/n8n-current-state.md`
|
||||
- `frontend/Dockerfile`
|
||||
- `frontend/e2e/_capture-demo-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-recruiter-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-screenshots.spec.ts`
|
||||
- `frontend/e2e/clickable-rows.spec.ts`
|
||||
- `frontend/e2e/demo-accessibility.spec.ts`
|
||||
- `frontend/e2e/demo-entry.spec.ts`
|
||||
- `frontend/e2e/demo-guide.spec.ts`
|
||||
- `frontend/e2e/demo-legibility.spec.ts`
|
||||
- `frontend/e2e/demo.spec.ts`
|
||||
- `frontend/e2e/error-messages.spec.ts`
|
||||
- `frontend/e2e/fleet-ops-correction.spec.ts`
|
||||
- `frontend/e2e/greeting-live.spec.ts`
|
||||
- `frontend/e2e/greeting.spec.ts`
|
||||
- `frontend/e2e/guided-demo-full.spec.ts`
|
||||
- `frontend/e2e/i18n-coverage.spec.ts`
|
||||
- `frontend/e2e/interactive-elements.spec.ts`
|
||||
- `frontend/e2e/operational-workflows.spec.ts`
|
||||
- `frontend/e2e/privacy.spec.ts`
|
||||
- `frontend/e2e/recruiter-polish.spec.ts`
|
||||
- `frontend/e2e/responsive-i18n.spec.ts`
|
||||
- `frontend/e2e/roadmap-regression.spec.ts`
|
||||
- `frontend/e2e/ui-redesign.spec.ts`
|
||||
- `frontend/index.html`
|
||||
- `frontend/nginx.conf`
|
||||
- `frontend/package-lock.json`
|
||||
- `frontend/package.json`
|
||||
- `frontend/playwright.config.ts`
|
||||
- `frontend/public/favicon.svg`
|
||||
- `frontend/public/og-fleet-ops.svg`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/api/apiError.ts`
|
||||
- `frontend/src/api/client.ts`
|
||||
- `frontend/src/api/errorMessages.ts`
|
||||
- `frontend/src/api/types.ts`
|
||||
- `frontend/src/components/Badge.tsx`
|
||||
- `frontend/src/components/CheckoutForm.tsx`
|
||||
- `frontend/src/components/DemoBadge.tsx`
|
||||
- `frontend/src/components/DemoGuide.tsx`
|
||||
- `frontend/src/components/Icons.tsx`
|
||||
- `frontend/src/components/LanguageSwitcher.tsx`
|
||||
- `frontend/src/components/Layout.tsx`
|
||||
- `frontend/src/components/PageChrome.tsx`
|
||||
- `frontend/src/components/Pagination.tsx`
|
||||
- `frontend/src/components/RequireAuth.tsx`
|
||||
- `frontend/src/components/ReturnForm.tsx`
|
||||
- `frontend/src/components/VehicleMaintenanceActions.tsx`
|
||||
- `frontend/src/context/AuthContext.tsx`
|
||||
- `frontend/src/context/DemoGuideContext.tsx`
|
||||
- `frontend/src/context/DemoManifestContext.tsx`
|
||||
- `frontend/src/data/demoGuideSteps.ts`
|
||||
- `frontend/src/data/evidenceSignals.ts`
|
||||
- `frontend/src/data/integrationLabels.ts`
|
||||
- `frontend/src/hooks/useViewportTier.ts`
|
||||
- `frontend/src/i18n/brusselsDateTime.ts`
|
||||
- `frontend/src/i18n/config.ts`
|
||||
- `frontend/src/i18n/format.ts`
|
||||
- `frontend/src/i18n/greeting.ts`
|
||||
- `frontend/src/i18n/locales/en-GB/accessibility.json`
|
||||
- `frontend/src/i18n/locales/en-GB/audit.json`
|
||||
- `frontend/src/i18n/locales/en-GB/auth.json`
|
||||
- `frontend/src/i18n/locales/en-GB/bookings.json`
|
||||
- `frontend/src/i18n/locales/en-GB/common.json`
|
||||
- `frontend/src/i18n/locales/en-GB/dashboard.json`
|
||||
- `frontend/src/i18n/locales/en-GB/demo.json`
|
||||
- `frontend/src/i18n/locales/en-GB/errors.json`
|
||||
- `frontend/src/i18n/locales/en-GB/fleet.json`
|
||||
- `frontend/src/i18n/locales/en-GB/integrations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/knowledge.json`
|
||||
- `frontend/src/i18n/locales/en-GB/navigation.json`
|
||||
- `frontend/src/i18n/locales/en-GB/operations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/privacy.json`
|
||||
- `frontend/src/i18n/locales/en-GB/quality.json`
|
||||
- `frontend/src/i18n/locales/en-GB/returns.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/common.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/returns.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/common.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/returns.json`
|
||||
- `frontend/src/i18n/useGreetingPeriod.ts`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/pages/AboutDemo.tsx`
|
||||
- `frontend/src/pages/Audit.tsx`
|
||||
- `frontend/src/pages/Automation.tsx`
|
||||
- `frontend/src/pages/BookingCreate.tsx`
|
||||
- `frontend/src/pages/BookingDetail.tsx`
|
||||
- `frontend/src/pages/Bookings.tsx`
|
||||
- `frontend/src/pages/Dashboard.tsx`
|
||||
- `frontend/src/pages/DataQuality.tsx`
|
||||
- `frontend/src/pages/DataQualityIssueDetail.tsx`
|
||||
- `frontend/src/pages/Highlights.tsx`
|
||||
- `frontend/src/pages/Knowledge.tsx`
|
||||
- `frontend/src/pages/Login.tsx`
|
||||
- `frontend/src/pages/Privacy.tsx`
|
||||
- `frontend/src/pages/Scenarios.tsx`
|
||||
- `frontend/src/pages/Users.tsx`
|
||||
- `frontend/src/pages/VehicleDetail.tsx`
|
||||
- `frontend/src/pages/Vehicles.tsx`
|
||||
- `frontend/src/product.ts`
|
||||
- `frontend/src/styles.css`
|
||||
- `frontend/src/vite-env.d.ts`
|
||||
- `frontend/tsconfig.json`
|
||||
- `frontend/vite.config.ts`
|
||||
- `knowledge/manifest.json`
|
||||
- `knowledge/procedures/en-GB/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/en-GB/02-vehicle-return.md`
|
||||
- `knowledge/procedures/en-GB/03-damage-handling.md`
|
||||
- `knowledge/procedures/en-GB/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/en-GB/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/en-GB/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/en-GB/07-customer-documents.md`
|
||||
- `knowledge/procedures/en-GB/08-privacy.md`
|
||||
- `knowledge/procedures/en-GB/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/en-GB/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/en-GB/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/fr-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/fr-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/fr-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/fr-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/fr-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/fr-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/fr-BE/08-privacy.md`
|
||||
- `knowledge/procedures/fr-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/fr-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/nl-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/nl-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/nl-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/nl-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/nl-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/nl-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/nl-BE/08-privacy.md`
|
||||
- `knowledge/procedures/nl-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/nl-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/11-vehicle-availability.md`
|
||||
- `n8n/README.md`
|
||||
- `n8n/workflows/MANIFEST.md`
|
||||
- `n8n/workflows/check_drift.py`
|
||||
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
- `n8n/workflows/fleet-ops-error-handler.json`
|
||||
- `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`
|
||||
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||
- `n8n/workflows/merge_credential_refs.py`
|
||||
- `scripts/run-isolated-tests.sh`
|
||||
- `seed/README.md`
|
||||
- `seed/bookings.csv`
|
||||
- `seed/customers.csv`
|
||||
- `seed/data_quality_issues.csv`
|
||||
- `seed/generate_seed.py`
|
||||
- `seed/inspections.csv`
|
||||
- `seed/maintenance.csv`
|
||||
- `seed/vehicles.csv`
|
||||
- `seed/workflow_runs.csv`
|
||||
@@ -1,21 +0,0 @@
|
||||
# Paste this once into Claude Code
|
||||
|
||||
Build MobilityOps autonomously from this repository.
|
||||
|
||||
First read `START_HERE.md`, `CLAUDE.md`, `PROJECT_STATE.md` and `docs/15-build-plan.md`. Treat the repository specifications and contracts as binding. Do not ask me questions that the files already answer, do not broaden the PoC, and do not stop after a milestone.
|
||||
|
||||
Implement the milestones in order. For each milestone:
|
||||
|
||||
1. read only the documents listed for that milestone;
|
||||
2. implement the smallest complete solution;
|
||||
3. run the specified validation plus relevant regression tests;
|
||||
4. fix failures using evidence rather than guesses;
|
||||
5. update `PROJECT_STATE.md` with concise evidence and the exact next step;
|
||||
6. commit the completed milestone;
|
||||
7. continue immediately.
|
||||
|
||||
MobilityOps owns operational data and business rules. RAGcore owns retrieval and grounded answers. ITWorx MCP Hub owns MCP publication and policy. n8n only orchestrates post-commit workflows. Use configurable adapters and working degraded modes so the core demo remains usable when any external service is absent.
|
||||
|
||||
The finished PoC must be reproducible from a clean checkout, have no dead UI, use deterministic synthetic data, support the documented five-minute demo, and satisfy every criterion in `docs/14-testing-and-acceptance.md`.
|
||||
|
||||
Keep chat output brief. Spend the available context on code, tests, validation and final evidence. Begin now and continue until the repository is complete.
|
||||
@@ -15,7 +15,7 @@ release line only.
|
||||
|
||||
Do not disclose suspected vulnerabilities through a public issue.
|
||||
|
||||
Report them privately to `jens@itworx.tech` with:
|
||||
Report them privately to `security@itworx.tech` with:
|
||||
|
||||
- the affected revision, endpoint or component;
|
||||
- reproduction steps and prerequisites;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# MobilityOps — Claude build pack
|
||||
|
||||
MobilityOps is a deliberately scoped, working proof of concept for a fictitious Belgian mobility-rental company. It demonstrates how operational data, workflow automation, data-quality review, a central RAG service and a central MCP hub can work together without pretending to be a full ERP.
|
||||
|
||||
## Use this pack efficiently
|
||||
|
||||
1. Extract this archive into a new repository.
|
||||
2. Open the repository in Claude Code.
|
||||
3. Paste the contents of `MASTER_BUILD_PROMPT.md` once.
|
||||
4. Let Claude continue autonomously through the milestones in `docs/15-build-plan.md`.
|
||||
5. Only intervene if the environment itself is unavailable or credentials for an external service are required.
|
||||
|
||||
Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do not restate the full project in later prompts.
|
||||
|
||||
## What is included
|
||||
|
||||
- locked product scope and non-goals;
|
||||
- architecture and domain decisions;
|
||||
- API and event contracts;
|
||||
- realistic deterministic synthetic seed data;
|
||||
- eleven fictitious procedures for RAGcore (nl-BE, en-GB, fr-BE);
|
||||
- an initial n8n workflow export;
|
||||
- MCP tool definitions for ITWorx MCP Hub;
|
||||
- a minimal bootable frontend/API scaffold;
|
||||
- acceptance criteria and a five-minute demo script;
|
||||
- a master prompt optimized for one autonomous build run.
|
||||
|
||||
## Core responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| MobilityOps | Operational data, business rules, UI, audit and data-quality review |
|
||||
| RAGcore | Document ingestion, retrieval and source-grounded answers |
|
||||
| ITWorx MCP Hub | Controlled AI access to MobilityOps tools and MobilityOps knowledge |
|
||||
| n8n | Cross-system orchestration after committed domain events |
|
||||
|
||||
## Build principle
|
||||
|
||||
Finish a small vertical slice completely. No placeholder pages, fake buttons, invented production claims or scope expansion.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Generated live deployment and demo evidence must stay outside source control.
|
||||
demo-release/
|
||||
deployment/
|
||||
screenshots/
|
||||
*.log
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
|
||||
# Keep this policy file.
|
||||
!.gitignore
|
||||
@@ -1,195 +0,0 @@
|
||||
# Demo-productization final summary
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at
|
||||
`http://192.168.10.150:3000/Jens/MobilityOps`)
|
||||
- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge
|
||||
to `main`; no rebase/reset/squash/force-push; full git history preserved, as required)
|
||||
- **Start commit** (functional-completion baseline, already accepted):
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`
|
||||
- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via
|
||||
`git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match
|
||||
`/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly.
|
||||
(This corrects a self-reference gap in the immediately preceding pair of commits, which
|
||||
necessarily could not know their own hash at the time they were written; this is now
|
||||
the single, unambiguous, verified reference. The repository's primary branch is
|
||||
`master`, not `main` — no branch named `main` exists in this repository.)
|
||||
- **Live URL**: `http://192.168.10.150:1236`
|
||||
|
||||
## Demo organisation and context
|
||||
|
||||
**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles,
|
||||
one main location, rental team, an Operations Manager, a small workshop). This name was
|
||||
already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`,
|
||||
`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the
|
||||
UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Operations Manager** — full access: data-quality resolution, workflow retries, audit
|
||||
trail, demo reset, the Demo Guide.
|
||||
- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant.
|
||||
|
||||
Both are reachable from the login screen with no password.
|
||||
|
||||
## Demo Guide
|
||||
|
||||
An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every
|
||||
step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1)
|
||||
understand operational state, (2) open the booking needing attention, (3) process the
|
||||
odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the
|
||||
duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8)
|
||||
review real vs. synthetic vs. not-connected.
|
||||
|
||||
## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`)
|
||||
|
||||
| # | Scenario | Fixed records | Role |
|
||||
|---|---|---|---|
|
||||
| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either |
|
||||
| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM |
|
||||
| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM |
|
||||
| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM |
|
||||
| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either |
|
||||
|
||||
`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the
|
||||
live underlying records, never hardcoded — confirmed via `backend/tests/
|
||||
test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and
|
||||
live-checked after every reset throughout this work.
|
||||
|
||||
## Seed strategy and date-anchoring
|
||||
|
||||
`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs
|
||||
with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py`
|
||||
shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so
|
||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset
|
||||
moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found
|
||||
sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`.
|
||||
|
||||
## Reset strategy
|
||||
|
||||
`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears
|
||||
MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan,
|
||||
and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded
|
||||
in both the response and the `demo_reset` audit event. Reachable from the sidebar, the
|
||||
Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other
|
||||
containers, or volumes.
|
||||
|
||||
## Real vs. synthetic vs. not-connected
|
||||
|
||||
See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles,
|
||||
vehicle/booking management, return preview/commit, the 5 data-quality rules and their
|
||||
resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated
|
||||
test suite are all really implemented. The organisation, all people, vehicles, bookings,
|
||||
procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are
|
||||
not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a
|
||||
fabricated success).
|
||||
|
||||
## Test results
|
||||
|
||||
### Backend (clean checkout, isolated stack)
|
||||
- `pytest`: **127 passed**
|
||||
- `ruff check .`: clean
|
||||
- `mypy app`: clean (48 source files)
|
||||
|
||||
### Frontend (clean checkout, isolated stack)
|
||||
- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories,
|
||||
unchanged from before this work — not introduced by it)
|
||||
- `tsc -b`: clean
|
||||
- `npm run build`: clean
|
||||
- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack)
|
||||
|
||||
### Guided-demo test
|
||||
`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh
|
||||
Operations Manager session through all 8 Demo Guide steps performing the real action at
|
||||
each step (processes the actual odometer-anomaly return, resolves the resulting
|
||||
data-quality issue, merges the duplicate customer, asks a suggested knowledge question,
|
||||
checks automation + audit, reviews the About page), then resets the demo data again to
|
||||
restore the environment. **Passed**, confirmed stable across repeated runs both locally
|
||||
and against the live Unraid deployment.
|
||||
|
||||
### Clean-checkout drill
|
||||
Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from
|
||||
`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped
|
||||
host ports (`compose.override.yaml` with `!override` merge tags — no shared state with
|
||||
any other stack), `docker compose up --build -d` from empty volumes → migrations ran
|
||||
automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/
|
||||
mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed)
|
||||
→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down
|
||||
(`docker compose down -v` on the isolated project only; the working dev stack was never
|
||||
touched).
|
||||
|
||||
### Server deployment
|
||||
Deployed incrementally after every batch (10 deploy cycles across this work); final
|
||||
state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched
|
||||
across all of them (no destructive migrations on this branch). Migrations at
|
||||
`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the
|
||||
final commit exactly.
|
||||
|
||||
### Container health
|
||||
`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops.
|
||||
|
||||
### Browser console / network
|
||||
No unexpected console errors on login, dashboard, scenarios, About, or with the Demo
|
||||
Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's
|
||||
own session-probe on first load is expected and explicitly accounted for, not silenced
|
||||
blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of
|
||||
this evidence capture.
|
||||
|
||||
### Responsive / accessibility
|
||||
- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal
|
||||
overflow (`demo-accessibility.spec.ts`).
|
||||
- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped
|
||||
main content with no reflow, making the return form's "Review return" button
|
||||
unclickable while the guide was open at ordinary desktop widths — this surfaced while
|
||||
writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves
|
||||
space for the panel; regression-tested.
|
||||
- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open,
|
||||
explicit close controls).
|
||||
- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design
|
||||
— see scope). The knowledge assistant uses a local, English-only demo knowledge base;
|
||||
a Dutch question against it returns "insufficient evidence" (verified empirically), so
|
||||
suggested questions and the Demo Guide's step 6 instructions deliberately stay in
|
||||
English rather than silently breaking the demo's centerpiece grounded-answer feature.
|
||||
- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||
Audit, Automation internals) remain in English; only new demo-productization surfaces
|
||||
(login, Demo Guide, scenario overview, About page, demo badge, plain-language
|
||||
integration labels) are in Dutch — a deliberate, documented scope decision, not an
|
||||
oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||
- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible
|
||||
in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`,
|
||||
matching the brief's request for exactly 5.
|
||||
|
||||
## 5-minute and 10-minute demo flows
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` for the exact click-through scripts.
|
||||
|
||||
## Redeploy commands and rollback procedure
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild
|
||||
`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier
|
||||
`.deploy/source-<short-sha>.tar.gz` and update `.deploy/source-revision` to match.
|
||||
|
||||
## Evidence screenshots
|
||||
|
||||
All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`):
|
||||
|
||||
1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes
|
||||
2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel
|
||||
3. `04-demo-guide.png` — the Demo Guide panel open
|
||||
4. `05-return-preview.png` / `06-return-result.png` — the return flow
|
||||
5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer
|
||||
6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI
|
||||
7. `09-knowledge-assistant.png` — a grounded answer with cited sources
|
||||
8. `10-integration-status.png` — plain-language integration status on Automation
|
||||
9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry
|
||||
10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down
|
||||
11. `14-about-demo.png` — the About page
|
||||
12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover
|
||||
13. `16-reset-confirm.png` — the reset confirmation flow
|
||||
|
||||
No secrets appear in any screenshot or in this document.
|
||||
@@ -1,145 +0,0 @@
|
||||
# MobilityOps Unraid deployment evidence
|
||||
|
||||
## Outcome
|
||||
|
||||
- Deployment: **PASS**
|
||||
- Gitea publication: **PASS**
|
||||
- Gitea URL: `https://gitea.itworx.tech/Jens/MobilityOps`
|
||||
- Visibility: private (verified in the Gitea web UI)
|
||||
- Branch: `master`
|
||||
- Verified baseline commit: `4bf9afbeff44088864e0844769d4dd0e4089d85b`
|
||||
- Deployment implementation commit: `1e13943cffb2da8a328b5b1ea5e9b1fe73fdd774`
|
||||
- Server: `192.168.10.150`
|
||||
- Server directory: `/mnt/user/appdata/mobilityops`
|
||||
- Compose project: `mobilityops`
|
||||
- Application URL: `http://192.168.10.150:1236`
|
||||
- Port mapping: LAN `0.0.0.0:1236` / `[::]:1236` to `web:80`
|
||||
|
||||
## Services and health
|
||||
|
||||
| Service | Runtime state | Health | Host exposure |
|
||||
|---|---|---|---|
|
||||
| `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) |
|
||||
| `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) |
|
||||
| `web` | running, 0 restarts | healthy | `1236:80` on LAN |
|
||||
| shared host `n8n` | running | healthy | `5678:5678` on LAN; outside MobilityOps Compose |
|
||||
|
||||
The final review topology reuses the n8n container that was already running on the host.
|
||||
Its empty public-host/editor URL settings were corrected in the persistent Unraid template
|
||||
so workflow execution URLs are valid. The temporary Compose-owned n8n container was
|
||||
removed without deleting its retained volume. Port `1236` was confirmed unused before the
|
||||
original deployment; the application directory was created specifically for MobilityOps.
|
||||
|
||||
## Deployment commands
|
||||
|
||||
The existing SSH aliases resolve to the requested hosts and keys (`gitea.itworx.tech`
|
||||
for Gitea SSH and `unraid` for root access). No key was created, copied, or replaced.
|
||||
The committed source was transferred from the workstation; Unraid has no Gitea key.
|
||||
|
||||
Repository publication used the SSH clone URL supplied by Gitea:
|
||||
|
||||
```bash
|
||||
git remote add origin ssh://git@192.168.10.150:222/Jens/MobilityOps.git
|
||||
git push -u origin master
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
Git and the Gitea web UI both verified `master` as the default branch, the full commit
|
||||
history, baseline commit `4bf9afbeff44088864e0844769d4dd0e4089d85b`, and zero tags.
|
||||
The remote tree contains no `.env`, local database, `node_modules`, virtual environment,
|
||||
test cache, build cache, Playwright output, or browser binaries.
|
||||
|
||||
```bash
|
||||
git archive --format=tar.gz --output=<temporary-archive> <commit>
|
||||
scp <temporary-archive> unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||
ssh unraid
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
tar -xzf .deploy/source.tar.gz
|
||||
./deploy/unraid/configure-env.sh http://192.168.10.150:1236
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \
|
||||
python -m app.cli seed --reset
|
||||
./deploy/unraid/setup-existing-n8n.sh \
|
||||
n8n \
|
||||
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
|
||||
```
|
||||
|
||||
The server `.env` was created from `.env.example`, is mode `0600`, and contains generated
|
||||
runtime secrets. Secret values and n8n owner credentials remain server-only and are not
|
||||
included here or in Git.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
- Migration: `e7b08389f47f (head)`.
|
||||
- Deterministic seed: users 2, customers 180, vehicles 50, bookings 246, inspections 75,
|
||||
maintenance 40, data-quality issues 26, workflow runs 20.
|
||||
- HTTP: `GET /` returned 200; `GET /health` returned
|
||||
`{"status":"ok","service":"mobilityops-api"}` through the web proxy.
|
||||
- Backend gates in an isolated local Compose project: 66 tests passed, Ruff clean, mypy
|
||||
clean across 44 files.
|
||||
- Frontend: `npm ci && npm run build` completed (`tsc -b && vite build`).
|
||||
- Logs: no traceback, fatal, uncaught, or unresolved startup error in the deployment log
|
||||
scan. Browser console had no warnings or errors during the smoke test.
|
||||
- Browser smoke test in Chrome: Operations Manager demo login, Dashboard, Vehicles,
|
||||
Bookings, Data Quality, Knowledge, Automation, and Audit all loaded from the LAN URL.
|
||||
- Dashboard showed persisted seed metrics (21 available, 11 rented, 6 cleaning,
|
||||
5 maintenance, 7 blocked, 22 open issues, 1 pending/failed workflow).
|
||||
- Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and
|
||||
`DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to
|
||||
returned.
|
||||
- Shared-n8n round trip: final post-deploy event `98eb06dc-0bcc-4e3d-96ec-c23b2d266293`
|
||||
reached `succeeded` on attempt 1 with no last error; the deterministic reset afterwards
|
||||
restored `BK-DEMO-RETURN` to `active`.
|
||||
- Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related
|
||||
booking/inspection references.
|
||||
- Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`;
|
||||
the damage question returned grounded excerpts and citations from the local procedures.
|
||||
|
||||
## Integration status
|
||||
|
||||
- RAGcore: disabled for this deployment; `KNOWLEDGE_PROVIDER=demo`. No claim of a live
|
||||
RAGcore connection is shown. Operational functionality is unaffected.
|
||||
- ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the
|
||||
independently authenticated provider endpoints remain available internally to the web
|
||||
proxy/API boundary, but no live Hub connection is claimed.
|
||||
- n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the
|
||||
MobilityOps workflow is imported/published there and a real return delivery succeeded.
|
||||
The bundled MobilityOps service is disabled by default in the Unraid overlay.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and ITWorx MCP Hub are intentionally not connected yet.
|
||||
- Demo authentication remains the accepted HMAC-cookie PoC mechanism.
|
||||
- The dependency advisories already documented in final acceptance remain unchanged.
|
||||
|
||||
## Redeploy
|
||||
|
||||
From the workstation, create an archive of the desired committed revision and transfer it
|
||||
to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, then run:
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
tar -xzf .deploy/source.tar.gz
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current
|
||||
curl -fsS http://127.0.0.1:1236/health
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web
|
||||
docker logs -f n8n
|
||||
```
|
||||
|
||||
## Safe rollback
|
||||
|
||||
Choose a known-good commit on the workstation, archive and transfer it as above, then on
|
||||
Unraid extract it over the identifiable MobilityOps source directory and run the same
|
||||
`up --build -d` command. Preserve `.env` and both named volumes; do not use `down -v`,
|
||||
remove volumes, prune Docker, or modify unrelated containers. Check the target commit's
|
||||
Alembic compatibility before rolling application code behind the current database schema.
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,89 +0,0 @@
|
||||
# MobilityOps premium UI evidence summary
|
||||
|
||||
Date: 2026-08-02
|
||||
Branch: `design/mobilityops-premium-ui`
|
||||
Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b`
|
||||
Final design implementation commit: `1f292e14bb6a2e8ded5dc675b1b3360307d8a9ae`
|
||||
Review URL: `http://192.168.10.150:1236`
|
||||
|
||||
## Outcome
|
||||
|
||||
The working PoC was transformed into the Control Rail operational interface without
|
||||
changing backend contracts or adding scope. All existing journeys remain functional;
|
||||
return registration gained an evidence-based review boundary before commit.
|
||||
|
||||
## Evidence index
|
||||
|
||||
- Baseline audit: `docs/design/current-ux-audit.md`
|
||||
- Three directions and decision: `docs/design/design-directions.md`
|
||||
- Design tokens and component rules: `docs/design/design-system.md`
|
||||
- Stitch resource IDs: `docs/design/stitch-manifest.md`
|
||||
- Implemented visual validation: `docs/design/implementation-validation.md`
|
||||
- Baseline captures: `artifacts/design-validation/current/`
|
||||
- Stitch captures: `artifacts/design-validation/stitch/`
|
||||
- Final responsive captures: `artifacts/design-validation/implementation/`
|
||||
|
||||
## Major implementation changes
|
||||
|
||||
- Responsive Control Rail shell with compact top bar, desktop rail, off-canvas menu and
|
||||
labelled mobile bottom navigation.
|
||||
- Live readiness band, filterable Attention queue, movement timeline, honest integration
|
||||
pulse and persisted activity on the operations dashboard.
|
||||
- Searchable fleet and booking registries; booking client pagination limits the DOM to 25
|
||||
operational rows; responsive tables retain field labels.
|
||||
- Capture → review → result return workflow with calculated consequence preview and no
|
||||
write request before confirmation.
|
||||
- Match/conflict duplicate comparison, evidence-first knowledge, system-health cards and
|
||||
expandable audit metadata.
|
||||
- Inline SVG product mark, Feather-like line icon set, CSS control-centre illustration,
|
||||
timeline/status motion and reduced-motion fallback; no image or motion dependency.
|
||||
|
||||
## Validation
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Backend tests | 66 passed |
|
||||
| Backend lint | ruff passed |
|
||||
| Backend types | mypy: 0 issues in 44 files |
|
||||
| Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip |
|
||||
| Browser journeys | 19 passed locally |
|
||||
| Horizontal overflow | none at 390/768/1280/1440 px |
|
||||
| Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support |
|
||||
| Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes |
|
||||
| Console/network | 0 browser warnings/errors; 7 authenticated API paths returned HTTP 200 |
|
||||
| Deployed global search | Ctrl+K plus `MO-024` navigation passed against the review URL |
|
||||
|
||||
All displayed operational counts remain derived from the existing persisted API data.
|
||||
Synthetic-data labelling is persistent on login and authenticated surfaces.
|
||||
|
||||
Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`.
|
||||
The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application,
|
||||
and healthy state from the server's existing n8n at port 5678. A synthetic return reached
|
||||
`succeeded` on attempt 1 through that shared n8n and its MobilityOps callback; the demo was
|
||||
then reset to its deterministic state.
|
||||
|
||||
## Performance observations
|
||||
|
||||
No runtime font, image or animation dependency was added. The application uses inline SVG
|
||||
and CSS visuals, and the production bundle remains appropriate for this internal PoC.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public
|
||||
references. It intentionally does not offer customer lookup because the locked PoC has
|
||||
no customer detail route or cross-entity search API.
|
||||
- The repository retains a bundled n8n service for standalone local clean-checkout demos.
|
||||
The Unraid overlay keeps it behind the opt-in `bundled-n8n` profile; the live review
|
||||
deployment uses the server's existing shared n8n instead.
|
||||
- The MCP Hub state is correctly shown as not configured in the current PoC rather than
|
||||
simulated as healthy.
|
||||
- Live RAGcore and MCP Hub round trips remain subject to the existing environment limits
|
||||
documented in `PROJECT_STATE.md`; their degradation behavior is unchanged.
|
||||
|
||||
## Rollback
|
||||
|
||||
The accepted baseline remains reachable at commit
|
||||
`dfabb41582e302f45a3de826f85f531bf23dfc8b`. To roll back the review deployment without
|
||||
rewriting git history, archive that revision, extract it over the application source on
|
||||
Unraid while preserving `.env` and Docker volumes, and run
|
||||
`docker compose -p mobilityops up -d --build`. Verify `/health` and port 1236 afterwards.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Fleet Ops — final acceptance evidence
|
||||
|
||||
## Accepted implementation
|
||||
|
||||
- Deployment source marker: current `master`; the application image was built from runtime commit `cb7edb0` and the following commits contain acceptance/evidence only.
|
||||
- Live demo: `http://192.168.10.150:1236` — public passwordless synthetic demo.
|
||||
- Deployment: Unraid `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
|
||||
- Database migration: `b913a72e8c14 (head)`.
|
||||
- Product name: Fleet Ops; MobilityOps remains the technical repository/deployment ID.
|
||||
|
||||
## Clean acceptance — 2026-08-10
|
||||
|
||||
```text
|
||||
Backend, isolated PostgreSQL: 241 passed, zero warnings
|
||||
Playwright, fresh local build: 152 passed (5.4 min)
|
||||
Playwright, live production: 152 passed (4.7 min)
|
||||
Ruff: clean
|
||||
Mypy: clean across 58 source files
|
||||
Frontend lint/build: passed
|
||||
npm audit, full and prod: 0 vulnerabilities
|
||||
Alembic: b913a72e8c14 (head)
|
||||
```
|
||||
|
||||
The browser suite covers both roles, protected routes, booking/return/maintenance/user/privacy workflows, five data-quality resolution types, correlated audit, retryable orchestration, grounded knowledge in three languages, the complete guided demo, the recruiter highlights, keyboard behaviour and responsive layouts from 360 to 1440 px.
|
||||
|
||||
The provider matrix explicitly covers both valid title contracts: the deterministic provider uses Markdown frontmatter titles, while production RAGcore returns curated localized presentation titles. Both must include the language-specific source fragment. The focused live matrix passed 3/3, followed by the complete 152/152 green production run.
|
||||
|
||||
## Production hand-off state
|
||||
|
||||
The final reset completed immediately before the verified Hub call at `2026-08-10T19:16Z` and restored:
|
||||
|
||||
```text
|
||||
users 2 · customers 180 · vehicles 50 · bookings 254 · inspections 75
|
||||
maintenance 40 · data-quality issues 33 · workflow runs 20
|
||||
scenario_integrity.all_ready = true (5/5 scenarios)
|
||||
```
|
||||
|
||||
API readiness is `ready`, PostgreSQL is `up`, and API, web, database, scheduled backup, Prometheus and Grafana are running healthy/current. No traceback or critical error appears in post-deployment API/backup logs.
|
||||
|
||||
A pre-deployment custom-format backup was created and independently verified by SHA-256 plus `pg_restore --list`:
|
||||
|
||||
`/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T185833Z.dump`
|
||||
|
||||
## External integration evidence
|
||||
|
||||
- **Existing central n8n:** configured, dispatch enabled and operational. All four expected workflows have recent healthy heartbeats. The reset state contains 19 successful runs, zero pending, zero unexpected failures and one explicitly synthetic failed-retry scenario.
|
||||
- **RAGcore:** reachable and ready for tenant `northstar-mobility-demo`, workspace `mobilityops`, collection `internal-procedures`. Exact identity lookups independently confirm 11/11 active published documents in each of NL/EN/FR; the latest n8n report separately records 33 synchronized documents and zero failures. The canonical parsed-artifact hash is not misrepresented as a raw-source hash.
|
||||
- **ITWorx MCP Hub:** registration is enabled, the Hub is reachable and Fleet Ops is **operational**. Three real `mobilityops.operations.summary` verification calls are audited under the Hub's tenant-bound client identity; the final short-lived bearer produced HTTP 200/JSON and HTTP 401 after immediate revocation. All exposed Fleet Ops tools remain read-only.
|
||||
|
||||
## Recruiter and visual acceptance
|
||||
|
||||
The public entry now offers a direct **90-second Highlights** route. It links three product actions to their engineering proof, while the **Engineering** workspace explains domain ownership, commit-before-orchestration, citation-bound AI, security and explicit scope.
|
||||
|
||||
Interactive live inspection confirmed:
|
||||
|
||||
- no horizontal overflow on Highlights or Engineering at desktop; automated coverage confirms 360/390 px mobile layouts;
|
||||
- clear hierarchy, consistent primary actions and readable proof cards;
|
||||
- compact remaining-attention action (count badge, two-line label, small directional icon) without the former stretched empty panel;
|
||||
- truthful live n8n/RAGcore/MCP labels after reset, including 11 verified RAG documents and the real Hub client/tool evidence;
|
||||
- no dead links, placeholder numbers or unexplained raw data-quality references;
|
||||
- complete synthetic-data disclosure on login and in the persistent shell.
|
||||
|
||||
Current evidence images are in `artifacts/evidence/screenshots/`:
|
||||
|
||||
- `1-login.png`
|
||||
- `2-highlights.png`
|
||||
- `3-engineering-story.png`
|
||||
- `4-dashboard.png`
|
||||
- `5-knowledge-evidence.png`
|
||||
- `6-highlights-mobile.png`
|
||||
|
||||
## Deliberate boundary
|
||||
|
||||
This is a completed, production-shaped public demo—not claimed customer adoption and not a general ERP. Accounting, payments, public reservations, CRM, inventory, HR, a second RAG stack, a separate MCP server and autonomous write agents remain intentionally excluded. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery governance.
|
||||
@@ -1,161 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,284 +0,0 @@
|
||||
# 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 8A–8F). 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
|
||||
<status>" 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.
|
||||
@@ -1,291 +0,0 @@
|
||||
# Fleet Ops final localization — final summary
|
||||
|
||||
Small, targeted correction round on top of the already-merged, functionally-validated
|
||||
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
|
||||
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
|
||||
test hardening, and documentation consistency — explicitly no redesign, no business-logic
|
||||
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
|
||||
|
||||
## Commits
|
||||
|
||||
| Stage | Commit | Message |
|
||||
|---|---|---|
|
||||
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
|
||||
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
|
||||
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
|
||||
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
|
||||
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
|
||||
|
||||
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
|
||||
this branch was verified freshly and cleanly branched from `origin/master` with a clean
|
||||
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
|
||||
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
|
||||
merge, per the mandatory pre-merge safety check.
|
||||
|
||||
Full commit sequence (oldest to newest):
|
||||
|
||||
```
|
||||
1fbb20b docs: audit remaining Fleet Ops localization gaps
|
||||
37a362c fix: translate remaining NL/FR interface gaps
|
||||
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
|
||||
d17af1c feat: centralize API error localization
|
||||
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
|
||||
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
|
||||
f0d6411 fix: serve the missing Fleet Ops favicon
|
||||
9468cc3 docs: update PROJECT_STATE and README for the final localization round
|
||||
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
|
||||
5f0eaa5 merge: finalize Fleet Ops localization
|
||||
```
|
||||
|
||||
## Product name and supported languages
|
||||
|
||||
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
|
||||
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
|
||||
Compose project / deployment-directory identifier only.
|
||||
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
|
||||
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
|
||||
automated test, see below).
|
||||
|
||||
## Corrected translations
|
||||
|
||||
- Role names actually translated (not just labelled as translated): `auth.json` /
|
||||
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
|
||||
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
|
||||
- `audit.title` → **Auditgeschiedenis** / **Piste d'audit**; `columns.actor` → **Uitvoerder**
|
||||
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
|
||||
limitations).
|
||||
- `list.statusOpen` → **Openstaand**; `ledger.filterRecent` → **Recentste**;
|
||||
`scenarios.startScenario` → **Scenario starten** / **Démarrer le scénario**.
|
||||
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
|
||||
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
|
||||
the pre-existing whole-string-identity test, which structurally cannot catch this class
|
||||
of bug.
|
||||
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
|
||||
below).
|
||||
|
||||
## Removed allowlist exceptions
|
||||
|
||||
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
|
||||
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
|
||||
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
|
||||
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
|
||||
translated; their old comments describing them as "deliberately untranslated" were no
|
||||
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
|
||||
no-"MobilityOps"/no-"PoC" guard.
|
||||
|
||||
## Hardcoded-text result
|
||||
|
||||
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
|
||||
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
|
||||
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
|
||||
|
||||
## API-error-localization result
|
||||
|
||||
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
|
||||
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
|
||||
English backend text for the common case) at all 13 call sites across 7 files
|
||||
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
|
||||
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
|
||||
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
|
||||
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
|
||||
explanation + optional next step; raw backend text is demoted to a "Technical
|
||||
details"/"Détails techniques" disclosure, never the primary message.
|
||||
|
||||
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
|
||||
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
|
||||
raw text as the primary message; unknown-code and unknown-status fallback chains behave
|
||||
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
|
||||
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
|
||||
seeded failed automation run renders a fully localized French error with a "DÉTAILS
|
||||
TECHNIQUES" disclosure below it.
|
||||
|
||||
## Greeting logic and edge cases
|
||||
|
||||
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
|
||||
one of 4 periods against **Europe/Brussels** wall-clock time via
|
||||
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
|
||||
construction — no manual UTC-offset math):
|
||||
|
||||
| Period | Window | nl-BE | en-GB | fr-BE |
|
||||
|---|---|---|---|---|
|
||||
| morning | 05:00–11:59 | Goedemorgen | Good morning | Bonjour |
|
||||
| afternoon | 12:00–17:59 | Goedemiddag | Good afternoon | Bonjour |
|
||||
| evening | 18:00–22:59 | Goedenavond | Good evening | Bonsoir |
|
||||
| night | 23:00–04:59 | Welkom terug | Welcome back | Bon retour |
|
||||
|
||||
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
|
||||
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
|
||||
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
|
||||
over live while the app stays open, no reload required; initial render uses a synchronous
|
||||
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
|
||||
|
||||
Edge-case evidence:
|
||||
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
|
||||
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
|
||||
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
|
||||
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
|
||||
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
|
||||
live period rollover with no `page.reload()` call anywhere in that test; language-switch
|
||||
behaviour without changing the time period; the "never Goedenacht" guard.
|
||||
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
|
||||
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
|
||||
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
|
||||
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
|
||||
|
||||
## README / PROJECT_STATE corrections
|
||||
|
||||
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
|
||||
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
|
||||
correction" section header, which still read "IN PROGRESS .../Not yet merged to
|
||||
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
|
||||
entry for this correction round (not a rewrite of prior entries, per the brief's
|
||||
explicit instruction not to hide earlier history).
|
||||
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
|
||||
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
|
||||
after the favicon regression test was added).
|
||||
|
||||
## Backend tests, Ruff, mypy
|
||||
|
||||
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
|
||||
|
||||
- `pytest`: **151 passed**, 0 failed.
|
||||
- `ruff check .`: **All checks passed!**
|
||||
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
|
||||
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
|
||||
in 49 source files.**
|
||||
|
||||
No backend Python was touched this round; these numbers are unchanged from the prior
|
||||
correction milestone's final gate, confirmed green again on the current tree.
|
||||
|
||||
## Frontend build, Playwright
|
||||
|
||||
- `npx tsc --noEmit`: clean, 0 errors.
|
||||
- `npm run build` (`tsc -b && vite build`): clean production build.
|
||||
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
|
||||
**139 passed**, 0 failed (confirmed on a clean run after two transient
|
||||
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
|
||||
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
|
||||
re-run of every new/changed suite also passed cleanly in between).
|
||||
|
||||
## Clean-checkout drill
|
||||
|
||||
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
|
||||
network with the working dev stack), fresh `git clone --branch
|
||||
fix/fleet-ops-final-i18n-ux` of only committed files:
|
||||
|
||||
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
|
||||
2. `alembic upgrade head` → `799d8800e241 (head)`.
|
||||
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
|
||||
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
|
||||
deterministic count exactly.
|
||||
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
|
||||
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
|
||||
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
|
||||
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
|
||||
greeting boundaries, API error paths, and the guided demo, all in one pass.
|
||||
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
|
||||
8. Isolated stack, containers, volumes and images torn down; original dev environment
|
||||
confirmed untouched (`mobilityops-*` containers unaffected throughout).
|
||||
|
||||
**PASS.**
|
||||
|
||||
## Guided demo per language
|
||||
|
||||
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
|
||||
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
|
||||
band, attention queue, integration pulse, recent activity), audit trail, automation retry
|
||||
flow with localized error + technical-details disclosure, and demo reset — all rendering
|
||||
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
|
||||
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
|
||||
stack and the isolated clean-checkout stack.
|
||||
|
||||
## Server deployment, container health
|
||||
|
||||
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
|
||||
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
|
||||
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
|
||||
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
|
||||
second n8n instance was started (shared existing n8n at `:5678` used throughout).
|
||||
|
||||
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive` → `scp` →
|
||||
extract over the existing deployment dir → update `.deploy/source-revision` →
|
||||
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||
→ confirm `alembic current` → `seed --reset`.
|
||||
|
||||
Final container status:
|
||||
|
||||
```
|
||||
mobilityops-api-1 Up (healthy)
|
||||
mobilityops-db-1 Up (healthy)
|
||||
mobilityops-web-1 Up (healthy)
|
||||
```
|
||||
|
||||
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
|
||||
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
|
||||
merge — both deployments passed migrations, reseed, and a live smoke test.
|
||||
|
||||
## Repository / runtime hash comparison
|
||||
|
||||
```
|
||||
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||
```
|
||||
|
||||
**Exact match.**
|
||||
|
||||
## Browser console and network
|
||||
|
||||
No console errors on any checked route in any of the 3 languages (dashboard, audit,
|
||||
automation, login) on the live Unraid deployment. All observed `/api/` network requests
|
||||
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
|
||||
the final deployment.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
|
||||
manual browser testing** on the live server: on 2 occasions, right after a client-side
|
||||
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
|
||||
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
|
||||
controlled repeat of the exact same click sequence (fresh login, single deliberate
|
||||
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
|
||||
possible: the codebase has exactly one `i18n.changeLanguage()` call site
|
||||
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
|
||||
`i18n.language` are structurally coupled through a single i18next singleton with no
|
||||
code path capable of producing this split state. Not reproduced even once across 139
|
||||
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
|
||||
local post-merge on master) in a clean, extension-free browser context. Most likely
|
||||
explanation: a third-party browser extension active in the specific interactive testing
|
||||
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
|
||||
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
|
||||
the `lang` attribute based on its own content heuristics, independent of the React app.
|
||||
Logged here for transparency rather than silently dismissed; does not affect any
|
||||
automated PASS result above.
|
||||
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
|
||||
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
|
||||
processes from the interactive testing session above. A clean run immediately
|
||||
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
|
||||
new/changed suite also passed cleanly in between. Treated as machine resource
|
||||
contention, not a code defect — consistent with the prior correction milestone's own
|
||||
documented experience of "sequential-run-only flakes reproduced from resource
|
||||
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
|
||||
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
|
||||
specified "Auteur") was missed in the initial pass and only caught during live browser
|
||||
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
|
||||
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
|
||||
potentially due for a visual refresh; per explicit user decision mid-session, this is
|
||||
out of scope for this correction round and deferred to a separate follow-up task.
|
||||
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
|
||||
demo/not-connected state documented by the prior correction milestone.
|
||||
|
||||
## Rollback procedure
|
||||
|
||||
`.deploy/source-revision` on the server records exactly which commit is live. To roll
|
||||
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
|
||||
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
|
||||
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
|
||||
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
|
||||
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||
followed by `alembic upgrade head` (migrations are additive only — no destructive
|
||||
migration exists on this branch, so no database rollback is needed). No secrets were
|
||||
printed or read at any point in this process (`.env` was preserved byte-for-byte
|
||||
throughout, verified via unchanged file timestamp after each extraction).
|
||||
@@ -1,155 +0,0 @@
|
||||
# Fleet Ops release — final-product-polish evidence
|
||||
|
||||
## Result: PASS
|
||||
|
||||
## Commits
|
||||
|
||||
- Original feature-branch baseline before this task: `257a4cf` (`docs(polish): audit finale demo-afwerking`)
|
||||
- Feature-branch commits added this task, on `feat/mobilityops-functional-completion`:
|
||||
- `337f871` — polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
|
||||
- `845db14` — fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
|
||||
- Feature branch final commit: `845db14e172539b1d10e40f6a3249a72122deb41`
|
||||
- `master` before merge (verified against the previously recorded baseline): `e0c7ed60112510687627d20a957af91c8b9db7f8` — unchanged, no unexpected commits, no conflicts (confirmed via `git merge-tree` dry run before merging)
|
||||
- Merge commit on `master`: `18a765d62345ea9a6660d04fb868f218cf4d0b6e` (`merge: release Fleet Ops multilingual demo`, `--no-ff`)
|
||||
- Final `master` commit (pushed and deployed): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
||||
- Deployed commit on Unraid (`.deploy/source-revision`): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
||||
- Feature branch was **not** deleted, per instruction.
|
||||
|
||||
## URL
|
||||
|
||||
- Live review deployment: `http://192.168.10.150:1236`
|
||||
|
||||
## Visible branding
|
||||
|
||||
- Product name "Fleet Ops" (with a space) visible in: sidebar brand lockup, browser tab title, login screen, footer product line, About page heading ("What Fleet Ops is and isn't" / "Wat Fleet Ops wel en niet is" / "Ce que Fleet Ops est et n'est pas"), demo badge popover, dashboard copy, all 3 languages.
|
||||
- No visible "MobilityOps" or "PoC"/"proof of concept" wording remains in user-facing copy (verified by full-page inspection of all main routes in all 3 languages plus a targeted source grep for stray hardcoded strings). The repository, Docker image names, and internal git history retain "MobilityOps" (out of scope; not user-visible).
|
||||
- Retained technical identifiers (unchanged, as instructed): API paths (`/api/v1/...`), Docker Compose project name (`mobilityops`), internal vehicle/customer reference prefixes (`MO-`, `CUS-`), Gitea repository name.
|
||||
|
||||
## Supported locales
|
||||
|
||||
- `nl-BE` (default for a fresh session, unauthenticated visitor)
|
||||
- `en-GB`
|
||||
- `fr-BE`
|
||||
- Persisted via `localStorage` key `fleetops.language`; survives refresh, logout/login, and demo reset. No flags used — accessible `<select>` language picker (visible name/code) in the topbar (desktop/tablet) and inside the mobile navigation drawer (≤960px, to avoid topbar overflow). `document.documentElement.lang` kept in sync. All dates/numbers rendered via `Intl.DateTimeFormat`/`Intl.NumberFormat` (`Europe/Brussels` timezone).
|
||||
|
||||
## Translation coverage
|
||||
|
||||
- `frontend/e2e/i18n-coverage.spec.ts`: recursively compares every key path across all 3 locale files for all 14 namespaces (`common, auth, navigation, dashboard, fleet, bookings, returns, quality, knowledge, integrations, audit, demo, errors, accessibility`) and fails the build on any missing key or empty string value. **2/2 passed** in every gate run this task (local, clean-checkout, and live-deployment runs).
|
||||
- Command: `npx playwright test e2e/i18n-coverage.spec.ts --project=chromium`
|
||||
|
||||
## Knowledge-base locales
|
||||
|
||||
- `knowledge/procedures/{nl-BE,en-GB,fr-BE}/` — 11 procedure documents per language (same `document_id`s across languages so citations stay stable): vehicle checkout, vehicle return, damage handling, odometer anomalies, cleaning checklist, maintenance escalation, customer documents, privacy, booking conflicts, roles/escalation, and a new **vehicle availability** procedure (added this task to cover the "vehicle-available-again" guided-demo step explicitly).
|
||||
- `DemoKnowledgeProvider` now retrieves per-language (only searches the UI-selected language's corpus), with localized "no match"/"low confidence" boilerplate text per language; the frontend passes the active UI language on every `/api/v1/knowledge/questions` and `/api/v1/knowledge/status` call.
|
||||
- Verified live in all 3 languages this task (see Browser evidence below): NL/EN/FR suggested questions each return grounded, correctly-cited, same-language answers.
|
||||
- Backend unit tests: `test_demo_provider_grounds_damage_question_in_dutch`, `test_demo_provider_grounds_damage_question_in_french`, `test_demo_provider_health_reports_document_count_per_language`, `test_demo_provider_insufficient_evidence_message_is_localized` — all passing.
|
||||
|
||||
## Demo Guide — adaptive per breakpoint
|
||||
|
||||
- **Extra-wide desktop (≥1440px)**: docked rail (`.demo-guide-panel.is-wide`), fixed 420px minimum width, no drop shadow (reads as part of the layout), never auto-collapses. Verified: `demo-guide.spec.ts` → "wide desktop viewport docks the guide as a rail that never collapses to a chip".
|
||||
- **Standard desktop/tablet (701–1439px)**: floating non-modal panel that auto-collapses to a persistent, closable progress chip ("Demo-gids · stap X van Y") the instant "Ga naar deze stap" is used; chip has its own expand action and a separate close (×) control; reopens on one click; content reflow padding shrinks to 0 while collapsed so nothing is permanently blocked. Verified: 3 dedicated tests in `demo-guide.spec.ts`.
|
||||
- **Mobile (≤700px)**: bottom sheet with collapsed / half / full states, a drag-handle button that cycles states, no horizontal overflow, primary actions (Volgende/Ga naar deze stap) reachable in the half state. Verified: `demo-guide.spec.ts` → "mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", plus `demo-accessibility.spec.ts` → "demo guide is usable as a mobile bottom sheet".
|
||||
- **Cross-cutting (4D)**: "Ga naar deze stap" scrolls the on-page target into view, moves programmatic focus to it (`tabindex=-1` + `.focus()`), and applies a 2.2s outline pulse (`.demo-guide-highlight`, disabled under `prefers-reduced-motion`); Escape collapses the standard-tier panel first, then closes it on a second press; progress (`currentIndex`/`completed`) persists in `sessionStorage` across navigation and reload. Verified: `demo-guide.spec.ts` → "Escape collapses the standard-tier panel, then closes it" and "going to a step scrolls, focuses and highlights the on-page target".
|
||||
- Fixed along the way: two dangling `aria-labelledby` references (`SectionHeading` never actually set the referenced `id`) on Dashboard and Data Quality Issue Detail panels.
|
||||
|
||||
## Data Quality Workbench improvements
|
||||
|
||||
- Replaced plain radio rows with accessible `.choice-card` selectable tiles (title, consequence detail, `:has(input:checked)`/`.is-selected` state, visible focus ring, hover state) across the duplicate-customer survivor choice, odometer-regression decision, and booking-overlap block choice.
|
||||
- Clear action hierarchy: primary resolve/apply/merge action uses `.button-primary`; defer uses a de-emphasized `.button-tertiary`; reject uses `.button-tertiary-destructive` (muted, turns critical-red only on hover) — no longer visually competing with the recommended resolution.
|
||||
- Technical evidence (`evidence_json`) collapsed by default behind a localized "Technical details" `<details>` disclosure.
|
||||
- Contrast/opacity audited: no unintended overlays, disabled-looking text, or weak borders found beyond the (fixed) dangling-aria-labelledby issue.
|
||||
|
||||
## Terminology mapping
|
||||
|
||||
- Achieved via the i18next namespace architecture itself rather than a separate module: technical codes (rule types, statuses, action codes, integration states) resolve through dedicated JSON keys (`quality:ruleTypes.*`, `quality:list.status*`, `audit:actions.*`, `integrations:statusLabels.*`, `fleet:statuses.*`, `bookings:statuses.*`) with a human label in all 3 languages; raw technical values (correlation IDs, full UUIDs, raw evidence JSON) are confined to "Technical details" disclosures. Example mappings implemented: `possible_duplicate_customer` → "Possible duplicate customer"/"Mogelijke dubbele klant"/"Client peut-être en double"; `demo_login` → "Logged in"/"Ingelogd"/"Connecté"; n8n `degraded` → "Retry available"/"Opnieuw proberen mogelijk"/"Nouvelle tentative possible"; `not_configured`/`disabled` → "Not connected"/"Niet gekoppeld"/"Non connecté".
|
||||
|
||||
## Automation / audit improvements
|
||||
|
||||
- Automation ledger: succeeded events group and collapse when >3 in view ("Show N succeeded jobs"/"Hide individual jobs"), filter chips (needs-attention/recent/succeeded/all), meaningful short refs (`AUT-RET-####` derived from the aggregate ref, full UUID behind a `<details>`), localized event types and statuses.
|
||||
- Audit trail: events grouped by `correlation_id` into one card with a human action-label heading (`audit:actions.*`), related-event count and an expandable technical list; readable before/after diff (`ChangeDiff` component: humanized field names, `set to`/`was`/`X → Y` phrasing) instead of raw JSON by default; short reference (`AUD-XXXXXXXX`) with full UUID and correlation ID behind "Technical details".
|
||||
|
||||
## Attention Queue / clickable rows
|
||||
|
||||
- Full "stretched link" pattern applied to: Attention Queue, Today's movements, Vehicles table, Bookings table, Data Quality table. Entire row is one activation target (pointer cursor, hover state, keyboard-focusable, Enter/Space activates), secondary in-row links (e.g. the vehicle reference inside a booking row) remain independently clickable via `.cell-link { z-index: 2 }` layered above the row overlay.
|
||||
- Dedicated tests in `frontend/e2e/clickable-rows.spec.ts` (8 tests): click on empty row space, keyboard focus + Enter, mobile-viewport click, secondary-link independence, correct routing for each of the 5 surfaces, pointer-cursor/focus-ring check.
|
||||
|
||||
## Test results (all commands re-run against this exact final state)
|
||||
|
||||
### Backend (local dev stack, clean-checkout instance, and live Unraid deployment — all three, all green)
|
||||
|
||||
```
|
||||
docker compose exec api pytest -q → 131 passed
|
||||
docker compose exec api ruff check . → All checks passed!
|
||||
docker compose exec api mypy app → Success: no issues found in 48 source files
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```
|
||||
cd frontend && npm run build → tsc -b && vite build: success
|
||||
```
|
||||
|
||||
### Playwright (92 tests; run against local dev stack, the isolated clean-checkout stack, and the live Unraid deployment — 92/92 passed in all three runs)
|
||||
|
||||
```
|
||||
npx playwright test --project=chromium
|
||||
```
|
||||
|
||||
Suites: `demo-accessibility`, `demo-entry`, `demo-guide` (including the 3 new adaptive-breakpoint tests, chip close-control test, Escape test, scroll/focus/highlight test), `demo-legibility`, `demo`, `guided-demo-full`, `i18n-coverage`, `interactive-elements`, `responsive-i18n` (7 breakpoints × 3 languages = 21 tests), `ui-redesign`, `clickable-rows` (new, 8 tests).
|
||||
|
||||
## Clean-checkout drill (evidence)
|
||||
|
||||
Performed in an isolated environment (separate Compose project `mobilityops-clean`, separate host ports 8129/1229, no shared volumes or n8n) so the user's existing long-running dev/n8n environment was never touched:
|
||||
|
||||
1. `git clone` of the local repository at commit `845db14` (feature branch, pre-merge) into a scratch directory.
|
||||
2. `cp .env.example .env` (project name and ports overridden for isolation only).
|
||||
3. `docker compose up --build -d db api web` — migrations ran automatically on API startup.
|
||||
4. `docker compose exec api python -m app.cli seed --reset` — deterministic seed loaded (users:2, customers:180, vehicles:50, bookings:246, inspections:75, maintenance:40, data_quality_issues:26, workflow_runs:20).
|
||||
5. `docker compose exec api pytest -q` → 131 passed. `ruff check .` → clean. `mypy app` → clean.
|
||||
6. `npm ci && npm run build` → clean build.
|
||||
7. `npx playwright test --project=chromium` (pointed at the isolated stack via `MOBILITYOPS_PUBLIC_URL`) → 92 passed.
|
||||
8. Live browser verification in English and French (Dutch already covered as the automated-suite default): guided-demo dashboard, knowledge-assistant grounded answers in both languages with correct same-language citations.
|
||||
9. `POST /api/v1/demo/reset` → `scenario_integrity: {"all_ready": true, "not_ready": []}`.
|
||||
10. Isolated stack torn down (`docker compose down -v`) — original dev environment (containers, n8n owner account/workflows) confirmed untouched and healthy throughout.
|
||||
|
||||
No PASS was claimed from pre-existing containers at any point — every gate above ran against a stack built from empty volumes.
|
||||
|
||||
## Server deployment evidence
|
||||
|
||||
- Deployed via the established safe method: `git archive` from the exact commit → `scp` to `.deploy/source-<sha>.tar.gz` on Unraid → extract → update `.deploy/source-revision` → `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web` (db never rebuilt; server `.env` and named volumes — Postgres, n8n — preserved throughout).
|
||||
- Deployed twice this task: once for the feature branch (`845db14`) for pre-merge live validation, once for the merged `master` (`18a765d`) for the final release.
|
||||
- Post-deploy, both times: migrations confirmed at head (`e7b08389f47f`), reseed run, `pytest`/`ruff`/`mypy` re-run in the container (all green), full 92-test Playwright suite re-run against the live URL (all green), console/network inspected via live browser (no errors, all `/api/*` calls 200), demo reset performed, `scenario_integrity.all_ready: true` confirmed both times.
|
||||
- Real shared n8n instance (`http://192.168.10.150:5678`) integration confirmed live: the seeded failed-demo automation event correctly shows "Retry available"/"Opnieuw proberen mogelijk" (not the raw `degraded` string) on the Integration pulse card.
|
||||
|
||||
## Responsive / accessibility
|
||||
|
||||
- No-horizontal-overflow verified across the full 7-breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844, 360×800) in all 3 languages (`responsive-i18n.spec.ts`, 21 tests) plus the original 4-breakpoint English suite (`ui-redesign.spec.ts`).
|
||||
- Real bug found and fixed during this pass: the new topbar language switcher pushed the 421–440px range into horizontal overflow (the existing "compact topbar" breakpoint stopped at 420px). Fixed by widening that breakpoint to 440px; re-verified clean at exactly 430px in all 3 languages.
|
||||
- Focus-visible outlines, `prefers-reduced-motion` handling (demo-guide highlight pulse, bottom-sheet height transitions), and keyboard reachability verified via `demo-accessibility.spec.ts` and the new adaptive-guide/clickable-row tests.
|
||||
|
||||
## Screenshots
|
||||
|
||||
`artifacts/fleet-ops-release/screenshots/`:
|
||||
- `01-login-nl.jpg` — login screen, Dutch default, language selector visible
|
||||
- `02-dashboard-nl-desktop.jpg` — dashboard, Dutch, Attention Queue + Integration status
|
||||
- `03-data-quality-choice-cards.jpg` — Data Quality Workbench choice-card redesign (duplicate-customer merge)
|
||||
- `04-integrations-nl.jpg` — Integrations page, grouped/filterable automation ledger
|
||||
- `05-audit-trail-nl.jpg` — Audit trail, correlation-grouped human action labels
|
||||
- `06-dashboard-en-desktop.jpg` — dashboard, English
|
||||
- `07-dashboard-fr-desktop.jpg` — dashboard, French
|
||||
- `08-about-fr.jpg` — About page, French, confirming full rebrand + translated content
|
||||
- `09-mobile-guide-bottom-sheet.png` — mobile bottom sheet, half state (390×844)
|
||||
- `10-mobile-guide-full.png` — mobile bottom sheet, full state (390×844)
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Data-quality evidence "summary" strings (the free-text detail line under each Attention Queue/Data Quality row, e.g. "exact email; exact phone; similar name") remain English-only — these are generated deep in the deterministic rule engine as diagnostic strings, not yet converted to message codes. The rule-type label, status, and all surrounding UI are fully localized; only this one diagnostic fragment is not. Documented as a follow-up, not blocking.
|
||||
- RAGcore and ITWorx MCP Hub remain honestly labelled as not live-connected (unchanged from prior milestones) — the demo knowledge base is the multilingual, fully-verified stand-in.
|
||||
- Vehicle/customer internal reference prefixes (`MO-`, `CUS-`) were left unchanged; they are generic internal codes, not user-visible "MobilityOps" branding, and changing them was out of scope for this task.
|
||||
- Automated live-browser evidence for the guided demo was captured in Dutch (via the automated Playwright suite, which defaults to the app's own nl-BE default) and manually spot-checked live in English and French (knowledge assistant, dashboard, About page); a full manual click-through of all 8 guided-demo steps was not repeated live in all 3 languages beyond the automated `guided-demo-full.spec.ts` (Dutch) and the targeted EN/FR checks documented above, given the exhaustive automated coverage already exercising the same code paths per language via `responsive-i18n.spec.ts` and `i18n-coverage.spec.ts`.
|
||||
|
||||
## Rollback procedure
|
||||
|
||||
- `.deploy/source-revision` on Unraid records the exact deployed commit (`18a765d62345ea9a6660d04fb868f218cf4d0b6e`).
|
||||
- Prior tarballs remain in `.deploy/` on the server, including `.deploy/source-845db14.tar.gz` (feature branch, pre-merge) and `.deploy/source-4a268c7.tar.gz` (previous release, pre-polish).
|
||||
- To roll back: extract the desired `source-<short-sha>.tar.gz`, 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`. Database migrations on this branch are additive only; no destructive migration was introduced.
|
||||
@@ -1,246 +0,0 @@
|
||||
# MobilityOps functional-completion — final summary
|
||||
|
||||
## Outcome: PASS
|
||||
|
||||
All achievable functional-completion requirements were audited, implemented, tested
|
||||
locally (including a genuine clean-checkout drill), committed, pushed, deployed to
|
||||
Unraid and re-verified against the live server after every batch.
|
||||
|
||||
## Revisions
|
||||
|
||||
- Starting branch: `design/mobilityops-premium-ui`
|
||||
- Starting/observed commit: `54dc952915a4874fcdf14781e1c37feb0e253851`
|
||||
- Completion branch: `feat/mobilityops-functional-completion`
|
||||
- Final commit: `5b2827eb7e84e40d97c4d025debb2af1246908e0` (this is the parent commit
|
||||
the deploy below targets; the commit that actually records this string is
|
||||
necessarily one commit later — `git log -1` on this branch is the authoritative
|
||||
source of the true HEAD)
|
||||
- Gitea branch URL: `https://gitea.itworx.tech/Jens/MobilityOps` (SSH remote
|
||||
`ssh://git@192.168.10.150:222/Jens/MobilityOps.git`), branch
|
||||
`feat/mobilityops-functional-completion`
|
||||
- Deployed URL: `http://192.168.10.150:1236`
|
||||
- Server deployment directory: `/mnt/user/appdata/mobilityops`
|
||||
- Server Compose project: `mobilityops`
|
||||
|
||||
## Exact commands executed (representative — run after every batch)
|
||||
|
||||
```bash
|
||||
# Local backend gate
|
||||
docker compose exec -T api python -m app.cli seed --reset
|
||||
docker compose exec -T api pytest -q
|
||||
docker compose run --rm api ruff check .
|
||||
docker compose run --rm api mypy app
|
||||
|
||||
# Local frontend gate
|
||||
cd frontend && npx tsc -b --noEmit && npm run build
|
||||
|
||||
# Local e2e (against the local dev stack)
|
||||
npx playwright test
|
||||
|
||||
# Deploy the exact committed revision
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && tar -xzf .deploy/source.tar.gz && echo $COMMIT > .deploy/source-revision"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current"
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||
|
||||
# e2e against the live server
|
||||
MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236 npx playwright test
|
||||
```
|
||||
|
||||
Clean-checkout drill (once, section 14):
|
||||
|
||||
```bash
|
||||
git clone --branch feat/mobilityops-functional-completion \
|
||||
<repo> /tmp/mobilityops-clean-checkout
|
||||
cd /tmp/mobilityops-clean-checkout
|
||||
cp .env.example .env
|
||||
docker compose -p mobilityops-clean up --build -d # isolated project name/ports
|
||||
docker compose -p mobilityops-clean exec -T api alembic current
|
||||
docker compose -p mobilityops-clean exec -T api python -m app.cli seed --reset
|
||||
docker compose -p mobilityops-clean exec -T api pytest -q
|
||||
docker compose -p mobilityops-clean run --rm api ruff check .
|
||||
docker compose -p mobilityops-clean run --rm api mypy app
|
||||
cd frontend && npm ci && npx tsc -b --noEmit && npm run build
|
||||
MOBILITYOPS_PUBLIC_URL=http://localhost:11228 npx playwright test
|
||||
docker compose -p mobilityops-clean down -v # isolated project only
|
||||
```
|
||||
|
||||
## Test and validation results
|
||||
|
||||
| Gate | Local dev stack | Clean-checkout (isolated) | Live Unraid |
|
||||
|---|---|---|---|
|
||||
| `pytest` | 117 passed | 117 passed | — (not applicable; no test runner on the review host) |
|
||||
| `ruff check .` | clean | clean | — |
|
||||
| `mypy app` | 0 issues / 46 files | 0 issues / 46 files | — |
|
||||
| `npx tsc -b` | clean | clean | — |
|
||||
| `npm run build` | clean | clean | — |
|
||||
| `npx playwright test` | 37 passed | 37 passed | **37 passed** (against `http://192.168.10.150:1236`) |
|
||||
| `npm audit` | 4 known advisories (unchanged — see Known limitations) | same | — |
|
||||
|
||||
Every batch (1 through 5) was deployed and re-verified with the full 37-test Playwright
|
||||
suite against the live server before moving to the next batch, not only at the end.
|
||||
|
||||
## Application URLs and ports
|
||||
|
||||
| Service | URL / port | Notes |
|
||||
|---|---|---|
|
||||
| Web (Unraid) | `http://192.168.10.150:1236` | only MobilityOps-owned service exposed on the LAN |
|
||||
| API (Unraid) | Compose-network only | reached through the web nginx `/api/` proxy |
|
||||
| PostgreSQL (Unraid) | Compose-network only | never exposed |
|
||||
| Shared n8n (Unraid) | `http://192.168.10.150:5678` | pre-existing host infrastructure, outside the MobilityOps Compose project |
|
||||
| Web (local dev) | `http://localhost:1228` | |
|
||||
| API (local dev) | `http://localhost:8128` | |
|
||||
| n8n (local dev) | `http://localhost:5678` | bundled, `bundled-n8n` profile |
|
||||
|
||||
## Demo users and access method
|
||||
|
||||
Two fixed seeded identities, selected via the login screen's role buttons (no
|
||||
password): **Amelie De Ridder** (`USR-OPS`, Operations Manager) and **Karim
|
||||
Boujaddaine** (`USR-EMP`, Rental Employee). `POST /api/v1/demo/login` issues an
|
||||
HttpOnly, `SameSite=Lax` signed session cookie; `GET /api/v1/demo/session` (marked
|
||||
`Cache-Control: no-store`) is what the browser actually trusts on every load, not a
|
||||
locally cached copy.
|
||||
|
||||
## Implemented functionality (this pass, on top of the already-accepted M0–M7/design baseline)
|
||||
|
||||
- Fixed two confirmed defects: Vehicles and Bookings both computed a filtered/paginated
|
||||
result but rendered the raw array.
|
||||
- Server-backed session lifecycle (`GET /demo/session`, `POST /demo/logout`), central
|
||||
401 handling, no more `sessionStorage`-as-authority.
|
||||
- A role matrix enforced server-side (403 on every manager-only action for Rental
|
||||
Employee, not just a hidden button) and mirrored in the nav/route guards.
|
||||
- Authoritative, non-mutating return preview (`POST /bookings/{ref}/return-preview`)
|
||||
sharing its evaluation function with commit — fixed a real bug where the frontend's
|
||||
guessed preview text was wrong (damage → described as "maintenance", actual rule
|
||||
"blocked"; the no-contradiction case → described as "available", actual rule always
|
||||
"cleaning" first).
|
||||
- Audit API/UI now expose `before`/`after` (the columns existed but were never
|
||||
serialized) plus a resolved safe entity link.
|
||||
- Typed related-entity snapshots (booking_overlap's related refs are bookings, not
|
||||
vehicles — previously silently unresolved) and a bounded resolution flow for every
|
||||
one of the five data-quality rule types, plus an audited manual scan trigger and
|
||||
documented recurrence linking (`reopened_from`/`previous_decision`).
|
||||
- Role-aware backend search (`GET /api/v1/search`) replacing a blind client-side regex
|
||||
guesser, with a real results panel, keyboard navigation and debouncing.
|
||||
- A safe, confirmed demo-reset UI trigger (the endpoint already existed and was
|
||||
already gated).
|
||||
- Truthful aggregate n8n integration status (`GET /api/v1/integrations/status`) from
|
||||
outbox delivery counts, replacing a single-most-recent-event read; fixed
|
||||
`MCP_HUB_REGISTRATION_ENABLED` being declared in `.env.example` but never wired into
|
||||
`Settings`.
|
||||
- Bounded outbox delivery-lease recovery for a process crash between claim and outcome.
|
||||
- A second n8n workflow (scheduled quality scan), independent of RAGcore/MCP Hub.
|
||||
|
||||
## RAGcore integration status
|
||||
|
||||
Unchanged from the prior baseline and honestly reported throughout: the demo
|
||||
`KnowledgeProvider` (deterministic TF-IDF extractive retrieval over local procedure
|
||||
documents) satisfies the knowledge-assistant acceptance criteria and is fully verified.
|
||||
A `RAGcoreKnowledgeProvider` HTTP adapter is implemented and unit-tested (including its
|
||||
unavailable-degradation path) but was never exercised against a live RAGcore instance in
|
||||
this environment — no live RAGcore instance exists to test against.
|
||||
`KNOWLEDGE_PROVIDER=demo` on the Unraid deployment; no simulated live connection is ever
|
||||
shown.
|
||||
|
||||
## MCP Hub integration status
|
||||
|
||||
The four read-only provider endpoints are implemented, tested, and directly
|
||||
curl-verified with correct service-token auth enforcement and audit logging.
|
||||
`MCP_HUB_REGISTRATION_ENABLED` — previously declared in `.env.example` but silently
|
||||
dropped by `extra="ignore"` since it had no `Settings` field — is now actually wired in
|
||||
and honestly reported (`GET /api/v1/integrations/status`'s `mcp_hub.state`). It is
|
||||
`false` on the Unraid deployment (`state: "not_configured"`). No live Hub instance was
|
||||
reachable in this environment to verify an actual Hub round trip.
|
||||
|
||||
## n8n integration status
|
||||
|
||||
Fully implemented and live-verified. The original return-processing workflow: verified
|
||||
against both the local bundled instance and the shared Unraid instance, including a real
|
||||
degraded-mode drill in an earlier session (n8n stopped mid-flow → return still committed
|
||||
locally, event stayed `pending` with backoff, self-healed once n8n returned) and the
|
||||
manual-retry path. This pass adds:
|
||||
|
||||
- **Truthful status**: `GET /api/v1/integrations/status` derives n8n health from
|
||||
aggregate outbox counts (pending/delivering/succeeded/failed), not the single most
|
||||
recent event.
|
||||
- **Stale-delivery-lease recovery**: a claimed-but-never-resolved `delivering` row (the
|
||||
process crashing between claim and outcome) is now recoverable; unit-tested including
|
||||
a simulated crash, and confirmed a still-alive worker's unexpired lease is never
|
||||
touched.
|
||||
- **Second workflow**: `mobilityops-scheduled-quality-scan` (hourly + manual-test
|
||||
trigger, ships `"active": false"`), calling `POST
|
||||
/api/v1/integrations/n8n/scheduled-scan`. Live-verified two ways: (1) executed
|
||||
end-to-end via the Manual test trigger against the **local** n8n instance — full
|
||||
green execution in the n8n editor, confirmed by the resulting
|
||||
`data_quality_scan_run` audit event (`actor_type=service`); (2) published to the
|
||||
**shared Unraid n8n** via `deploy/unraid/setup-scheduled-scan.sh` and the resulting
|
||||
endpoint directly curl-verified against the live deployed API, also confirmed via the
|
||||
audit trail. The shared instance's own UI could not be browser-tested directly — it
|
||||
runs `N8N_SECURE_COOKIE=true` and refuses login over the plain-HTTP LAN URL used for
|
||||
automated testing here, which is correct, pre-existing shared-infrastructure
|
||||
behaviour and out of scope to change.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and ITWorx MCP Hub remain honestly not-live-connected — no live instance of
|
||||
either exists in this environment (environment limitation, not a code defect).
|
||||
- `npm audit`: one moderate esbuild/Vite dev-server-only advisory (fix requires a Vite
|
||||
major upgrade, deliberately deferred), and a
|
||||
react-router RSC-mode advisory that doesn't apply (the app never uses RSC/SSR mode) —
|
||||
both pre-existing, confirmed unchanged by this pass's clean `npm ci`.
|
||||
- Demo authentication remains the accepted HMAC-cookie PoC mechanism tied to two fixed
|
||||
seeded identities — not a production identity provider.
|
||||
- The scheduled quality-scan workflow's own n8n-engine execution was verified live
|
||||
against the local bundled n8n and, for the HTTP round trip specifically, against the
|
||||
shared Unraid n8n's resulting API call — not against a full n8n-engine execution *on
|
||||
the shared instance itself*, for the browser-access reason above.
|
||||
- `n8n_delivery_lease_seconds` (120s default) is a code-level tunable, not exposed in
|
||||
`.env.example`, consistent with the existing `n8n_dispatch_interval_seconds`/
|
||||
`n8n_max_attempts`/`n8n_http_timeout_seconds` tunables already handled that way.
|
||||
|
||||
## Clean deployment instructions
|
||||
|
||||
See `deploy/unraid/README.md` and `docs/17-runbook.md` for the full runbook. Redeploy
|
||||
the exact committed revision:
|
||||
|
||||
```bash
|
||||
COMMIT=<commit to deploy>
|
||||
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& tar -xzf .deploy/source.tar.gz \
|
||||
&& echo $COMMIT > .deploy/source-revision \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current \
|
||||
&& curl -fsS http://127.0.0.1:1236/health"
|
||||
```
|
||||
|
||||
`.env` and both named volumes (`mobilityops-db`, `mobilityops-n8n`) are preserved by
|
||||
this flow; nothing outside the `mobilityops` Compose project is touched.
|
||||
|
||||
## Five-minute demonstration flow
|
||||
|
||||
1. Open `http://192.168.10.150:1236`, log in as **Operations Manager**.
|
||||
2. Dashboard: point out the persisted readiness metrics and the now-truthful n8n
|
||||
integration-status card (aggregate counts, not just the latest event).
|
||||
3. Global search (`Ctrl/Cmd+K`): type a vehicle, booking or issue reference; use arrow
|
||||
keys + Enter to navigate; show the no-results state for a nonsense query.
|
||||
4. Bookings: filter by status, page through results (max 25/page), confirm page 2
|
||||
differs from page 1.
|
||||
5. Open an active booking → capture a return with a below-canonical odometer reading →
|
||||
the review step shows the server's authoritative evaluation (odometer regression
|
||||
flagged, resulting status and reason) → confirm → result screen distinguishes local
|
||||
commit success from queued-not-yet-confirmed n8n delivery, links to the created
|
||||
data-quality issue.
|
||||
6. Data quality: run a manual scan; open the newly flagged (or an existing) issue for
|
||||
each rule type and show its dedicated bounded resolution flow (not raw JSON).
|
||||
7. Audit trail: filter by the correlation ID from the return above; show the
|
||||
human-readable before/after change summary, expand the raw-JSON `<details>`.
|
||||
8. Switch role to **Rental Employee**: show Data Quality/Integrations/Audit are absent
|
||||
from the nav, and that direct URL navigation to any of them shows the restricted
|
||||
message rather than partial data or a crash.
|
||||
9. Switch back to Operations Manager, trigger **Reset demo data** with confirmation,
|
||||
land back at login, log in again to confirm deterministic data was restored.
|
||||
@@ -1,207 +0,0 @@
|
||||
# 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.
|
||||
@@ -24,6 +24,9 @@ FROM runtime-base AS test
|
||||
COPY backend/requirements.lock ./requirements.lock
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
COPY backend/tests ./tests
|
||||
COPY contracts ./contracts
|
||||
COPY scripts/check-contracts.py ./scripts/check-contracts.py
|
||||
COPY n8n/workflows ./n8n/workflows
|
||||
USER app
|
||||
|
||||
FROM runtime-base AS runtime
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.schemas import (
|
||||
ReturnPreviewResult,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||
@@ -146,8 +147,14 @@ def create_booking(
|
||||
) -> BookingOut:
|
||||
if body.ends_at <= body.starts_at:
|
||||
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref))
|
||||
if customer is None or customer.merged_into_customer_id is not None:
|
||||
customer = db.scalar(
|
||||
select(Customer).where(Customer.public_ref == body.customer_ref).with_for_update()
|
||||
)
|
||||
if (
|
||||
customer is None
|
||||
or customer.merged_into_customer_id is not None
|
||||
or customer.anonymized_at is not None
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
|
||||
# Serialise booking creation per vehicle. The overlap check must run after
|
||||
# acquiring this lock, otherwise two concurrent requests can both pass it.
|
||||
@@ -179,7 +186,9 @@ def create_booking(
|
||||
status="reserved",
|
||||
start_odometer_km=None,
|
||||
end_odometer_km=None,
|
||||
requirements_complete=body.requirements_complete,
|
||||
# Requirements are intentionally confirmed in a separate, audited action.
|
||||
# Never allow booking creation to bypass that operational checkpoint.
|
||||
requirements_complete=False,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
@@ -225,6 +234,15 @@ def checkout_booking(
|
||||
if active_conflict is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
before_booking = {
|
||||
"status": booking.status,
|
||||
"start_odometer_km": booking.start_odometer_km,
|
||||
}
|
||||
before_vehicle = {
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
attention_reasons: list[str] = []
|
||||
if body.start_odometer_km < vehicle.odometer_km:
|
||||
attention_reasons.append("odometer_regression")
|
||||
@@ -250,6 +268,18 @@ def checkout_booking(
|
||||
completed_by=user.display_name,
|
||||
)
|
||||
db.add(inspection)
|
||||
if "odometer_regression" in attention_reasons:
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=body.start_odometer_km,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="checkout",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
actor_label=user.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if attention_reasons:
|
||||
booking.status = "blocked"
|
||||
vehicle.operational_status = (
|
||||
@@ -269,13 +299,31 @@ def checkout_booking(
|
||||
action="booking_checkout_recorded",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_booking,
|
||||
after={
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"booking_status": booking.status,
|
||||
"start_odometer_km": booking.start_odometer_km,
|
||||
"vehicle_status": vehicle.operational_status,
|
||||
"attention_reasons": attention_reasons,
|
||||
},
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="vehicle_status_changed",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_vehicle,
|
||||
after={
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
},
|
||||
metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref},
|
||||
)
|
||||
db.commit()
|
||||
return CheckoutBookingResult(
|
||||
booking_ref=booking.public_ref,
|
||||
|
||||
@@ -21,6 +21,7 @@ def search_customers(
|
||||
select(Customer)
|
||||
.where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
or_(
|
||||
Customer.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
@@ -224,6 +225,8 @@ _PREFIX_TO_TYPE = {
|
||||
"MO-": "vehicle",
|
||||
"BK-": "booking",
|
||||
"INSP-": "inspection",
|
||||
"MAINT-": "maintenance",
|
||||
"MNT-": "maintenance",
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +295,17 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
"completed_at": inspection.completed_at.isoformat(),
|
||||
"booking_ref": booking.public_ref if booking else None,
|
||||
}
|
||||
if entity_type == "maintenance":
|
||||
record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref))
|
||||
if record is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "maintenance",
|
||||
"public_ref": record.public_ref,
|
||||
"odometer_km": record.odometer_km,
|
||||
"occurred_at": record.occurred_at.isoformat(),
|
||||
"category": record.category,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import func, select
|
||||
@@ -10,7 +11,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine
|
||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
@@ -21,7 +24,6 @@ from app.services.sessions import revoke_session
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
_reset_guard = threading.Lock()
|
||||
_last_reset_monotonic = 0.0
|
||||
_RESET_ADVISORY_LOCK_ID = 706_533_149
|
||||
|
||||
|
||||
@@ -110,7 +112,6 @@ def demo_reset(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
global _last_reset_monotonic
|
||||
if not settings.mobilityops_demo_mode:
|
||||
# Outside demo mode the reset endpoint must not exist at all: it wipes
|
||||
# operational data and replaces it with synthetic records.
|
||||
@@ -122,19 +123,35 @@ def demo_reset(
|
||||
)
|
||||
if not _reset_guard.acquire(blocking=False):
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
replica_lock_connection = None
|
||||
try:
|
||||
elapsed = time.monotonic() - _last_reset_monotonic
|
||||
if _last_reset_monotonic and elapsed < settings.demo_reset_cooldown_seconds:
|
||||
# A session-level lock on its own connection rejects another replica immediately;
|
||||
# the main DB session can then safely end its auth read transaction and wait on
|
||||
# the normal shared/exclusive data barrier without releasing this replica guard.
|
||||
replica_lock_connection = engine.connect()
|
||||
locked = replica_lock_connection.scalar(
|
||||
select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
if not locked:
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
begin_exclusive_demo_reset(db)
|
||||
# The audit timestamp is shared by every replica. A process-local monotonic
|
||||
# timestamp cannot protect a multi-replica deployment.
|
||||
last_reset_at = db.scalar(
|
||||
select(AuditEvent.occurred_at)
|
||||
.where(AuditEvent.action == "demo_reset")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None
|
||||
if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds:
|
||||
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
|
||||
if not locked:
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
result = reset_and_seed(db, preserve_integration_telemetry=True)
|
||||
result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False)
|
||||
integrity = scenario_integrity_report(db)
|
||||
record_audit_event(
|
||||
db,
|
||||
@@ -149,8 +166,15 @@ def demo_reset(
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
_last_reset_monotonic = time.monotonic()
|
||||
end_exclusive_demo_reset(db)
|
||||
finally:
|
||||
if replica_lock_connection is not None:
|
||||
try:
|
||||
replica_lock_connection.scalar(
|
||||
select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
finally:
|
||||
replica_lock_connection.close()
|
||||
_reset_guard.release()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
@@ -51,6 +52,13 @@ def _require_service_token(service_token: str) -> None:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
|
||||
|
||||
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
|
||||
"""Serialize callback check+insert by a stable, transaction-scoped key."""
|
||||
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
|
||||
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||
def workflow_heartbeat(
|
||||
body: N8nHeartbeatIn,
|
||||
@@ -61,6 +69,7 @@ def workflow_heartbeat(
|
||||
_require_service_token(service_token)
|
||||
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
||||
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
||||
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
@@ -109,9 +118,29 @@ def return_callback(
|
||||
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
||||
) from exc
|
||||
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
|
||||
if event is None:
|
||||
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
||||
if body.event_id != event_id:
|
||||
raise AppError(
|
||||
"CALLBACK_EVENT_MISMATCH",
|
||||
"Callback event_id does not match Idempotency-Key.",
|
||||
status_code=409,
|
||||
)
|
||||
try:
|
||||
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AppError(
|
||||
"INVALID_EVENT_CORRELATION",
|
||||
"The stored outbox event has no valid correlation ID.",
|
||||
status_code=409,
|
||||
) from exc
|
||||
if body.correlation_id != expected_correlation_id:
|
||||
raise AppError(
|
||||
"CALLBACK_CORRELATION_MISMATCH",
|
||||
"Callback correlation_id does not match the outbox event.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
||||
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
||||
@@ -131,7 +160,7 @@ def return_callback(
|
||||
actor_label="n8n",
|
||||
action="n8n_return_followup_recorded",
|
||||
entity_type="booking",
|
||||
correlation_id=body.correlation_id,
|
||||
correlation_id=expected_correlation_id,
|
||||
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||
metadata={"event_id": str(event_id)},
|
||||
)
|
||||
@@ -170,6 +199,7 @@ def workflow_error(
|
||||
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||
_require_service_token(service_token)
|
||||
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
@@ -181,19 +211,13 @@ def workflow_error(
|
||||
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,
|
||||
correlation_id=body.correlation_id,
|
||||
after={
|
||||
"workflow_id": body.workflow_id,
|
||||
"workflow_name": body.workflow_name,
|
||||
@@ -248,6 +272,7 @@ def procedures_sync_result(
|
||||
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||
_require_service_token(service_token)
|
||||
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
|
||||
@@ -53,7 +53,9 @@ def ask_question(
|
||||
client_ip = (
|
||||
forwarded.split(",")[-1].strip()
|
||||
if forwarded
|
||||
else request.client.host if request.client else "unknown"
|
||||
else request.client.host
|
||||
if request.client
|
||||
else "unknown"
|
||||
)
|
||||
token = request.cookies.get(settings.session_cookie_name, "")
|
||||
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas import (
|
||||
VehiclePageOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
|
||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||
|
||||
@@ -143,7 +144,7 @@ def list_vehicles(
|
||||
def get_vehicle(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> VehicleDetailOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
|
||||
if vehicle is None:
|
||||
@@ -163,11 +164,21 @@ def get_vehicle(
|
||||
.where(MaintenanceRecord.vehicle_id == vehicle.id)
|
||||
.order_by(MaintenanceRecord.occurred_at.desc())
|
||||
).all()
|
||||
issues = db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
).all()
|
||||
# Detailed data-quality evidence is an operations-manager surface. Employees still
|
||||
# get the operational vehicle record they need, but never receive hidden evidence in
|
||||
# the payload merely because the frontend omits the Quality tab.
|
||||
issues = (
|
||||
db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
).all()
|
||||
if user.role == "operations_manager"
|
||||
else []
|
||||
)
|
||||
|
||||
booking_by_id = {b.id: b.public_ref for b in bookings}
|
||||
next_booking = next(
|
||||
@@ -276,6 +287,12 @@ def create_maintenance_record(
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
correlation_id = uuid.uuid4()
|
||||
before_vehicle = {
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
"next_service_km": vehicle.next_service_km,
|
||||
}
|
||||
record = MaintenanceRecord(
|
||||
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
|
||||
vehicle_id=vehicle.id,
|
||||
@@ -285,6 +302,17 @@ def create_maintenance_record(
|
||||
summary=body.summary.strip(),
|
||||
)
|
||||
db.add(record)
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=record.public_ref,
|
||||
reading_km=body.odometer_km,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="maintenance",
|
||||
related_refs=[record.public_ref],
|
||||
actor_label=user.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
|
||||
if body.next_service_km is not None:
|
||||
if body.next_service_km < vehicle.odometer_km:
|
||||
@@ -301,7 +329,14 @@ def create_maintenance_record(
|
||||
action="maintenance_record_created",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
|
||||
correlation_id=correlation_id,
|
||||
before=before_vehicle,
|
||||
after={
|
||||
"maintenance_ref": record.public_ref,
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
"next_service_km": vehicle.next_service_km,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return MaintenanceOut(
|
||||
|
||||
@@ -33,7 +33,11 @@ class Settings(BaseSettings):
|
||||
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||
n8n_dispatch_enabled: bool = True
|
||||
n8n_dispatch_interval_seconds: float = 3.0
|
||||
n8n_http_timeout_seconds: float = 5.0
|
||||
# The synchronous n8n workflow performs two bounded, retried callbacks before it
|
||||
# acknowledges an event. Keep this above that complete workflow budget, while the
|
||||
# delivery lease remains the wider crash-recovery boundary (enforced by the contract
|
||||
# check in scripts/check-contracts.py).
|
||||
n8n_http_timeout_seconds: float = 15.0
|
||||
n8n_max_attempts: int = 5
|
||||
n8n_delivery_lease_seconds: float = 120.0
|
||||
app_secret: str = "replace-in-production"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event, func, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -10,6 +10,37 @@ settings = get_settings()
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
# Every SQLAlchemy transaction participates in a shared database-wide barrier. Normal
|
||||
# reads/writes coexist; the short demo reset takes the exclusive form so it can never
|
||||
# interleave deletes/inserts with an API request, scanner, callback, or dispatcher cycle.
|
||||
DEMO_DATA_BARRIER_LOCK_ID = 5_344_725_149_212_793_901
|
||||
_EXCLUSIVE_RESET_INFO_KEY = "mobilityops_demo_reset_exclusive"
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_begin")
|
||||
def _acquire_demo_data_barrier(session: Session, _transaction, connection) -> None:
|
||||
if connection.dialect.name != "postgresql":
|
||||
return
|
||||
lock = (
|
||||
func.pg_advisory_xact_lock(DEMO_DATA_BARRIER_LOCK_ID)
|
||||
if session.info.get(_EXCLUSIVE_RESET_INFO_KEY)
|
||||
else func.pg_advisory_xact_lock_shared(DEMO_DATA_BARRIER_LOCK_ID)
|
||||
)
|
||||
connection.execute(select(lock))
|
||||
|
||||
|
||||
def begin_exclusive_demo_reset(db: Session) -> None:
|
||||
"""Make the session's next transaction the exclusive side of the reset barrier."""
|
||||
if db.in_transaction():
|
||||
# Auth normally already read the user under a shared barrier. End that read-only
|
||||
# transaction before requesting exclusive; in-place lock upgrades can deadlock.
|
||||
db.rollback()
|
||||
db.info[_EXCLUSIVE_RESET_INFO_KEY] = True
|
||||
|
||||
|
||||
def end_exclusive_demo_reset(db: Session) -> None:
|
||||
db.info.pop(_EXCLUSIVE_RESET_INFO_KEY, None)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@@ -4,9 +4,13 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||
|
||||
Role = Literal["operations_manager", "rental_employee"]
|
||||
NonBlankOperationalReason = Annotated[
|
||||
str,
|
||||
StringConstraints(strip_whitespace=True, min_length=3, max_length=500),
|
||||
]
|
||||
|
||||
|
||||
class DemoLoginRequest(BaseModel):
|
||||
@@ -93,17 +97,16 @@ class CreateBookingRequest(BaseModel):
|
||||
vehicle_ref: str = Field(min_length=3, max_length=20)
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
requirements_complete: bool = False
|
||||
|
||||
|
||||
class CompleteBookingRequirementsRequest(BaseModel):
|
||||
confirmation: str = Field(min_length=3, max_length=500)
|
||||
confirmation: NonBlankOperationalReason
|
||||
|
||||
|
||||
class RescheduleBookingRequest(BaseModel):
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
reason: NonBlankOperationalReason
|
||||
|
||||
|
||||
class CustomerOptionOut(BaseModel):
|
||||
@@ -122,7 +125,7 @@ class AvailableVehicleOut(BaseModel):
|
||||
|
||||
|
||||
class CancelBookingRequest(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
reason: NonBlankOperationalReason
|
||||
|
||||
|
||||
class CheckoutBookingRequest(BaseModel):
|
||||
@@ -225,7 +228,7 @@ class CreateMaintenanceRequest(BaseModel):
|
||||
|
||||
|
||||
class ReleaseVehicleRequest(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
reason: NonBlankOperationalReason
|
||||
|
||||
|
||||
class DataQualityIssueOut(BaseModel):
|
||||
@@ -295,7 +298,7 @@ class WorkflowErrorReportIn(BaseModel):
|
||||
]
|
||||
error_summary: str = Field(max_length=500)
|
||||
trigger_context: str | None = Field(default=None, max_length=200)
|
||||
correlation_id: str | None = None
|
||||
correlation_id: uuid.UUID | None = None
|
||||
attempt: int = Field(default=1, ge=1, le=1000)
|
||||
retry_action: str | None = Field(default=None, max_length=200)
|
||||
|
||||
@@ -433,7 +436,8 @@ class ReturnCallbackIn(BaseModel):
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
correlation_id: uuid.UUID | None = None
|
||||
event_id: uuid.UUID
|
||||
correlation_id: uuid.UUID
|
||||
follow_up: str | None = Field(default=None, max_length=200)
|
||||
summary: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import delete, insert, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
@@ -201,7 +202,11 @@ def load_seed(db: Session) -> SeedResult:
|
||||
counts["bookings"] = len(booking_rows)
|
||||
|
||||
inspection_rows = []
|
||||
for row in _read_csv("inspections.csv"):
|
||||
inspection_source_rows = _read_csv("inspections.csv")
|
||||
return_inspection_row_by_booking_ref = {
|
||||
row["booking_ref"]: row for row in inspection_source_rows if row["type"] == "return"
|
||||
}
|
||||
for row in inspection_source_rows:
|
||||
inspection_rows.append(
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
@@ -259,16 +264,18 @@ def load_seed(db: Session) -> SeedResult:
|
||||
}
|
||||
|
||||
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]
|
||||
later = return_inspection_row_by_booking_ref[later_ref]
|
||||
earlier = return_inspection_row_by_booking_ref[earlier_ref]
|
||||
return [
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"source_type": "return",
|
||||
"params": {
|
||||
"later_ref": later_ref,
|
||||
"later_km": later["end_odometer_km"],
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": earlier["end_odometer_km"],
|
||||
"later_ref": later["public_ref"],
|
||||
"later_km": int(later["odometer_km"]),
|
||||
"earlier_ref": earlier["public_ref"],
|
||||
"earlier_km": int(earlier["odometer_km"]),
|
||||
"booking_ref": later_ref,
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -354,6 +361,27 @@ def load_seed(db: Session) -> SeedResult:
|
||||
entity_type, entity_id = resolve_entity(row["entity_ref"])
|
||||
related_ref = row.get("related_ref") or ""
|
||||
related_refs = related_ref.split("|") if related_ref else []
|
||||
signals = _seed_signals(row["public_ref"], row["entity_ref"], related_refs)
|
||||
evidence_extra: dict[str, object] = {}
|
||||
if row["rule_type"] == "odometer_regression" and signals:
|
||||
params = signals[0].get("params", {})
|
||||
later_ref = params.get("later_ref")
|
||||
earlier_ref = params.get("earlier_ref")
|
||||
booking_ref = params.get("booking_ref")
|
||||
if (
|
||||
isinstance(earlier_ref, str)
|
||||
and isinstance(later_ref, str)
|
||||
and isinstance(booking_ref, str)
|
||||
):
|
||||
# The authored CSV prose predates structured references. Preserve the
|
||||
# two real source bookings so the detail page can show evidence, while
|
||||
# only the later (regressing) reading is eligible for correction.
|
||||
related_refs = [earlier_ref, booking_ref, later_ref]
|
||||
evidence_extra = {
|
||||
"source_type": signals[0].get("source_type", "return"),
|
||||
"source_types": [signals[0].get("source_type", "return")],
|
||||
"correctable_booking_refs": [booking_ref],
|
||||
}
|
||||
severity_due_delta = {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
@@ -372,7 +400,8 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"summary": row["evidence"],
|
||||
"entity_ref": row["entity_ref"],
|
||||
"related_refs": related_refs,
|
||||
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
|
||||
"signals": signals,
|
||||
**evidence_extra,
|
||||
},
|
||||
"proposed_action_json": {},
|
||||
"detected_at": now,
|
||||
@@ -449,12 +478,23 @@ def load_seed(db: Session) -> SeedResult:
|
||||
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||
|
||||
|
||||
def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
|
||||
def reset_and_seed(
|
||||
db: Session,
|
||||
*,
|
||||
preserve_integration_telemetry: bool = False,
|
||||
commit: bool = True,
|
||||
) -> SeedResult:
|
||||
from app.services.data_quality import run_scan
|
||||
|
||||
if not db.info.get("mobilityops_demo_reset_exclusive"):
|
||||
begin_exclusive_demo_reset(db)
|
||||
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
|
||||
result = load_seed(db)
|
||||
db.commit()
|
||||
scan = run_scan(db)
|
||||
scan = run_scan(db, commit=False)
|
||||
result.counts["data_quality_issues"] += sum(scan.created.values())
|
||||
if commit:
|
||||
db.commit()
|
||||
end_exclusive_demo_reset(db)
|
||||
else:
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,10 +11,22 @@ from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality_common import has_open_issue as _has_open_issue
|
||||
from app.services.data_quality_common import issue_due_at
|
||||
from app.services.data_quality_common import load_open_issue as _load_open_issue
|
||||
from app.services.data_quality_common import new_scan_ref as _new_scan_ref
|
||||
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
|
||||
from app.services.data_quality_odometer import (
|
||||
open_odometer_regression_issue as open_odometer_regression_issue,
|
||||
)
|
||||
from app.services.data_quality_odometer import (
|
||||
resolve_odometer_regression as resolve_odometer_regression,
|
||||
)
|
||||
from app.services.vehicle_status import (
|
||||
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||
VehicleStatusRecommendation,
|
||||
@@ -28,15 +40,6 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
|
||||
|
||||
|
||||
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||
"""Return the local operational SLA deadline for a newly detected issue."""
|
||||
return detected_at + {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
"low": timedelta(days=3),
|
||||
}.get(severity, timedelta(days=1))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
created: dict[str, int] = field(default_factory=dict)
|
||||
@@ -45,25 +48,6 @@ class ScanResult:
|
||||
self.created[rule_type] = self.created.get(rule_type, 0) + 1
|
||||
|
||||
|
||||
def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool:
|
||||
return (
|
||||
db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.rule_type == rule_type,
|
||||
DataQualityIssue.entity_type == entity_type,
|
||||
DataQualityIssue.entity_id == entity_id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _new_scan_ref(prefix: str) -> str:
|
||||
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
|
||||
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
|
||||
|
||||
|
||||
def _open_issue(
|
||||
db: Session,
|
||||
scan: ScanResult,
|
||||
@@ -76,6 +60,7 @@ def _open_issue(
|
||||
entity_ref: str,
|
||||
related_refs: list[str],
|
||||
signals: list[dict] | None = None,
|
||||
evidence_extra: dict | None = None,
|
||||
) -> None:
|
||||
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||
return
|
||||
@@ -103,6 +88,7 @@ def _open_issue(
|
||||
"related_refs": related_refs,
|
||||
"signals": signals or [],
|
||||
}
|
||||
evidence.update(evidence_extra or {})
|
||||
if previous is not None:
|
||||
evidence["reopened_from"] = previous.public_ref
|
||||
evidence["previous_decision"] = previous.status
|
||||
@@ -235,57 +221,110 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
# The seed dataset's vehicle.odometer_km is generated independently of booking
|
||||
# history, so comparing every historical booking against it produces near-universal
|
||||
# false positives. Instead check the booking sequence's own internal consistency:
|
||||
# each vehicle's completed bookings should show a non-decreasing odometer reading.
|
||||
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
||||
for booking in db.scalars(
|
||||
def _scan_odometer_regressions(
|
||||
db: Session,
|
||||
scan: ScanResult,
|
||||
*,
|
||||
actor_label: str | None,
|
||||
actor_type: str,
|
||||
) -> None:
|
||||
# Compare the chronological history with itself rather than every historical reading
|
||||
# to today's canonical value. That detects imported checkout/return/maintenance
|
||||
# regressions without flagging every legitimate older reading.
|
||||
# Runtime checkout/return/maintenance mutations take the vehicle lock before they
|
||||
# inspect or append DQ-03 evidence. Taking the same lock here makes scan-vs-command
|
||||
# check/merge atomic and prevents a partial-unique race for the open issue.
|
||||
vehicles = {
|
||||
v.id: v for v in db.scalars(select(Vehicle).order_by(Vehicle.id).with_for_update()).all()
|
||||
}
|
||||
readings_by_vehicle: dict[uuid.UUID, list[tuple[datetime, str, int, str, str | None]]] = {}
|
||||
inspections = db.scalars(select(Inspection)).all()
|
||||
return_inspection_booking_ids = {
|
||||
inspection.booking_id for inspection in inspections if inspection.type == "return"
|
||||
}
|
||||
returned_bookings = db.scalars(
|
||||
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
|
||||
).all():
|
||||
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
||||
).all()
|
||||
booking_ref_by_id = {booking.id: booking.public_ref for booking in returned_bookings}
|
||||
for booking in returned_bookings:
|
||||
# A real return inspection owns the actual reading timestamp. Using the booking's
|
||||
# planned ends_at as a duplicate second reading can make an early return appear to
|
||||
# go backwards after a newer real inspection. Keep booking data only as the legacy
|
||||
# import fallback when no return inspection exists.
|
||||
if booking.id in return_inspection_booking_ids:
|
||||
continue
|
||||
assert booking.end_odometer_km is not None
|
||||
readings_by_vehicle.setdefault(booking.vehicle_id, []).append(
|
||||
(
|
||||
booking.ends_at,
|
||||
booking.public_ref,
|
||||
booking.end_odometer_km,
|
||||
"booking",
|
||||
booking.public_ref,
|
||||
)
|
||||
)
|
||||
for inspection in inspections:
|
||||
readings_by_vehicle.setdefault(inspection.vehicle_id, []).append(
|
||||
(
|
||||
inspection.completed_at,
|
||||
inspection.public_ref,
|
||||
inspection.odometer_km,
|
||||
inspection.type,
|
||||
(
|
||||
booking_ref_by_id.get(inspection.booking_id)
|
||||
if inspection.type == "return"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
for record in db.scalars(select(MaintenanceRecord)).all():
|
||||
readings_by_vehicle.setdefault(record.vehicle_id, []).append(
|
||||
(record.occurred_at, record.public_ref, record.odometer_km, "maintenance", None)
|
||||
)
|
||||
|
||||
for vehicle_id, bookings in bookings_by_vehicle.items():
|
||||
bookings.sort(key=lambda b: b.ends_at)
|
||||
for earlier, later in zip(bookings, bookings[1:], strict=False):
|
||||
# The query above filters end_odometer_km IS NOT NULL, so both are ints here.
|
||||
assert earlier.end_odometer_km is not None
|
||||
assert later.end_odometer_km is not None
|
||||
if later.end_odometer_km < earlier.end_odometer_km:
|
||||
for vehicle_id, readings in readings_by_vehicle.items():
|
||||
readings.sort(key=lambda reading: (reading[0], reading[1]))
|
||||
highest = readings[0] if readings else None
|
||||
for later in readings[1:]:
|
||||
assert highest is not None
|
||||
if later[2] < highest[2]:
|
||||
vehicle = vehicles[vehicle_id]
|
||||
_open_issue(
|
||||
had_open_issue = _has_open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="odometer_regression",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle_id,
|
||||
severity="medium",
|
||||
summary=(
|
||||
f"Booking {later.public_ref} recorded {later.end_odometer_km} km, "
|
||||
f"below the {earlier.end_odometer_km} km recorded by earlier "
|
||||
f"booking {earlier.public_ref}."
|
||||
),
|
||||
entity_ref=vehicle.public_ref,
|
||||
related_refs=[earlier.public_ref, later.public_ref],
|
||||
signals=[
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"params": {
|
||||
"later_ref": later.public_ref,
|
||||
"later_km": later.end_odometer_km,
|
||||
"earlier_ref": earlier.public_ref,
|
||||
"earlier_km": earlier.end_odometer_km,
|
||||
},
|
||||
}
|
||||
],
|
||||
"odometer_regression",
|
||||
"vehicle",
|
||||
vehicle_id,
|
||||
)
|
||||
break
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=later[1],
|
||||
reading_km=later[2],
|
||||
canonical_km=highest[2],
|
||||
canonical_ref=highest[1],
|
||||
source_type=later[3],
|
||||
related_refs=list(
|
||||
dict.fromkeys(
|
||||
[highest[1], later[1], *([later[4]] if later[4] is not None else [])]
|
||||
)
|
||||
),
|
||||
correctable_booking_refs=[later[4]] if later[4] is not None else [],
|
||||
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||
actor_label=actor_label,
|
||||
actor_type=actor_type,
|
||||
)
|
||||
if issue is not None and not had_open_issue:
|
||||
scan.bump("odometer_regression")
|
||||
if later[2] > highest[2]:
|
||||
highest = later
|
||||
|
||||
|
||||
def run_scan(
|
||||
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
|
||||
db: Session,
|
||||
*,
|
||||
actor_label: str | None = None,
|
||||
actor_type: str = "user",
|
||||
commit: bool = True,
|
||||
) -> ScanResult:
|
||||
# The check-then-insert work below spans several rules. Serialise whole scans at the
|
||||
# database boundary so API and n8n triggers cannot both observe an empty condition.
|
||||
@@ -293,7 +332,12 @@ def run_scan(
|
||||
scan = ScanResult()
|
||||
scan_duplicate_customers(db, scan, _open_issue)
|
||||
_scan_missing_required_fields(db, scan)
|
||||
_scan_odometer_regressions(db, scan)
|
||||
_scan_odometer_regressions(
|
||||
db,
|
||||
scan,
|
||||
actor_label=actor_label,
|
||||
actor_type=actor_type,
|
||||
)
|
||||
_scan_booking_overlaps(db, scan)
|
||||
_scan_vehicle_status_conflicts(db, scan)
|
||||
if actor_label is not None:
|
||||
@@ -305,28 +349,13 @@ def run_scan(
|
||||
entity_type="system",
|
||||
metadata={"created": scan.created},
|
||||
)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return scan
|
||||
|
||||
|
||||
def _load_open_issue(
|
||||
db: Session, public_ref: str, *, lock: bool = True
|
||||
) -> DataQualityIssue:
|
||||
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
issue = db.scalar(statement)
|
||||
if issue is None:
|
||||
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||
if issue.status != "open":
|
||||
raise AppError(
|
||||
"ISSUE_NOT_OPEN",
|
||||
f"Issue is '{issue.status}', not 'open'.",
|
||||
status_code=409,
|
||||
)
|
||||
return issue
|
||||
|
||||
|
||||
def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
issue.status = "deferred"
|
||||
@@ -456,115 +485,6 @@ def provide_missing_fields(
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_odometer_regression(
|
||||
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "odometer_regression":
|
||||
raise AppError(
|
||||
"NOT_AN_ODOMETER_ISSUE",
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
|
||||
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
|
||||
booking: Booking | None = None
|
||||
if body.decision != "retain_canonical":
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||
)
|
||||
if booking is None:
|
||||
raise AppError(
|
||||
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||
)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
|
||||
if body.decision == "retain_canonical":
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_retained",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
|
||||
)
|
||||
else:
|
||||
assert booking is not None and body.corrected_odometer_km is not None
|
||||
# Never silently lower the canonical odometer: a correction must be at or above
|
||||
# the current canonical value, otherwise it would just create a new regression.
|
||||
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||
raise AppError(
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
(
|
||||
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
booking.end_odometer_km = body.corrected_odometer_km
|
||||
vehicle.odometer_km = body.corrected_odometer_km
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_corrected",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
},
|
||||
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
metadata={"decision": body.decision, "note": body.note},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_booking_overlap(
|
||||
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
|
||||
|
||||
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||
"""Return the local operational SLA deadline for a newly detected issue."""
|
||||
return detected_at + {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
"low": timedelta(days=3),
|
||||
}.get(severity, timedelta(days=1))
|
||||
|
||||
|
||||
def has_open_issue(
|
||||
db: Session,
|
||||
rule_type: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> bool:
|
||||
return (
|
||||
db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.rule_type == rule_type,
|
||||
DataQualityIssue.entity_type == entity_type,
|
||||
DataQualityIssue.entity_id == entity_id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def new_scan_ref(prefix: str) -> str:
|
||||
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
|
||||
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
|
||||
|
||||
|
||||
def load_open_issue(db: Session, public_ref: str, *, lock: bool = True) -> DataQualityIssue:
|
||||
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
issue = db.scalar(statement)
|
||||
if issue is None:
|
||||
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||
if issue.status != "open":
|
||||
raise AppError(
|
||||
"ISSUE_NOT_OPEN",
|
||||
f"Issue is '{issue.status}', not 'open'.",
|
||||
status_code=409,
|
||||
)
|
||||
return issue
|
||||
@@ -90,9 +90,7 @@ def scan_duplicate_customers[ScanType: ScanAccumulator](
|
||||
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||
if ratio >= 0.5:
|
||||
score += round(ratio * 30)
|
||||
signals.append(
|
||||
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
|
||||
)
|
||||
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
|
||||
summary_parts.append("similar name")
|
||||
|
||||
if score >= DUPLICATE_THRESHOLD:
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality_common import issue_due_at, load_open_issue, new_scan_ref
|
||||
|
||||
|
||||
def _odometer_fingerprint(
|
||||
*,
|
||||
source_type: str,
|
||||
later_ref: str,
|
||||
later_km: int,
|
||||
) -> dict[str, str | int]:
|
||||
"""Stable identity for one reviewed regression, independent of issue IDs."""
|
||||
return {
|
||||
"source_type": source_type,
|
||||
"later_ref": later_ref,
|
||||
"later_km": later_km,
|
||||
}
|
||||
|
||||
|
||||
def _was_odometer_regression_retained(
|
||||
db: Session,
|
||||
*,
|
||||
vehicle_id: uuid.UUID,
|
||||
fingerprint: dict[str, str | int],
|
||||
) -> bool:
|
||||
reviewed = db.scalars(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.status == "resolved",
|
||||
)
|
||||
).all()
|
||||
return any(
|
||||
issue.evidence_json.get("resolution_decision") == "retain_canonical"
|
||||
and fingerprint in issue.evidence_json.get("retained_odometer_fingerprints", [])
|
||||
for issue in reviewed
|
||||
)
|
||||
|
||||
|
||||
def open_odometer_regression_issue(
|
||||
db: Session,
|
||||
*,
|
||||
vehicle: Vehicle,
|
||||
reading_ref: str,
|
||||
reading_km: int,
|
||||
canonical_km: int,
|
||||
source_type: str,
|
||||
related_refs: list[str],
|
||||
correctable_booking_refs: list[str] | None = None,
|
||||
canonical_ref: str | None = None,
|
||||
detected_at: datetime | None = None,
|
||||
public_ref: str | None = None,
|
||||
actor_label: str | None = None,
|
||||
actor_type: str = "user",
|
||||
correlation_id: uuid.UUID | None = None,
|
||||
) -> DataQualityIssue | None:
|
||||
"""Open one explainable DQ-03 issue while the caller holds the vehicle lock."""
|
||||
if reading_km >= canonical_km:
|
||||
return None
|
||||
earlier_ref = canonical_ref or vehicle.public_ref
|
||||
fingerprint = _odometer_fingerprint(
|
||||
source_type=source_type,
|
||||
later_ref=reading_ref,
|
||||
later_km=reading_km,
|
||||
)
|
||||
# A manager's explicit "retain canonical" decision acknowledges this exact source
|
||||
# fact. Reopening it on every scheduled scan would create churn; only changed or new
|
||||
# evidence (and therefore a different fingerprint) is actionable again.
|
||||
if _was_odometer_regression_retained(
|
||||
db,
|
||||
vehicle_id=vehicle.id,
|
||||
fingerprint=fingerprint,
|
||||
):
|
||||
return None
|
||||
existing = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if existing is not None:
|
||||
before_related_refs = list(existing.evidence_json.get("related_refs", []))
|
||||
before_correctable_refs = list(existing.evidence_json.get("correctable_booking_refs", []))
|
||||
new_signal = {
|
||||
"code": "odometer.regression",
|
||||
"source_type": source_type,
|
||||
"params": {
|
||||
"later_ref": reading_ref,
|
||||
"later_km": reading_km,
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": canonical_km,
|
||||
},
|
||||
}
|
||||
existing_signals = list(existing.evidence_json.get("signals", []))
|
||||
signal_was_new = not any(
|
||||
isinstance(signal, dict)
|
||||
and isinstance(signal.get("params"), dict)
|
||||
and _odometer_fingerprint(
|
||||
source_type=str(
|
||||
signal.get("source_type", existing.evidence_json.get("source_type"))
|
||||
),
|
||||
later_ref=str(signal["params"].get("later_ref")),
|
||||
later_km=signal["params"].get("later_km"),
|
||||
)
|
||||
== fingerprint
|
||||
for signal in existing_signals
|
||||
if isinstance(signal, dict)
|
||||
and isinstance(signal.get("params"), dict)
|
||||
and isinstance(signal["params"].get("later_km"), int)
|
||||
)
|
||||
if signal_was_new:
|
||||
existing_signals.append(new_signal)
|
||||
merged_related_refs = list(
|
||||
dict.fromkeys([*before_related_refs, *(related_refs if signal_was_new else [])])
|
||||
)
|
||||
merged_correctable_refs = list(
|
||||
dict.fromkeys([*before_correctable_refs, *(correctable_booking_refs or [])])
|
||||
)
|
||||
if (
|
||||
not signal_was_new
|
||||
and merged_related_refs == before_related_refs
|
||||
and merged_correctable_refs == before_correctable_refs
|
||||
):
|
||||
return existing
|
||||
source_types = list(existing.evidence_json.get("source_types", []))
|
||||
previous_source_type = existing.evidence_json.get("source_type")
|
||||
if (
|
||||
isinstance(previous_source_type, str)
|
||||
and previous_source_type != "multiple"
|
||||
and previous_source_type not in source_types
|
||||
):
|
||||
source_types.append(previous_source_type)
|
||||
if source_type not in source_types:
|
||||
source_types.append(source_type)
|
||||
existing.evidence_json = {
|
||||
**existing.evidence_json,
|
||||
"summary": (
|
||||
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||
),
|
||||
"related_refs": merged_related_refs,
|
||||
"correctable_booking_refs": merged_correctable_refs,
|
||||
"source_type": source_type if len(source_types) == 1 else "multiple",
|
||||
"source_types": source_types,
|
||||
"signals": existing_signals,
|
||||
}
|
||||
if actor_label is not None and (
|
||||
merged_related_refs != before_related_refs
|
||||
or merged_correctable_refs != before_correctable_refs
|
||||
or signal_was_new
|
||||
):
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_issue_evidence_updated",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=existing.id,
|
||||
correlation_id=correlation_id,
|
||||
before={
|
||||
"related_refs": before_related_refs,
|
||||
"correctable_booking_refs": before_correctable_refs,
|
||||
},
|
||||
after={
|
||||
"related_refs": merged_related_refs,
|
||||
"correctable_booking_refs": merged_correctable_refs,
|
||||
},
|
||||
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||
)
|
||||
return existing
|
||||
now = detected_at or datetime.now(UTC)
|
||||
previous = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status != "open",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
)
|
||||
evidence = {
|
||||
"summary": (
|
||||
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||
),
|
||||
"entity_ref": vehicle.public_ref,
|
||||
"related_refs": related_refs,
|
||||
"correctable_booking_refs": correctable_booking_refs or [],
|
||||
"source_type": source_type,
|
||||
"source_types": [source_type],
|
||||
"signals": [
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"source_type": source_type,
|
||||
"params": {
|
||||
"later_ref": reading_ref,
|
||||
"later_km": reading_km,
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": canonical_km,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
if previous is not None:
|
||||
evidence["reopened_from"] = previous.public_ref
|
||||
evidence["previous_decision"] = previous.status
|
||||
issue = DataQualityIssue(
|
||||
public_ref=public_ref or new_scan_ref("DQ-ODO"),
|
||||
rule_type="odometer_regression",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
severity="medium",
|
||||
status="open",
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=issue_due_at(now, "medium"),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
if actor_label is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_issue_created",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
after={"status": "open", "rule_type": "odometer_regression"},
|
||||
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||
)
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_odometer_regression(
|
||||
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
# Read the routing data without a row lock first. The canonical mutation order is
|
||||
# booking -> vehicle -> issue -> inspection everywhere, matching checkout/return.
|
||||
# Locking the issue before the booking creates a resolver-vs-return deadlock.
|
||||
issue_snapshot = load_open_issue(db, public_ref, lock=False)
|
||||
if issue_snapshot.rule_type != "odometer_regression":
|
||||
raise AppError(
|
||||
"NOT_AN_ODOMETER_ISSUE",
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
def correctable_refs(issue: DataQualityIssue) -> list[str]:
|
||||
if "correctable_booking_refs" in issue.evidence_json:
|
||||
refs = issue.evidence_json.get("correctable_booking_refs", [])
|
||||
else:
|
||||
# Backward compatibility for issues created before source-aware evidence.
|
||||
refs = [
|
||||
ref
|
||||
for ref in issue.evidence_json.get("related_refs", [])
|
||||
if isinstance(ref, str) and ref.startswith("BK-")
|
||||
]
|
||||
return [ref for ref in refs if isinstance(ref, str)]
|
||||
|
||||
booking: Booking | None = None
|
||||
if body.decision != "retain_canonical":
|
||||
if body.booking_ref not in correctable_refs(issue_snapshot):
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||
)
|
||||
if booking is None:
|
||||
raise AppError(
|
||||
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||
)
|
||||
vehicle = db.scalar(
|
||||
select(Vehicle).where(Vehicle.id == issue_snapshot.entity_id).with_for_update()
|
||||
)
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
# A return or scan may have appended evidence while we waited for the domain locks.
|
||||
# Lock and refresh the issue only now, then revalidate every decision against that
|
||||
# current evidence instead of resolving a stale snapshot.
|
||||
issue = load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "odometer_regression" or issue.entity_id != vehicle.id:
|
||||
raise AppError(
|
||||
"ISSUE_CHANGED",
|
||||
"The issue changed while the correction was being prepared. Review it again.",
|
||||
status_code=409,
|
||||
)
|
||||
if booking is not None:
|
||||
if booking.vehicle_id != vehicle.id or booking.public_ref not in correctable_refs(issue):
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
|
||||
if body.decision == "retain_canonical":
|
||||
retained_fingerprints: list[dict[str, str | int]] = []
|
||||
fallback_source_type = str(issue.evidence_json.get("source_type", "unknown"))
|
||||
for signal in issue.evidence_json.get("signals", []):
|
||||
if not isinstance(signal, dict) or signal.get("code") != "odometer.regression":
|
||||
continue
|
||||
params = signal.get("params")
|
||||
if not isinstance(params, dict):
|
||||
continue
|
||||
source_type = signal.get("source_type", fallback_source_type)
|
||||
later_ref = params.get("later_ref")
|
||||
later_km = params.get("later_km")
|
||||
earlier_ref = params.get("earlier_ref")
|
||||
earlier_km = params.get("earlier_km")
|
||||
if (
|
||||
isinstance(source_type, str)
|
||||
and isinstance(later_ref, str)
|
||||
and isinstance(later_km, int)
|
||||
and isinstance(earlier_ref, str)
|
||||
and isinstance(earlier_km, int)
|
||||
):
|
||||
retained_fingerprints.append(
|
||||
_odometer_fingerprint(
|
||||
source_type=source_type,
|
||||
later_ref=later_ref,
|
||||
later_km=later_km,
|
||||
)
|
||||
)
|
||||
issue.evidence_json = {
|
||||
**issue.evidence_json,
|
||||
"resolution_decision": "retain_canonical",
|
||||
"retained_odometer_fingerprints": retained_fingerprints,
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_retained",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
metadata={
|
||||
"issue_ref": issue.public_ref,
|
||||
"canonical_odometer_km": vehicle.odometer_km,
|
||||
"retained_fingerprint_count": len(retained_fingerprints),
|
||||
},
|
||||
)
|
||||
else:
|
||||
assert booking is not None and body.corrected_odometer_km is not None
|
||||
# Never silently lower the canonical odometer: a correction must be at or above
|
||||
# the current canonical value, otherwise it would just create a new regression.
|
||||
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||
raise AppError(
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
(
|
||||
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# A returned booking has two persisted representations of the same reading.
|
||||
# Correct every return inspection for the selected booking, including historical
|
||||
# seed rows whose old issue evidence did not yet cite the inspection explicitly.
|
||||
related_inspections = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.booking_id == booking.id, Inspection.type == "return")
|
||||
.with_for_update()
|
||||
).all()
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
"inspection_odometer_km": {
|
||||
inspection.public_ref: inspection.odometer_km for inspection in related_inspections
|
||||
},
|
||||
}
|
||||
booking.end_odometer_km = body.corrected_odometer_km
|
||||
for inspection in related_inspections:
|
||||
inspection.odometer_km = body.corrected_odometer_km
|
||||
vehicle.odometer_km = body.corrected_odometer_km
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_corrected",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
"inspection_odometer_km": {
|
||||
inspection.public_ref: inspection.odometer_km
|
||||
for inspection in related_inspections
|
||||
},
|
||||
},
|
||||
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||
)
|
||||
|
||||
has_remaining_evidence = False
|
||||
if booking is not None:
|
||||
target_refs = {booking.public_ref, *(item.public_ref for item in related_inspections)}
|
||||
before_related = [
|
||||
ref for ref in issue.evidence_json.get("related_refs", []) if isinstance(ref, str)
|
||||
]
|
||||
before_correctable = correctable_refs(issue)
|
||||
before_signals = [
|
||||
signal for signal in issue.evidence_json.get("signals", []) if isinstance(signal, dict)
|
||||
]
|
||||
remaining_signals = [
|
||||
signal
|
||||
for signal in before_signals
|
||||
if not (
|
||||
isinstance(signal.get("params"), dict)
|
||||
and signal["params"].get("later_ref") in target_refs
|
||||
)
|
||||
]
|
||||
remaining_related = [ref for ref in before_related if ref not in target_refs]
|
||||
remaining_correctable = [ref for ref in before_correctable if ref != booking.public_ref]
|
||||
has_remaining_evidence = bool(remaining_signals or remaining_correctable)
|
||||
issue.evidence_json = {
|
||||
**issue.evidence_json,
|
||||
"summary": (
|
||||
"Additional odometer regression evidence remains for review."
|
||||
if has_remaining_evidence
|
||||
else issue.evidence_json.get("summary", "Odometer reading corrected.")
|
||||
),
|
||||
"related_refs": remaining_related,
|
||||
"correctable_booking_refs": remaining_correctable,
|
||||
"signals": remaining_signals,
|
||||
}
|
||||
if has_remaining_evidence:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_partially_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={
|
||||
"related_refs": before_related,
|
||||
"correctable_booking_refs": before_correctable,
|
||||
"signal_count": len(before_signals),
|
||||
},
|
||||
after={
|
||||
"related_refs": remaining_related,
|
||||
"correctable_booking_refs": remaining_correctable,
|
||||
"signal_count": len(remaining_signals),
|
||||
},
|
||||
metadata={
|
||||
"decision": body.decision,
|
||||
"booking_ref": booking.public_ref,
|
||||
"note": body.note,
|
||||
},
|
||||
)
|
||||
|
||||
if not has_remaining_evidence:
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
metadata={"decision": body.decision, "note": body.note},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
@@ -9,16 +9,28 @@ from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||
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 KnowledgeHealth, get_knowledge_provider
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||
|
||||
|
||||
def _knowledge_scenario_ready(health: KnowledgeHealth) -> bool:
|
||||
"""Require a reachable provider and independently verified indexed documents.
|
||||
|
||||
``source_document_count`` describes local Markdown files, while a sync report only
|
||||
describes an upload attempt. Neither proves that the active provider can retrieve a
|
||||
corpus, so an unavailable verification count remains honestly not ready.
|
||||
"""
|
||||
return bool(
|
||||
health.available and health.document_count is not None and health.document_count > 0
|
||||
)
|
||||
|
||||
|
||||
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
@@ -49,7 +61,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
|
||||
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
|
||||
automation_ready = bool(failed_run and is_demo_scenario_failure(failed_run))
|
||||
knowledge_ready = _knowledge_scenario_ready(knowledge_health)
|
||||
|
||||
return [
|
||||
DemoScenarioOut(
|
||||
@@ -119,8 +132,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
estimated_minutes=2,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path="/knowledge",
|
||||
ready=knowledge_health.available,
|
||||
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
|
||||
ready=knowledge_ready,
|
||||
blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -145,9 +158,11 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
key="ragcore",
|
||||
status_code=(
|
||||
"operational"
|
||||
if knowledge_health.provider == "ragcore" and knowledge_health.available
|
||||
if knowledge_health.provider == "ragcore"
|
||||
and _knowledge_scenario_ready(knowledge_health)
|
||||
else "demoMode"
|
||||
if knowledge_health.provider == "demo" and knowledge_health.available
|
||||
if knowledge_health.provider == "demo"
|
||||
and _knowledge_scenario_ready(knowledge_health)
|
||||
else "unavailable"
|
||||
),
|
||||
detail_code=(
|
||||
@@ -169,9 +184,7 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
# "operational" -- same evidence rule the integration status page uses.
|
||||
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||
detail_code=(
|
||||
"mcpDetailOperational"
|
||||
if mcp_hub.state == "operational"
|
||||
else "mcpDetailPrepared"
|
||||
"mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared"
|
||||
),
|
||||
detail_params={},
|
||||
),
|
||||
|
||||
@@ -77,6 +77,14 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||
for row in rows:
|
||||
row.delivery_status = "delivering"
|
||||
row.next_attempt_at = lease_deadline
|
||||
# The token is stored inside the internal payload (the wire envelope below
|
||||
# explicitly selects only contract fields). It lets the outcome transaction
|
||||
# prove that this is still the same lease after network I/O. A stale worker
|
||||
# must never overwrite a later reclaim/retry or an idempotent callback.
|
||||
row.payload_json = {
|
||||
**row.payload_json,
|
||||
"_delivery_claim_token": str(uuid.uuid4()),
|
||||
}
|
||||
db.commit()
|
||||
return claimed_ids
|
||||
finally:
|
||||
@@ -109,6 +117,9 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
wire_event = None
|
||||
payload_error = f"Malformed outbox payload, missing key {exc}"
|
||||
attempts = event.attempts
|
||||
claim_token = event.payload_json.get("_delivery_claim_token")
|
||||
if event.delivery_status != "delivering" or not isinstance(claim_token, str):
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -130,9 +141,33 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
except ValueError:
|
||||
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"
|
||||
acknowledged = body.get("ok") is True
|
||||
response_event_id = body.get("event_id")
|
||||
event_id_matches = response_event_id == str(event_id)
|
||||
result = body.get("result")
|
||||
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||
execution_id_valid = isinstance(execution_id, str) and bool(execution_id.strip())
|
||||
success = acknowledged and event_id_matches and execution_id_valid
|
||||
if success:
|
||||
error = None
|
||||
error_code = None
|
||||
elif not acknowledged:
|
||||
error = (
|
||||
"n8n response did not explicitly acknowledge the event with ok=true: "
|
||||
f"{body}"
|
||||
)
|
||||
error_code = (
|
||||
"remoteReportedFailure" if body.get("ok") is False else "malformedResponse"
|
||||
)
|
||||
elif not event_id_matches:
|
||||
error = (
|
||||
"n8n acknowledged a different event ID "
|
||||
f"(expected {event_id}, received {response_event_id!r})"
|
||||
)
|
||||
error_code = "mismatchedEventId"
|
||||
else:
|
||||
error = "n8n response omitted a valid result.execution_id"
|
||||
error_code = "malformedResponse"
|
||||
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
|
||||
@@ -152,16 +187,35 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.get(OutboxEvent, event_id)
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
|
||||
)
|
||||
if event is None:
|
||||
return
|
||||
if (
|
||||
event.delivery_status != "delivering"
|
||||
or event.attempts != attempts
|
||||
or event.payload_json.get("_delivery_claim_token") != claim_token
|
||||
):
|
||||
logger.info(
|
||||
"Ignoring stale delivery outcome for event %s because lease ownership changed",
|
||||
event_id,
|
||||
)
|
||||
return
|
||||
event.attempts = attempts + 1
|
||||
event.payload_json = {
|
||||
key: value
|
||||
for key, value in event.payload_json.items()
|
||||
if key != "_delivery_claim_token"
|
||||
}
|
||||
if success:
|
||||
event.delivery_status = "succeeded"
|
||||
event.last_error = None
|
||||
event.last_error_code = None
|
||||
event.next_attempt_at = None
|
||||
event.external_run_id = str((body or {}).get("event_id", event_id))
|
||||
result = (body or {}).get("result")
|
||||
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||
event.external_run_id = execution_id if isinstance(execution_id, str) else None
|
||||
else:
|
||||
event.last_error = (error or "delivery failed")[:2000]
|
||||
event.last_error_code = error_code or "unknownError"
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy import Row, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -93,7 +93,10 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(
|
||||
OutboxEvent.delivery_status == "failed",
|
||||
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
|
||||
or_(
|
||||
OutboxEvent.last_error_code.is_(None),
|
||||
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
|
||||
),
|
||||
)
|
||||
)
|
||||
latest_demo_scenario_at = db.scalar(
|
||||
@@ -106,11 +109,17 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif not settings.n8n_webhook_url:
|
||||
# Persisted history does not make a currently unconfigured dispatcher green.
|
||||
state = "unavailable"
|
||||
elif unexpected_failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif unexpected_failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
# Queued/in-flight work proves only that MobilityOps has work for the dispatcher;
|
||||
# it does not prove that n8n has ever accepted a delivery. A green state requires
|
||||
# at least one persisted successful round trip.
|
||||
elif succeeded > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
@@ -128,8 +137,14 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
# 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")
|
||||
latest_procedure_sync_row = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
latest_procedure_sync_at = (
|
||||
latest_procedure_sync_row[0] if latest_procedure_sync_row is not None else None
|
||||
)
|
||||
|
||||
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||
@@ -198,6 +213,24 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
seen_at = failure_signal[0]
|
||||
last_status = "failed"
|
||||
execution_id = failure_signal[1]
|
||||
if name == "Fleet Ops — RAGcore Procedure Sync" and latest_procedure_sync_row:
|
||||
sync_at, sync_result, sync_metadata = latest_procedure_sync_row
|
||||
synced = (sync_result or {}).get("synced", 0)
|
||||
failed_syncs = (sync_result or {}).get("failed", 0)
|
||||
# The result callback is authoritative for corpus delivery. A generic
|
||||
# succeeded heartbeat cannot turn a zero/partial upload green.
|
||||
sync_result_failed = (
|
||||
not isinstance(synced, int)
|
||||
or not isinstance(failed_syncs, int)
|
||||
or synced <= 0
|
||||
or failed_syncs > 0
|
||||
)
|
||||
if sync_result_failed:
|
||||
last_status = "failed"
|
||||
if seen_at is None or sync_at > seen_at:
|
||||
seen_at = sync_at
|
||||
if sync_result_failed:
|
||||
execution_id = (sync_metadata or {}).get("execution_id")
|
||||
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
|
||||
if seen_at is None:
|
||||
workflow_state = "no_evidence"
|
||||
@@ -265,16 +298,20 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
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
|
||||
|
||||
hub_reachable = _check_hub_reachable()
|
||||
|
||||
state: Literal["not_configured", "no_evidence", "operational"]
|
||||
if not settings.mcp_hub_registration_enabled:
|
||||
state = "not_configured"
|
||||
elif total_calls > 0:
|
||||
elif total_calls > 0 and hub_reachable is not False:
|
||||
state = "operational"
|
||||
else:
|
||||
# Historical tool calls remain useful telemetry, but cannot support a current
|
||||
# operational claim when the configured Hub health endpoint is unreachable.
|
||||
# ``hub_reachable`` stays available separately so consumers can distinguish
|
||||
# this from a provider that simply has no call evidence yet.
|
||||
state = "no_evidence"
|
||||
|
||||
hub_reachable = _check_hub_reachable()
|
||||
|
||||
return McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state=state,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -13,6 +16,10 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
|
||||
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
|
||||
|
||||
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||
_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | {
|
||||
"not_answerable",
|
||||
"conflicting_evidence",
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -47,6 +54,11 @@ _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
|
||||
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
|
||||
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
|
||||
}
|
||||
_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"}
|
||||
|
||||
|
||||
def _normalize_evidence_text(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", value.casefold()))
|
||||
|
||||
|
||||
def _question_concepts(question: str) -> set[str]:
|
||||
@@ -93,7 +105,9 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
|
||||
)
|
||||
|
||||
|
||||
def _retrieval_score(result: dict) -> float | None:
|
||||
def _retrieval_score(result: object) -> float | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
scores = result.get("scores")
|
||||
if not isinstance(scores, dict):
|
||||
return None
|
||||
@@ -124,23 +138,81 @@ class RAGcoreKnowledgeProvider:
|
||||
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.
|
||||
Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for
|
||||
the requested UI language. Returned internal document/version UUIDs are deliberately
|
||||
not treated as Fleet Ops identifiers: every citation must instead prove the complete
|
||||
managed-source chain (source id, URI, locator, checksum and extractive local text).
|
||||
"""
|
||||
|
||||
name = "ragcore"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._settings = get_settings()
|
||||
self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {}
|
||||
for document in iter_procedure_documents(Path(self._settings.knowledge_dir)):
|
||||
self._managed_documents_by_source_id[document.source_id] = document
|
||||
self._verification_cache: dict[str, tuple[float, int]] = {}
|
||||
self._verification_lock = Lock()
|
||||
self._answers_circuit_lock = Lock()
|
||||
self._answers_circuit_open_until = 0.0
|
||||
|
||||
def _managed_source(self, citation: object, language: str) -> SourceCard | None:
|
||||
if not isinstance(citation, dict):
|
||||
return None
|
||||
source_id = citation.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
document = self._managed_documents_by_source_id.get(str(source_id))
|
||||
if document is None or document.language != language:
|
||||
return None
|
||||
if citation.get("locator") != f"{document.document_id}.md":
|
||||
return None
|
||||
if citation.get("source_uri") != f"ragcore://source/{document.source_id}":
|
||||
return None
|
||||
for field_name in ("id", "document_id", "document_version_id"):
|
||||
value = citation.get(field_name)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = uuid.UUID(value)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
if parsed.int == 0:
|
||||
return None
|
||||
if not isinstance(citation.get("title"), str):
|
||||
return None
|
||||
section = citation.get("section")
|
||||
if section is not None and not isinstance(section, str):
|
||||
return None
|
||||
excerpt = citation.get("excerpt")
|
||||
if not isinstance(excerpt, str) or not excerpt.strip():
|
||||
return None
|
||||
expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest()
|
||||
if citation.get("excerpt_sha256") != expected_hash:
|
||||
return None
|
||||
# RAGcore owns chunking, but the cited excerpt must still be extractive evidence
|
||||
# from the authoritative local procedure. Token normalization tolerates Markdown
|
||||
# punctuation/whitespace while rejecting provider text that was never uploaded.
|
||||
normalized_excerpt = _normalize_evidence_text(excerpt)
|
||||
if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text(
|
||||
document.content
|
||||
):
|
||||
return None
|
||||
return SourceCard(
|
||||
document_id=document.document_id,
|
||||
title=document.title,
|
||||
version=document.version,
|
||||
section=section or "",
|
||||
excerpt=excerpt,
|
||||
)
|
||||
|
||||
def _managed_source_ids(self, language: str) -> list[str]:
|
||||
return [
|
||||
document.source_id
|
||||
for document in self._managed_documents_by_source_id.values()
|
||||
if document.language == language
|
||||
]
|
||||
|
||||
def _answers_circuit_is_open(self) -> bool:
|
||||
with self._answers_circuit_lock:
|
||||
return monotonic() < self._answers_circuit_open_until
|
||||
@@ -284,7 +356,7 @@ class RAGcoreKnowledgeProvider:
|
||||
if self._answers_circuit_is_open():
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
|
||||
else:
|
||||
answered = self._ask_via_answers(question, correlation_id)
|
||||
answered = self._ask_via_answers(question, correlation_id, language)
|
||||
if answered is not None:
|
||||
return answered
|
||||
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
|
||||
@@ -295,7 +367,9 @@ class RAGcoreKnowledgeProvider:
|
||||
# generation step.
|
||||
return self._ask_via_search_fallback(question, correlation_id, language)
|
||||
|
||||
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
|
||||
def _ask_via_answers(
|
||||
self, question: str, correlation_id: str, language: 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,
|
||||
@@ -307,6 +381,7 @@ class RAGcoreKnowledgeProvider:
|
||||
json={
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
@@ -320,25 +395,73 @@ class RAGcoreKnowledgeProvider:
|
||||
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()
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError("answer response must be an object")
|
||||
# Validate the provider's complete AnswerResponse contract before trusting
|
||||
# generated prose. These IDs and claim bindings are the evidence that RAGcore
|
||||
# ran its deterministic claim/citation validator; one unrelated but otherwise
|
||||
# valid citation must never make arbitrary answer text appear grounded.
|
||||
uuid.UUID(str(body["answer_id"]))
|
||||
uuid.UUID(str(body["retrieval_run_id"]))
|
||||
raw_citations = body.get("citations", [])
|
||||
if not isinstance(raw_citations, list):
|
||||
raise TypeError("citations must be a list")
|
||||
citation_ids: set[uuid.UUID] = set()
|
||||
for citation in raw_citations:
|
||||
if not isinstance(citation, dict):
|
||||
raise TypeError("citation must be an object")
|
||||
citation_ids.add(uuid.UUID(str(citation["id"])))
|
||||
raw_claims = body["claims"]
|
||||
if not isinstance(raw_claims, list):
|
||||
raise TypeError("claims must be a list")
|
||||
claims_are_bound = bool(raw_claims)
|
||||
answer_text = body.get("answer")
|
||||
for claim in raw_claims:
|
||||
if not isinstance(claim, dict):
|
||||
raise TypeError("claim must be an object")
|
||||
claim_text = claim.get("text")
|
||||
claim_citation_ids = claim.get("citation_ids")
|
||||
if (
|
||||
not isinstance(claim_text, str)
|
||||
or not claim_text.strip()
|
||||
or not isinstance(answer_text, str)
|
||||
or claim_text.strip() not in answer_text
|
||||
or not isinstance(claim_citation_ids, list)
|
||||
or not claim_citation_ids
|
||||
):
|
||||
claims_are_bound = False
|
||||
continue
|
||||
try:
|
||||
bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids}
|
||||
except (TypeError, ValueError):
|
||||
claims_are_bound = False
|
||||
continue
|
||||
if not bound_ids.issubset(citation_ids):
|
||||
claims_are_bound = False
|
||||
mapped_sources = [
|
||||
source
|
||||
for citation in raw_citations
|
||||
if (source := self._managed_source(citation, language)) is not None
|
||||
]
|
||||
sources = _deduplicate_sources(sources)
|
||||
citations_are_managed = len(mapped_sources) == len(raw_citations)
|
||||
sources = _deduplicate_sources(mapped_sources)
|
||||
answerability = body.get("answerability", "not_answerable")
|
||||
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||
if answerability not in _ANSWERABILITY_STATES:
|
||||
raise TypeError("answerability is invalid")
|
||||
if not isinstance(answer_text, str):
|
||||
raise TypeError("answer must be a string")
|
||||
is_grounded = (
|
||||
answerability in _GROUNDED_ANSWERABILITY
|
||||
and bool(sources)
|
||||
and citations_are_managed
|
||||
and claims_are_bound
|
||||
and bool(answer_text.strip())
|
||||
)
|
||||
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
|
||||
self._close_answers_circuit()
|
||||
return GroundedAnswer(
|
||||
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||
answer=answer_text if evidence_state == "grounded" else "",
|
||||
evidence_state=evidence_state,
|
||||
sources=sources if evidence_state == "grounded" else [],
|
||||
provider=self.name,
|
||||
@@ -366,6 +489,7 @@ class RAGcoreKnowledgeProvider:
|
||||
json={
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||
"max_results": 5,
|
||||
},
|
||||
)
|
||||
@@ -378,24 +502,24 @@ class RAGcoreKnowledgeProvider:
|
||||
return unavailable
|
||||
|
||||
try:
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError("search response must be an object")
|
||||
results = body.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
raise TypeError("results must be a list")
|
||||
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"],
|
||||
)
|
||||
source
|
||||
for result in results
|
||||
if isinstance(result, dict)
|
||||
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||
]
|
||||
except (TypeError, KeyError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
|
||||
return unavailable
|
||||
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
score = _retrieval_score(result)
|
||||
if score is not None:
|
||||
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
|
||||
@@ -403,15 +527,11 @@ class RAGcoreKnowledgeProvider:
|
||||
all_sources = _deduplicate_sources(sources)
|
||||
concepts = _question_concepts(question)
|
||||
qualified_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"],
|
||||
)
|
||||
source
|
||||
for result in results
|
||||
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||
if isinstance(result, dict)
|
||||
and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||
]
|
||||
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
||||
if not all_sources:
|
||||
@@ -424,15 +544,17 @@ class RAGcoreKnowledgeProvider:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
damage_evidence = any(
|
||||
term
|
||||
in (
|
||||
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||
).casefold()
|
||||
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
|
||||
evidence_text = " ".join(
|
||||
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||
for source in sources
|
||||
for term in _DOMAIN_CONCEPTS["damage"]
|
||||
)
|
||||
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
|
||||
).casefold()
|
||||
covered_concepts = {
|
||||
concept
|
||||
for concept in required_concepts
|
||||
if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept])
|
||||
}
|
||||
if not required_concepts or not sources or covered_concepts != required_concepts:
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
|
||||
@@ -4,7 +4,7 @@ import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -12,13 +12,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, RegisterReturnRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
|
||||
|
||||
def _new_inspection_ref() -> str:
|
||||
@@ -261,28 +261,21 @@ def register_vehicle_return(
|
||||
|
||||
quality_issue_ref: str | None = None
|
||||
if evaluation.odometer_regression:
|
||||
issue = DataQualityIssue(
|
||||
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||
rule_type="odometer_regression",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
severity="medium",
|
||||
status="open",
|
||||
evidence_json={
|
||||
"summary": (
|
||||
f"Return submitted {body.end_odometer_km} km, below canonical "
|
||||
f"{evaluation.canonical_odometer_km} km."
|
||||
),
|
||||
"entity_ref": vehicle.public_ref,
|
||||
"related_refs": [booking.public_ref, inspection.public_ref],
|
||||
},
|
||||
proposed_action_json={},
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=body.end_odometer_km,
|
||||
canonical_km=evaluation.canonical_odometer_km,
|
||||
source_type="return",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
correctable_booking_refs=[booking.public_ref],
|
||||
detected_at=now,
|
||||
due_at=now + timedelta(days=1),
|
||||
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||
actor_label=actor.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
quality_issue_ref = issue.public_ref
|
||||
quality_issue_ref = issue.public_ref if issue is not None else None
|
||||
|
||||
resulting_status = evaluation.resulting_vehicle_status
|
||||
vehicle.odometer_km = evaluation.resulting_odometer_km
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.db import SessionLocal, engine
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
|
||||
def test_unauthenticated_dashboard_is_rejected(client):
|
||||
response = client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 401
|
||||
@@ -101,15 +113,106 @@ def test_demo_reset_rejects_concurrent_rebuild(ops_client):
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_demo_reset_database_lock_blocks_another_api_replica(ops_client):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
# Use a raw connection: an ordinary Session intentionally participates in the
|
||||
# shared mutation barrier and would make an exclusive reset wait before it can test
|
||||
# this separate non-blocking replica lock.
|
||||
with engine.connect() as lock_connection:
|
||||
lock_connection.scalar(select(func.pg_advisory_lock(demo_router._RESET_ADVISORY_LOCK_ID)))
|
||||
try:
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
finally:
|
||||
lock_connection.scalar(
|
||||
select(func.pg_advisory_unlock(demo_router._RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_demo_reset_waits_for_active_mutation_and_then_replaces_it_atomically():
|
||||
started = threading.Event()
|
||||
completed = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def reset_in_other_session() -> None:
|
||||
started.set()
|
||||
try:
|
||||
with SessionLocal() as reset_db:
|
||||
reset_and_seed(reset_db)
|
||||
except BaseException as exc: # pragma: no cover - assertion reports thread failures
|
||||
errors.append(exc)
|
||||
finally:
|
||||
completed.set()
|
||||
|
||||
with SessionLocal() as mutation_db:
|
||||
vehicle = mutation_db.scalar(
|
||||
select(Vehicle).where(Vehicle.public_ref == "MO-001").with_for_update()
|
||||
)
|
||||
vehicle.location = "Concurrent mutation marker"
|
||||
mutation_db.flush()
|
||||
worker = threading.Thread(target=reset_in_other_session, daemon=True)
|
||||
worker.start()
|
||||
assert started.wait(timeout=1)
|
||||
assert not completed.wait(timeout=0.2)
|
||||
mutation_db.commit()
|
||||
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive(), "reset deadlocked behind the active mutation"
|
||||
assert errors == []
|
||||
with SessionLocal() as db:
|
||||
restored = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-001"))
|
||||
assert restored.location != "Concurrent mutation marker"
|
||||
|
||||
|
||||
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
|
||||
monkeypatch.setattr(demo_router, "_last_reset_monotonic", demo_router.time.monotonic())
|
||||
with SessionLocal() as db:
|
||||
cooldown_event = record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label="Cooldown test",
|
||||
action="demo_reset",
|
||||
entity_type="system",
|
||||
)
|
||||
db.commit()
|
||||
cooldown_event_id = cooldown_event.id
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 429
|
||||
assert int(response.headers["retry-after"]) >= 1
|
||||
monkeypatch.setattr(demo_router, "_last_reset_monotonic", 0.0)
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.id == cooldown_event_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_demo_reset_rolls_back_every_change_when_integrity_check_fails(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 0)
|
||||
with SessionLocal() as db:
|
||||
probe = record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="Atomic reset test",
|
||||
action="reset_atomicity_probe",
|
||||
entity_type="system",
|
||||
)
|
||||
db.commit()
|
||||
probe_id = probe.id
|
||||
|
||||
def fail_integrity_check(_db):
|
||||
raise RuntimeError("Injected integrity-check failure")
|
||||
|
||||
monkeypatch.setattr(demo_router, "scenario_integrity_report", fail_integrity_check)
|
||||
with pytest.raises(RuntimeError, match="Injected integrity-check failure"):
|
||||
ops_client.post("/api/v1/demo/reset")
|
||||
|
||||
with SessionLocal() as db:
|
||||
assert db.scalar(select(AuditEvent).where(AuditEvent.id == probe_id)) is not None
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.id == probe_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||
|
||||
@@ -95,6 +95,16 @@ def test_booking_requirements_are_explicit_and_audited(ops_client):
|
||||
)
|
||||
assert checkout.status_code == 409
|
||||
|
||||
whitespace_only = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": " "},
|
||||
)
|
||||
assert whitespace_only.status_code == 422
|
||||
assert (
|
||||
ops_client.get(f"/api/v1/bookings/{booking['public_ref']}").json()["requirements_complete"]
|
||||
is False
|
||||
)
|
||||
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
@@ -106,6 +116,29 @@ def test_booking_requirements_are_explicit_and_audited(ops_client):
|
||||
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
|
||||
|
||||
|
||||
def test_booking_creation_cannot_preconfirm_requirements(ops_client):
|
||||
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
|
||||
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||
|
||||
created = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
assert created.json()["requirements_complete"] is False
|
||||
audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "booking_requirements_completed"},
|
||||
)
|
||||
assert all(item["entity_ref"] != created.json()["public_ref"] for item in audit.json())
|
||||
|
||||
|
||||
def test_customer_search_returns_canonical_customers(ops_client):
|
||||
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
|
||||
assert response.status_code == 200
|
||||
@@ -221,10 +254,14 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
response = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
@@ -241,6 +278,95 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert updated_vehicle["operational_status"] == "rented"
|
||||
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
|
||||
checkout_audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "booking_checkout_recorded"},
|
||||
).json()
|
||||
booking_event = next(
|
||||
item for item in checkout_audit if item["entity_ref"] == booking["public_ref"]
|
||||
)
|
||||
vehicle_audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={
|
||||
"action": "vehicle_status_changed",
|
||||
"correlation_id": booking_event["correlation_id"],
|
||||
},
|
||||
).json()
|
||||
assert len(vehicle_audit) == 1
|
||||
assert vehicle_audit[0]["entity_ref"] == vehicle["public_ref"]
|
||||
assert vehicle_audit[0]["before"]["operational_status"] == "available"
|
||||
assert vehicle_audit[0]["after"]["operational_status"] == "rented"
|
||||
|
||||
|
||||
def test_checkout_odometer_regression_keeps_canonical_and_opens_bounded_quality_issue(
|
||||
ops_client,
|
||||
):
|
||||
window = {"starts_at": "2052-09-01T10:00:00Z", "ends_at": "2052-09-02T12:00:00Z"}
|
||||
existing_issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
vehicle_option = next(
|
||||
item
|
||||
for item in available
|
||||
if item["operational_status"] == "available" and item["public_ref"] not in unavailable_refs
|
||||
)
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
assert vehicle["odometer_km"] > 0
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0003",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"] - 1,
|
||||
"fuel_level_percent": 90,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert checkout.status_code == 200
|
||||
assert checkout.json()["booking_status"] == "blocked"
|
||||
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert updated_vehicle["odometer_km"] == vehicle["odometer_km"]
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
|
||||
assert issue["evidence"]["source_type"] == "checkout"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
assert any(snapshot["entity_type"] == "inspection" for snapshot in detail["related_snapshots"])
|
||||
invalid_correction = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking["public_ref"],
|
||||
"corrected_odometer_km": vehicle["odometer_km"],
|
||||
},
|
||||
)
|
||||
assert invalid_correction.status_code == 422
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
assert retained.json()["status"] == "resolved"
|
||||
|
||||
|
||||
def test_get_booking_detail(ops_client):
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
def test_dashboard_metrics_are_persisted_counts(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
@@ -15,6 +21,43 @@ def test_dashboard_metrics_are_persisted_counts(ops_client):
|
||||
assert metrics["pending_or_failed_workflows"] >= 1
|
||||
|
||||
|
||||
def test_dashboard_metrics_change_when_persisted_fleet_data_changes(ops_client):
|
||||
before = ops_client.get("/api/v1/dashboard").json()["metrics"]
|
||||
db = SessionLocal()
|
||||
probe = Vehicle(
|
||||
public_ref="MO-METRIC-PROBE",
|
||||
make="Synthetic",
|
||||
model="Metric probe",
|
||||
model_year=2026,
|
||||
registration_number="TEST-METRIC-PROBE",
|
||||
location="Test fixture",
|
||||
operational_status="blocked",
|
||||
odometer_km=0,
|
||||
next_service_km=1,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
try:
|
||||
db.add(probe)
|
||||
db.commit()
|
||||
|
||||
after_response = ops_client.get("/api/v1/dashboard")
|
||||
assert after_response.status_code == 200
|
||||
after = after_response.json()["metrics"]
|
||||
assert after["blocked"] == before["blocked"] + 1
|
||||
fleet_statuses = ("available", "rented", "cleaning", "maintenance", "blocked")
|
||||
assert (
|
||||
sum(after[status] for status in fleet_statuses)
|
||||
== sum(before[status] for status in fleet_statuses) + 1
|
||||
)
|
||||
finally:
|
||||
persisted_probe = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-METRIC-PROBE"))
|
||||
if persisted_probe is not None:
|
||||
db.delete(persisted_probe)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_dashboard_attention_items_link_to_records(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
from sqlalchemy import select
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import Barrier
|
||||
|
||||
from sqlalchemy import delete, select, text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, RegisterReturnRequest, ResolveOdometerRegressionRequest
|
||||
from app.services.data_quality import (
|
||||
open_odometer_regression_issue,
|
||||
resolve_odometer_regression,
|
||||
)
|
||||
from app.services.returns import register_vehicle_return
|
||||
|
||||
|
||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
@@ -21,6 +38,30 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
db.close()
|
||||
|
||||
|
||||
def _cleanup_odometer_scenario(vehicle_id, customer_id) -> None:
|
||||
"""Remove one isolated DQ-03 scenario in foreign-key-safe order."""
|
||||
with SessionLocal() as db:
|
||||
booking_ids = db.scalars(select(Booking.id).where(Booking.vehicle_id == vehicle_id)).all()
|
||||
issue_ids = db.scalars(
|
||||
select(DataQualityIssue.id).where(DataQualityIssue.entity_id == vehicle_id)
|
||||
).all()
|
||||
audit_entity_ids = [vehicle_id, *booking_ids, *issue_ids]
|
||||
if audit_entity_ids:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id.in_(audit_entity_ids)))
|
||||
if booking_ids:
|
||||
db.execute(
|
||||
delete(IdempotencyRecord).where(IdempotencyRecord.booking_id.in_(booking_ids))
|
||||
)
|
||||
db.execute(delete(OutboxEvent).where(OutboxEvent.aggregate_id.in_(booking_ids)))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_list_includes_all_five_rule_types(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues")
|
||||
assert response.status_code == 200
|
||||
@@ -43,6 +84,84 @@ def test_scan_is_idempotent_once_seeded(ops_client):
|
||||
assert response.json()["created"] == {}
|
||||
|
||||
|
||||
def test_scan_detects_imported_maintenance_odometer_regression(ops_client):
|
||||
with SessionLocal() as db:
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-SCAN",
|
||||
make="Synthetic",
|
||||
model="Scanner",
|
||||
model_year=2026,
|
||||
registration_number="DQ-SCAN-01",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.flush()
|
||||
vehicle_id = vehicle.id
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-A",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2045, 1, 1, tzinfo=UTC),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic scan baseline",
|
||||
),
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-B",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2045, 2, 1, tzinfo=UTC),
|
||||
odometer_km=19_000,
|
||||
category="inspection",
|
||||
summary="Synthetic imported regression",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
response = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created"]["odometer_regression"] >= 1
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == "MO-DQ-SCAN")
|
||||
assert issue["evidence"]["source_type"] == "maintenance"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
|
||||
# A later imported regression must enrich the existing open issue instead of
|
||||
# being silently dropped by the generic check-then-return scanner path.
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-C",
|
||||
vehicle_id=vehicle_id,
|
||||
occurred_at=datetime(2045, 3, 1, tzinfo=UTC),
|
||||
odometer_km=18_000,
|
||||
category="inspection",
|
||||
summary="Second synthetic imported regression",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
rescanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert rescanned.status_code == 200
|
||||
enriched = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
later_refs = {signal["params"]["later_ref"] for signal in enriched["evidence"]["signals"]}
|
||||
assert {"MAINT-DQ-SCAN-B", "MAINT-DQ-SCAN-C"}.issubset(later_refs)
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_scan_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 403
|
||||
@@ -440,9 +559,8 @@ def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then
|
||||
|
||||
|
||||
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
|
||||
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
|
||||
# Create a fresh one with a real related booking via a live regression return, so
|
||||
# the "correct_reading" path has an actual booking_ref to target.
|
||||
# Create a live return regression to prove the complete correction chain updates
|
||||
# both persisted representations of that reading (booking + return inspection).
|
||||
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||
low_reading = vehicle_before["odometer_km"] - 200
|
||||
@@ -459,6 +577,7 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
|
||||
)
|
||||
assert returned.status_code == 201
|
||||
issue_ref = returned.json()["quality_issue_ref"]
|
||||
inspection_ref = returned.json()["inspection_ref"]
|
||||
assert issue_ref is not None
|
||||
|
||||
corrected = vehicle_before["odometer_km"] + 500
|
||||
@@ -477,6 +596,701 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
|
||||
assert vehicle["odometer_km"] == corrected
|
||||
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||
assert booking["end_odometer_km"] == corrected
|
||||
with SessionLocal() as db:
|
||||
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == inspection_ref))
|
||||
assert inspection is not None
|
||||
assert inspection.odometer_km == corrected
|
||||
|
||||
|
||||
def test_correcting_one_of_two_regressions_keeps_the_other_open(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-MULTI",
|
||||
first_name="Synthetic",
|
||||
last_name="Multi",
|
||||
email="dq-multi@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-MULTI",
|
||||
make="Synthetic",
|
||||
model="Multi",
|
||||
model_year=2026,
|
||||
registration_number="DQ-MULTI",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=1_000,
|
||||
next_service_km=2_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
bookings: list[Booking] = []
|
||||
inspections: list[Inspection] = []
|
||||
for index, reading in enumerate((900, 800), start=1):
|
||||
booking = Booking(
|
||||
public_ref=f"BK-DQ-MULTI-{index}",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2046, index, 1, tzinfo=UTC),
|
||||
ends_at=datetime(2046, index, 2, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=reading - 10,
|
||||
end_odometer_km=reading,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
inspection = Inspection(
|
||||
public_ref=f"INSP-DQ-M-{index}",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=reading,
|
||||
completed_at=booking.ends_at,
|
||||
)
|
||||
db.add(inspection)
|
||||
db.flush()
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=reading,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="return",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
correctable_booking_refs=[booking.public_ref],
|
||||
public_ref="DQ-MULTI-SOURCE" if index == 1 else None,
|
||||
)
|
||||
bookings.append(booking)
|
||||
inspections.append(inspection)
|
||||
booking_refs = [booking.public_ref for booking in bookings]
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
first = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking_refs[0],
|
||||
"corrected_odometer_km": 1_100,
|
||||
},
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.json()["status"] == "open"
|
||||
assert first.json()["evidence"]["correctable_booking_refs"] == [booking_refs[1]]
|
||||
|
||||
second = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking_refs[1],
|
||||
"corrected_odometer_km": 1_200,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["status"] == "resolved"
|
||||
with SessionLocal() as db:
|
||||
persisted = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.vehicle_id == vehicle_id)
|
||||
.order_by(Inspection.public_ref)
|
||||
).all()
|
||||
assert [item.odometer_km for item in persisted] == [1_100, 1_200]
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_retained_regression_is_suppressed_until_source_reading_changes(ops_client):
|
||||
with SessionLocal() as db:
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-RETAIN",
|
||||
make="Synthetic",
|
||||
model="Retain",
|
||||
model_year=2026,
|
||||
registration_number="DQ-RETAIN",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.flush()
|
||||
vehicle_id = vehicle.id
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-RET-A",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2047, 1, 1, tzinfo=UTC),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Retain baseline",
|
||||
),
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-RET-B",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2047, 2, 1, tzinfo=UTC),
|
||||
odometer_km=19_000,
|
||||
category="inspection",
|
||||
summary="Retained source reading",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
issue = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-RETAIN"
|
||||
)
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical", "note": "Verified source entry"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
assert retained.json()["status"] == "resolved"
|
||||
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
with SessionLocal() as db:
|
||||
assert (
|
||||
db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
changed = db.scalar(
|
||||
select(MaintenanceRecord).where(MaintenanceRecord.public_ref == "MAINT-DQ-RET-B")
|
||||
)
|
||||
changed.odometer_km = 18_999
|
||||
db.commit()
|
||||
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
reopened = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-RETAIN"
|
||||
)
|
||||
assert reopened["evidence"]["reopened_from"] == issue["public_ref"]
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_live_return_retained_as_canonical_is_not_reopened_by_scan(ops_client):
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-LIVE-RET",
|
||||
first_name="Synthetic",
|
||||
last_name="Retained return",
|
||||
email="dq-live-retain@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-LIVE-RET",
|
||||
make="Synthetic",
|
||||
model="Retained return",
|
||||
model_year=2026,
|
||||
registration_number="DQ-LIVE-RET",
|
||||
location="Brussels",
|
||||
operational_status="rented",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
booking = Booking(
|
||||
public_ref="BK-DQ-LIVE-RET",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=2),
|
||||
ends_at=now + timedelta(days=1),
|
||||
status="active",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=None,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
booking,
|
||||
MaintenanceRecord(
|
||||
public_ref="MNT-DQ-LIVE-RET",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=now - timedelta(days=3),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic canonical baseline",
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
returned = ops_client.post(
|
||||
"/api/v1/bookings/BK-DQ-LIVE-RET/return",
|
||||
json={
|
||||
"end_odometer_km": 19_000,
|
||||
"fuel_level_percent": 50,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
headers={"Idempotency-Key": "test-dq-live-retain-001"},
|
||||
)
|
||||
assert returned.status_code == 201, returned.text
|
||||
issue_ref = returned.json()["quality_issue_ref"]
|
||||
inspection_ref = returned.json()["inspection_ref"]
|
||||
assert issue_ref is not None
|
||||
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical", "note": "Source reading verified as wrong."},
|
||||
)
|
||||
assert retained.status_code == 200, retained.text
|
||||
assert retained.json()["status"] == "resolved"
|
||||
assert retained.json()["evidence"]["retained_odometer_fingerprints"] == [
|
||||
{
|
||||
"source_type": "return",
|
||||
"later_ref": inspection_ref,
|
||||
"later_km": 19_000,
|
||||
}
|
||||
]
|
||||
|
||||
scanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scanned.status_code == 200, scanned.text
|
||||
with SessionLocal() as db:
|
||||
issues = db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at)
|
||||
).all()
|
||||
assert [(issue.public_ref, issue.status) for issue in issues] == [
|
||||
(issue_ref, "resolved")
|
||||
]
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_scanned_return_regression_can_correct_booking_all_inspections_and_vehicle(ops_client):
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-SCAN-RET",
|
||||
first_name="Synthetic",
|
||||
last_name="Scanned return",
|
||||
email="dq-scan-return@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-SCAN-RET",
|
||||
make="Synthetic",
|
||||
model="Scanned return",
|
||||
model_year=2026,
|
||||
registration_number="DQ-SCAN-RET",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
booking = Booking(
|
||||
public_ref="BK-DQ-SCAN-RET",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=3),
|
||||
ends_at=now - timedelta(hours=12),
|
||||
status="returned",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=18_500,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MNT-DQ-SCAN-RET",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=now - timedelta(days=4),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic canonical baseline",
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-SCAN-R1",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=19_000,
|
||||
completed_at=now - timedelta(days=1),
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-SCAN-R2",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=18_500,
|
||||
completed_at=now - timedelta(hours=12),
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
scanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scanned.status_code == 200, scanned.text
|
||||
issue = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-SCAN-RET"
|
||||
)
|
||||
assert issue["evidence"]["correctable_booking_refs"] == ["BK-DQ-SCAN-RET"]
|
||||
later_refs = {signal["params"]["later_ref"] for signal in issue["evidence"]["signals"]}
|
||||
assert later_refs == {"INSP-DQ-SCAN-R1", "INSP-DQ-SCAN-R2"}
|
||||
|
||||
corrected = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": "BK-DQ-SCAN-RET",
|
||||
"corrected_odometer_km": 20_500,
|
||||
},
|
||||
)
|
||||
assert corrected.status_code == 200, corrected.text
|
||||
assert corrected.json()["status"] == "resolved"
|
||||
|
||||
with SessionLocal() as db:
|
||||
persisted_vehicle = db.get(Vehicle, vehicle_id)
|
||||
persisted_booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == "BK-DQ-SCAN-RET")
|
||||
)
|
||||
assert persisted_booking is not None
|
||||
persisted_inspections = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.booking_id == persisted_booking.id, Inspection.type == "return")
|
||||
.order_by(Inspection.public_ref)
|
||||
).all()
|
||||
assert persisted_vehicle is not None
|
||||
assert persisted_vehicle.odometer_km == 20_500
|
||||
assert persisted_booking.end_odometer_km == 20_500
|
||||
assert [inspection.odometer_km for inspection in persisted_inspections] == [
|
||||
20_500,
|
||||
20_500,
|
||||
]
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_resolver_and_concurrent_return_finish_without_deadlock_or_lost_evidence():
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-RACE",
|
||||
first_name="Synthetic",
|
||||
last_name="Race",
|
||||
email="dq-race@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-RACE",
|
||||
make="Synthetic",
|
||||
model="Race",
|
||||
model_year=2026,
|
||||
registration_number="DQ-RACE",
|
||||
location="Brussels",
|
||||
operational_status="rented",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
corrected_booking = Booking(
|
||||
public_ref="BK-DQ-RACE-OLD",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=4),
|
||||
ends_at=now - timedelta(days=3),
|
||||
status="returned",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=19_000,
|
||||
requirements_complete=True,
|
||||
)
|
||||
concurrent_booking = Booking(
|
||||
public_ref="BK-DQ-RACE-NEW",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=1),
|
||||
ends_at=now + timedelta(days=1),
|
||||
status="active",
|
||||
start_odometer_km=19_000,
|
||||
end_odometer_km=None,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all([corrected_booking, concurrent_booking])
|
||||
db.flush()
|
||||
old_inspection = Inspection(
|
||||
public_ref="INSP-DQ-RACE-OLD",
|
||||
booking_id=corrected_booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=19_000,
|
||||
completed_at=corrected_booking.ends_at,
|
||||
)
|
||||
db.add(old_inspection)
|
||||
db.flush()
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=old_inspection.public_ref,
|
||||
reading_km=19_000,
|
||||
canonical_km=20_000,
|
||||
source_type="return",
|
||||
related_refs=[corrected_booking.public_ref, old_inspection.public_ref],
|
||||
correctable_booking_refs=[corrected_booking.public_ref],
|
||||
public_ref="DQ-ODO-RACE",
|
||||
)
|
||||
assert issue is not None
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-DQ-RACE",
|
||||
display_name="DQ Race Manager",
|
||||
role="operations_manager",
|
||||
)
|
||||
start = Barrier(2)
|
||||
|
||||
def correct_existing_reading() -> str:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SET LOCAL lock_timeout = '5s'"))
|
||||
start.wait(timeout=5)
|
||||
resolved = resolve_odometer_regression(
|
||||
db,
|
||||
"DQ-ODO-RACE",
|
||||
ResolveOdometerRegressionRequest(
|
||||
decision="correct_reading",
|
||||
booking_ref="BK-DQ-RACE-OLD",
|
||||
corrected_odometer_km=20_500,
|
||||
),
|
||||
actor,
|
||||
)
|
||||
return resolved.status
|
||||
|
||||
def return_other_booking() -> dict:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SET LOCAL lock_timeout = '5s'"))
|
||||
start.wait(timeout=5)
|
||||
_status, response = register_vehicle_return(
|
||||
db,
|
||||
"BK-DQ-RACE-NEW",
|
||||
RegisterReturnRequest(
|
||||
end_odometer_km=18_000,
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
),
|
||||
"test-dq-resolve-return-race-001",
|
||||
actor,
|
||||
)
|
||||
return response
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
correction_future = executor.submit(correct_existing_reading)
|
||||
return_future = executor.submit(return_other_booking)
|
||||
correction_status = correction_future.result(timeout=15)
|
||||
return_result = return_future.result(timeout=15)
|
||||
|
||||
assert correction_status in {"open", "resolved"}
|
||||
assert return_result["quality_issue_ref"] is not None
|
||||
with SessionLocal() as db:
|
||||
persisted_vehicle = db.get(Vehicle, vehicle_id)
|
||||
bookings = {
|
||||
booking.public_ref: booking
|
||||
for booking in db.scalars(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle_id)
|
||||
).all()
|
||||
}
|
||||
old_inspections = db.scalars(
|
||||
select(Inspection).where(Inspection.booking_id == bookings["BK-DQ-RACE-OLD"].id)
|
||||
).all()
|
||||
open_issues = db.scalars(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
).all()
|
||||
assert persisted_vehicle is not None
|
||||
assert persisted_vehicle.odometer_km == 20_500
|
||||
assert bookings["BK-DQ-RACE-OLD"].end_odometer_km == 20_500
|
||||
assert [inspection.odometer_km for inspection in old_inspections] == [20_500]
|
||||
assert bookings["BK-DQ-RACE-NEW"].status == "returned"
|
||||
assert bookings["BK-DQ-RACE-NEW"].end_odometer_km == 18_000
|
||||
assert len(open_issues) == 1
|
||||
assert open_issues[0].evidence_json["correctable_booking_refs"] == ["BK-DQ-RACE-NEW"]
|
||||
later_refs = {
|
||||
signal["params"]["later_ref"] for signal in open_issues[0].evidence_json["signals"]
|
||||
}
|
||||
assert later_refs == {return_result["inspection_ref"]}
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_early_return_uses_inspection_time_without_duplicate_booking_regression(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-EARLY",
|
||||
first_name="Synthetic",
|
||||
last_name="Early",
|
||||
email="dq-early@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-EARLY",
|
||||
make="Synthetic",
|
||||
model="Early",
|
||||
model_year=2026,
|
||||
registration_number="DQ-EARLY",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=200,
|
||||
next_service_km=10_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
early = Booking(
|
||||
public_ref="BK-DQ-EARLY-A",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2048, 1, 1, tzinfo=UTC),
|
||||
ends_at=datetime(2048, 3, 1, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=90,
|
||||
end_odometer_km=100,
|
||||
requirements_complete=True,
|
||||
)
|
||||
later = Booking(
|
||||
public_ref="BK-DQ-EARLY-B",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2048, 1, 10, tzinfo=UTC),
|
||||
ends_at=datetime(2048, 2, 1, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=100,
|
||||
end_odometer_km=200,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all([early, later])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-EARLY-A",
|
||||
booking_id=early.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=100,
|
||||
completed_at=datetime(2048, 1, 2, tzinfo=UTC),
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-EARLY-B",
|
||||
booking_id=later.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=200,
|
||||
completed_at=datetime(2048, 2, 1, tzinfo=UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
try:
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
with SessionLocal() as db:
|
||||
assert (
|
||||
db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_manual_scan_records_audit_event(ops_client):
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services import demo_manifest
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
|
||||
|
||||
def test_demo_manifest_is_public(client):
|
||||
@@ -52,3 +57,102 @@ def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "demoMode"
|
||||
|
||||
|
||||
def test_automation_scenario_requires_the_exact_prepared_failure():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == demo_manifest._FAILED_DEMO_EVENT_ID)
|
||||
)
|
||||
assert event is not None
|
||||
event.last_error_code = "connectionError"
|
||||
db.flush()
|
||||
|
||||
scenario = next(
|
||||
item for item in demo_manifest._scenarios(db) if item.id == "automation-retry"
|
||||
)
|
||||
assert scenario.ready is False
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_knowledge_scenario_rejects_available_provider_with_verified_empty_corpus(
|
||||
client, monkeypatch
|
||||
):
|
||||
class EmptyKnowledgeProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="Ready, but no indexed documents.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=0,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", EmptyKnowledgeProvider)
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
|
||||
assert scenario["ready"] is False
|
||||
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
|
||||
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "unavailable"
|
||||
|
||||
|
||||
def test_knowledge_scenario_rejects_local_sources_when_index_count_is_unverified(
|
||||
client, monkeypatch
|
||||
):
|
||||
class SourceAwareKnowledgeProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="Ready; index count endpoint is unavailable.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=None,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="not_reported",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", SourceAwareKnowledgeProvider)
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
|
||||
assert scenario["ready"] is False
|
||||
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
|
||||
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "unavailable"
|
||||
|
||||
|
||||
def test_knowledge_scenario_requires_provider_availability_even_with_documents():
|
||||
health = KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=False,
|
||||
detail="Provider is unreachable.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=4,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
assert demo_manifest._knowledge_scenario_ready(health) is False
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -65,12 +66,17 @@ def test_claim_marks_events_delivering():
|
||||
|
||||
def test_deliver_one_success(monkeypatch):
|
||||
event_id = _make_pending_event("MO-002")
|
||||
execution_id = "n8n-execution-123"
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
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": {"execution_id": execution_id},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
@@ -79,7 +85,7 @@ def test_deliver_one_success(monkeypatch):
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "succeeded"
|
||||
assert event.attempts == 1
|
||||
assert event.external_run_id == str(event_id)
|
||||
assert event.external_run_id == execution_id
|
||||
assert event.last_error is None
|
||||
assert event.last_error_code is None
|
||||
|
||||
@@ -129,6 +135,106 @@ def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
|
||||
assert event.last_error_code == "malformedResponse"
|
||||
|
||||
|
||||
def test_deliver_one_rejects_json_object_without_explicit_success_ack(monkeypatch):
|
||||
event_id = _make_pending_event("MO-010")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {},
|
||||
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.last_error_code == "malformedResponse"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [{}, {"execution_id": 123}, {"execution_id": " "}])
|
||||
def test_deliver_one_rejects_ack_without_valid_execution_id(monkeypatch, result):
|
||||
event_id = _make_pending_event("MO-EXEC-ID")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": result},
|
||||
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.last_error_code == "malformedResponse"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
def test_deliver_one_rejects_success_ack_for_a_different_event(monkeypatch):
|
||||
event_id = _make_pending_event("MO-011")
|
||||
dispatcher._claim_due_events()
|
||||
other_event_id = uuid.uuid4()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(other_event_id)},
|
||||
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.last_error_code == "mismatchedEventId"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
def test_late_delivery_outcome_cannot_overwrite_a_newer_lease(monkeypatch):
|
||||
event_id = _make_pending_event("MO-LEASE-RACE")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
# Simulate lease expiry/reclaim while the original worker is still in network
|
||||
# I/O. The later claimant owns a different token and its state must win.
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
|
||||
)
|
||||
event.payload_json = {
|
||||
**event.payload_json,
|
||||
"_delivery_claim_token": "newer-worker-token",
|
||||
}
|
||||
event.next_attempt_at = datetime.now(UTC) + timedelta(minutes=1)
|
||||
db.commit()
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"ok": True,
|
||||
"event_id": str(event_id),
|
||||
"result": {"execution_id": "stale-worker-execution"},
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "delivering"
|
||||
assert event.attempts == 0
|
||||
assert event.external_run_id is None
|
||||
assert event.payload_json["_delivery_claim_token"] == "newer-worker-token"
|
||||
|
||||
|
||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||
event_id = _make_pending_event("MO-004")
|
||||
settings = get_settings()
|
||||
@@ -139,7 +245,6 @@ def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
|
||||
for _ in range(settings.n8n_max_attempts):
|
||||
dispatcher._claim_due_events()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
@@ -147,6 +252,8 @@ def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
claimed = dispatcher._claim_due_events(batch_size=1000)
|
||||
assert event_id in claimed
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
@@ -259,7 +366,11 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
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": {"execution_id": "n8n-recovered-execution"},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
@@ -275,7 +386,11 @@ def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
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": {"execution_id": "n8n-cycle-execution"},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
|
||||
@@ -174,11 +174,12 @@ def test_return_idempotency_key_rejects_different_body(ops_client):
|
||||
|
||||
|
||||
def test_return_callback_rejects_malformed_correlation_id(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning", "correlation_id": "nope"},
|
||||
json={"event_id": event_id, "follow_up": "cleaning", "correlation_id": "nope"},
|
||||
headers={
|
||||
"Idempotency-Key": str(uuid.uuid4()),
|
||||
"Idempotency-Key": event_id,
|
||||
"X-Service-Token": get_settings().n8n_callback_token,
|
||||
},
|
||||
)
|
||||
@@ -195,10 +196,14 @@ def test_blocked_checkout_booking_can_be_cancelled(ops_client):
|
||||
json={
|
||||
"customer_ref": "CUS-0002",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services import integration_status
|
||||
|
||||
|
||||
def _reseed() -> None:
|
||||
@@ -108,6 +114,101 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
|
||||
assert body["state"] == "operational"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delivery_status", ["pending", "delivering"])
|
||||
def test_queued_work_without_any_success_is_not_reported_operational(delivery_status):
|
||||
_reseed()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for event in db.scalars(select(OutboxEvent)).all():
|
||||
event.delivery_status = delivery_status
|
||||
event.last_error = None
|
||||
event.last_error_code = None
|
||||
db.execute(
|
||||
delete(AuditEvent).where(
|
||||
AuditEvent.action.in_(
|
||||
{
|
||||
"data_quality_scan_run",
|
||||
"n8n_procedures_synced",
|
||||
"n8n_workflow_failure_registered",
|
||||
"n8n_workflow_heartbeat",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
|
||||
assert status.succeeded == 0
|
||||
assert getattr(status, delivery_status) > 0
|
||||
assert status.state == "no_evidence"
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_historical_success_is_not_green_when_webhook_is_unconfigured(monkeypatch):
|
||||
_reseed()
|
||||
with SessionLocal() as db:
|
||||
monkeypatch.setattr(integration_status.settings, "n8n_dispatch_enabled", True)
|
||||
monkeypatch.setattr(integration_status.settings, "n8n_webhook_url", "")
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
assert status.configured is False
|
||||
assert status.succeeded > 0
|
||||
assert status.state == "unavailable"
|
||||
|
||||
|
||||
def test_null_coded_real_failure_sets_latest_failure_timestamp():
|
||||
_reseed()
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
|
||||
)
|
||||
event.delivery_status = "failed"
|
||||
event.last_error_code = None
|
||||
db.flush()
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
assert status.unexpected_failed == 1
|
||||
assert status.latest_failure_at is not None
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_unreachable_hub_invalidates_historical_operational_claim(monkeypatch):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="service",
|
||||
actor_id=None,
|
||||
actor_label="itworx-mcp-hub",
|
||||
action="mcp_tool_request",
|
||||
entity_type="integration",
|
||||
entity_id=None,
|
||||
correlation_id=uuid.uuid4(),
|
||||
before_json=None,
|
||||
after_json=None,
|
||||
metadata_json={"tool": "operations_summary"},
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
monkeypatch.setattr(integration_status.settings, "mcp_hub_registration_enabled", True)
|
||||
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: False)
|
||||
|
||||
unreachable = integration_status.derive_mcp_hub_status(db)
|
||||
|
||||
assert unreachable.total_calls > 0
|
||||
assert unreachable.hub_reachable is False
|
||||
assert unreachable.state == "no_evidence"
|
||||
|
||||
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: True)
|
||||
reachable = integration_status.derive_mcp_hub_status(db)
|
||||
assert reachable.state == "operational"
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -12,19 +13,29 @@ def _callback_headers(event_id: str, token: str | None = None):
|
||||
|
||||
|
||||
def test_callback_rejects_wrong_service_token(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning"},
|
||||
headers=_callback_headers(str(uuid.uuid4()), token="wrong-token"),
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id, token="wrong-token"),
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_callback_unknown_event_returns_404(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning"},
|
||||
headers=_callback_headers(str(uuid.uuid4())),
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -39,13 +50,14 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
booking = db.scalar(select(Booking).limit(1))
|
||||
correlation_id = uuid.uuid4()
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
aggregate_type="booking",
|
||||
aggregate_id=booking.id,
|
||||
payload_json={
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"correlation_id": str(correlation_id),
|
||||
"aggregate": {
|
||||
"type": "booking",
|
||||
"id": str(booking.id),
|
||||
@@ -66,12 +78,22 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
|
||||
|
||||
first = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning", "summary": "test"},
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(correlation_id),
|
||||
"follow_up": "cleaning",
|
||||
"summary": "test",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning", "summary": "test"},
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(correlation_id),
|
||||
"follow_up": "cleaning",
|
||||
"summary": "test",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
@@ -82,6 +104,79 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
|
||||
).json()
|
||||
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["correlation_id"] == str(correlation_id)
|
||||
|
||||
|
||||
def test_callback_rejects_crossed_event_or_correlation(client):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(select(OutboxEvent).limit(1))
|
||||
event_id = str(event.event_id)
|
||||
correlation_id = event.payload_json["correlation_id"]
|
||||
|
||||
crossed_event = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"correlation_id": correlation_id,
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert crossed_event.status_code == 409
|
||||
assert crossed_event.json()["error"]["code"] == "CALLBACK_EVENT_MISMATCH"
|
||||
|
||||
crossed_correlation = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert crossed_correlation.status_code == 409
|
||||
assert crossed_correlation.json()["error"]["code"] == "CALLBACK_CORRELATION_MISMATCH"
|
||||
|
||||
|
||||
def test_callback_concurrent_retries_record_one_audit(client, ops_client):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(select(OutboxEvent).limit(1))
|
||||
event_id = str(event.event_id)
|
||||
correlation_id = event.payload_json["correlation_id"]
|
||||
body = {
|
||||
"event_id": event_id,
|
||||
"correlation_id": correlation_id,
|
||||
"follow_up": "cleaning",
|
||||
}
|
||||
|
||||
def post_callback(_index: int):
|
||||
return client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json=body,
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(pool.map(post_callback, range(2)))
|
||||
assert [response.status_code for response in responses] == [200, 200]
|
||||
matching = [
|
||||
event
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_return_followup_recorded"}
|
||||
).json()
|
||||
if event["metadata"]["event_id"] == event_id
|
||||
]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
def test_scheduled_scan_rejects_wrong_service_token(client):
|
||||
@@ -183,6 +278,42 @@ def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_
|
||||
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||
|
||||
|
||||
def test_workflow_error_preserves_correlation_and_rejects_malformed(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
execution_id = str(uuid.uuid4())
|
||||
correlation_id = str(uuid.uuid4())
|
||||
accepted = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(execution_id, correlation_id=correlation_id),
|
||||
headers=headers,
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
matching = [
|
||||
event
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||
).json()
|
||||
if event["metadata"]["execution_id"] == execution_id
|
||||
]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["correlation_id"] == correlation_id
|
||||
|
||||
malformed_execution = str(uuid.uuid4())
|
||||
malformed = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(malformed_execution, correlation_id="not-a-uuid"),
|
||||
headers=headers,
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
assert all(
|
||||
event["metadata"]["execution_id"] != malformed_execution
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||
).json()
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_error_bounds_summary_length(client):
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
@@ -266,6 +397,26 @@ def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||
assert ragcore_sync["last_seen_at"] is not None
|
||||
|
||||
|
||||
def test_failed_or_partial_procedure_sync_is_never_reported_healthy(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
for synced, failed in ((0, 33), (32, 1)):
|
||||
execution_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": execution_id, "synced": synced, "failed": failed},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
workflow = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
||||
assert workflow["state"] == "failed"
|
||||
assert workflow["last_status"] == "failed"
|
||||
assert workflow["last_execution_id"] == execution_id
|
||||
assert status["state"] == "degraded"
|
||||
|
||||
|
||||
def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client):
|
||||
settings = get_settings()
|
||||
execution_id = str(uuid.uuid4())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.api.routers import knowledge as knowledge_router
|
||||
from app.core.config import get_settings
|
||||
@@ -244,11 +247,11 @@ def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_c
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
def __init__(self, status_code: int, body: object):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
|
||||
def json(self) -> dict:
|
||||
def json(self) -> object:
|
||||
return self._body
|
||||
|
||||
|
||||
@@ -260,6 +263,7 @@ class _FakeClient:
|
||||
post_responses=None,
|
||||
raise_on=None,
|
||||
get_handler=None,
|
||||
requests=None,
|
||||
):
|
||||
self._get_response = get_response
|
||||
self._post_response = post_response
|
||||
@@ -270,6 +274,7 @@ class _FakeClient:
|
||||
self._post_responses = post_responses or {}
|
||||
self._raise_on = raise_on
|
||||
self._get_handler = get_handler
|
||||
self.requests = requests if requests is not None else []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -289,6 +294,7 @@ class _FakeClient:
|
||||
def post(self, path, json=None):
|
||||
if self._raise_on == "post":
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
self.requests.append((path, json))
|
||||
return self._post_responses.get(path, self._post_response)
|
||||
|
||||
|
||||
@@ -393,22 +399,107 @@ def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
|
||||
assert "unavailable" in health.detail.lower()
|
||||
|
||||
|
||||
def _answers_body(**overrides) -> dict:
|
||||
_EXCERPTS = {
|
||||
("en-GB", "damage-procedure"): (
|
||||
"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."
|
||||
),
|
||||
("en-GB", "vehicle-return-procedure"): (
|
||||
"Open the active booking and record the ending odometer, fuel level, cleanliness, "
|
||||
"visible damage, technical warnings and relevant notes."
|
||||
),
|
||||
("en-GB", "cleaning-checklist"): (
|
||||
"Cleaning completion alone does not make a blocked or maintenance vehicle "
|
||||
"available. Fleet Ops derives availability from all active restrictions."
|
||||
),
|
||||
("en-GB", "maintenance-escalation"): (
|
||||
"Escalate when a technical warning is reported, the service threshold is reached, "
|
||||
"a safety-related defect is observed or an existing maintenance block remains "
|
||||
"unresolved."
|
||||
),
|
||||
("nl-BE", "damage-procedure"): (
|
||||
"Wanneer een voertuig terugkomt met zichtbare of gemelde schade, markeer de schade "
|
||||
"in de retourinspectie, voeg een beknopte feitelijke beschrijving toe en houd het "
|
||||
"voertuig geblokkeerd."
|
||||
),
|
||||
("nl-BE", "vehicle-return-procedure"): (
|
||||
"Open de actieve boeking en registreer de eindkilometerstand, het brandstofniveau, "
|
||||
"de netheid, zichtbare schade, technische waarschuwingen en relevante notities."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _managed_document(provider: RAGcoreKnowledgeProvider, document_id: str, language: str):
|
||||
return next(
|
||||
document
|
||||
for document in provider._managed_documents_by_source_id.values()
|
||||
if document.document_id == document_id and document.language == language
|
||||
)
|
||||
|
||||
|
||||
def _citation(
|
||||
provider: RAGcoreKnowledgeProvider,
|
||||
document_id: str,
|
||||
*,
|
||||
language: str = "en-GB",
|
||||
section: str = "Procedure",
|
||||
seed: str = "1",
|
||||
) -> dict:
|
||||
document = _managed_document(provider, document_id, language)
|
||||
excerpt = _EXCERPTS[(language, document_id)]
|
||||
return {
|
||||
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"citation:{language}:{document_id}:{seed}")),
|
||||
# These are deliberately opaque RAGcore-owned UUIDs, not Fleet Ops' stable
|
||||
# human-readable procedure id.
|
||||
"document_id": str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, f"ragcore-document:{language}:{document_id}")
|
||||
),
|
||||
"document_version_id": str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
f"ragcore-document-version:{language}:{document_id}:{seed}",
|
||||
)
|
||||
),
|
||||
"title": "untrusted-provider-title.md",
|
||||
"section": section,
|
||||
"excerpt": excerpt,
|
||||
"excerpt_sha256": hashlib.sha256(excerpt.encode("utf-8")).hexdigest(),
|
||||
"source_uri": f"ragcore://source/{document.source_id}",
|
||||
"source_id": document.source_id,
|
||||
"locator": f"{document.document_id}.md",
|
||||
}
|
||||
|
||||
|
||||
def _answers_body(provider: RAGcoreKnowledgeProvider, **overrides) -> dict:
|
||||
citation = _citation(provider, "damage-procedure", section="Detection")
|
||||
answer = "Report damage and route the vehicle to maintenance."
|
||||
body = {
|
||||
"answer": "Report damage and route the vehicle to maintenance.",
|
||||
"answer_id": str(uuid.uuid4()),
|
||||
"retrieval_run_id": str(uuid.uuid4()),
|
||||
"answer": answer,
|
||||
"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.",
|
||||
}
|
||||
],
|
||||
"citations": [citation],
|
||||
"claims": [{"text": answer, "citation_ids": [citation["id"]]}],
|
||||
}
|
||||
body.update(overrides)
|
||||
if "claims" not in overrides:
|
||||
effective_answer = body.get("answer")
|
||||
effective_citations = body.get("citations")
|
||||
body["claims"] = (
|
||||
[
|
||||
{
|
||||
"text": effective_answer,
|
||||
"citation_ids": [item["id"] for item in effective_citations],
|
||||
}
|
||||
]
|
||||
if isinstance(effective_answer, str)
|
||||
and effective_answer
|
||||
and isinstance(effective_citations, list)
|
||||
and effective_citations
|
||||
and all(isinstance(item, dict) and "id" in item for item in effective_citations)
|
||||
else []
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
@@ -418,47 +509,178 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(provider))),
|
||||
)
|
||||
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.document_id == "damage-procedure"
|
||||
assert source.title == "Damage handling procedure"
|
||||
assert source.version == "version-1"
|
||||
assert source.version == "1.3"
|
||||
assert source.section == "Detection"
|
||||
assert source.excerpt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate",
|
||||
[
|
||||
lambda body: body.pop("claims"),
|
||||
lambda body: body.update(claims=[]),
|
||||
lambda body: body["claims"][0].update(citation_ids=[str(uuid.uuid4())]),
|
||||
lambda body: body.pop("answer_id"),
|
||||
lambda body: body.update(retrieval_run_id="not-a-uuid"),
|
||||
],
|
||||
)
|
||||
def test_ragcore_answer_requires_valid_claim_citation_contract(monkeypatch, mutate):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
body = _answers_body(provider)
|
||||
mutate(body)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, body)),
|
||||
)
|
||||
|
||||
answer = provider.ask("What must I do about damage?", "test-invalid-claim-contract")
|
||||
|
||||
assert answer.evidence_state != "grounded"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_requests_are_scoped_to_managed_sources_for_requested_language(
|
||||
monkeypatch,
|
||||
):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
requests = []
|
||||
fake_client = _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")),
|
||||
},
|
||||
requests=requests,
|
||||
)
|
||||
monkeypatch.setattr(provider, "_client", lambda: fake_client)
|
||||
|
||||
answer = provider.ask(
|
||||
"Wat is de procedure voor een voertuigretour?",
|
||||
"language-filter",
|
||||
"nl-BE",
|
||||
)
|
||||
|
||||
assert answer.evidence_state == "grounded"
|
||||
assert [path for path, _payload in requests] == ["/v1/answers", "/v1/search"]
|
||||
expected_source_ids = provider._managed_source_ids("nl-BE")
|
||||
assert len(expected_source_ids) == 11
|
||||
assert all(uuid.UUID(source_id) for source_id in expected_source_ids)
|
||||
for _path, payload in requests:
|
||||
assert payload["filters"] == {"source_ids": expected_source_ids}
|
||||
assert payload["requested_space_ids"] == ["space-1"]
|
||||
|
||||
|
||||
def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
citations = []
|
||||
for index in range(5):
|
||||
document_ids = [
|
||||
"damage-procedure",
|
||||
"damage-procedure",
|
||||
"vehicle-return-procedure",
|
||||
"cleaning-checklist",
|
||||
"maintenance-escalation",
|
||||
]
|
||||
for index, document_id in enumerate(document_ids):
|
||||
citations.append(
|
||||
{
|
||||
"id": f"cite-{index}",
|
||||
"document_id": f"doc-{index}",
|
||||
"document_version_id": f"version-{index}",
|
||||
"title": "Damage procedure" if index < 2 else f"Procedure {index}",
|
||||
"section": "Return",
|
||||
"excerpt": (
|
||||
f"Record visible damage before release, chunk {index}."
|
||||
if index < 2
|
||||
else f"Unique procedure evidence {index}."
|
||||
),
|
||||
}
|
||||
_citation(
|
||||
provider,
|
||||
document_id,
|
||||
section="Return",
|
||||
seed=str(index),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))),
|
||||
lambda: _FakeClient(
|
||||
post_response=_FakeResponse(200, _answers_body(provider, citations=citations))
|
||||
),
|
||||
)
|
||||
answer = provider.ask("What must I do about vehicle damage?", "dedupe-test")
|
||||
assert len(answer.sources) == 3
|
||||
assert sum(source.title == "Damage procedure" for source in answer.sources) == 1
|
||||
assert sum(source.title == "Damage handling procedure" for source in answer.sources) == 1
|
||||
|
||||
|
||||
def test_ragcore_answer_rejects_unknown_document_citation(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_response=_FakeResponse(
|
||||
200,
|
||||
_answers_body(
|
||||
provider,
|
||||
answer="A plausible-looking but unmanaged answer.",
|
||||
citations=[
|
||||
{
|
||||
**_citation(provider, "damage-procedure"),
|
||||
"source_id": str(uuid.uuid4()),
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask("What must I do about damage?", "unknown-answer-citation")
|
||||
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "bad_value"),
|
||||
[
|
||||
("id", "not-a-uuid"),
|
||||
("document_id", "not-a-uuid"),
|
||||
("document_version_id", "00000000-0000-0000-0000-000000000000"),
|
||||
("source_id", "00000000-0000-4000-8000-000000000099"),
|
||||
("source_uri", "ragcore://source/00000000-0000-4000-8000-000000000099"),
|
||||
("locator", "another-procedure.md"),
|
||||
("excerpt_sha256", "0" * 64),
|
||||
("title", None),
|
||||
("section", {"not": "a string"}),
|
||||
],
|
||||
)
|
||||
def test_ragcore_rejects_broken_managed_source_provenance(field_name, bad_value):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
citation = _citation(provider, "damage-procedure")
|
||||
citation[field_name] = bad_value
|
||||
|
||||
assert provider._managed_source(citation, "en-GB") is None
|
||||
|
||||
|
||||
def test_ragcore_rejects_fabricated_excerpt_even_with_matching_hash():
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
citation = _citation(provider, "damage-procedure")
|
||||
fabricated = "Invent a repair price and promise it to the customer."
|
||||
citation["excerpt"] = fabricated
|
||||
citation["excerpt_sha256"] = hashlib.sha256(fabricated.encode("utf-8")).hexdigest()
|
||||
|
||||
assert provider._managed_source(citation, "en-GB") is None
|
||||
|
||||
|
||||
def test_ragcore_never_relabels_an_english_source_as_dutch():
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
citation = _citation(provider, "damage-procedure", language="en-GB")
|
||||
|
||||
assert provider._managed_source(citation, "nl-BE") is None
|
||||
|
||||
|
||||
def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client):
|
||||
@@ -503,6 +725,7 @@ def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(mo
|
||||
post_response=_FakeResponse(
|
||||
200,
|
||||
_answers_body(
|
||||
provider,
|
||||
answer="This should never be shown.",
|
||||
answerability="not_answerable",
|
||||
citations=[],
|
||||
@@ -524,7 +747,8 @@ def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypat
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_response=_FakeResponse(
|
||||
200, _answers_body(answerability="answerable", citations=[])
|
||||
200,
|
||||
_answers_body(provider, answerability="answerable", citations=[]),
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -565,19 +789,66 @@ def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||
assert answer.evidence_state == "unavailable"
|
||||
|
||||
|
||||
def _search_body(**overrides) -> dict:
|
||||
def test_ragcore_provider_malformed_top_level_bodies_do_not_escape(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(200, ["not", "an", "object"]),
|
||||
"/v1/search": _FakeResponse(200, None),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask("Anything?", "test-correlation-malformed-top-level")
|
||||
|
||||
assert answer.evidence_state == "unavailable"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_answer_with_malformed_citation_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(provider, citations=[None]),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask("What must I do about damage?", "malformed-answer-citation")
|
||||
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def _search_body(
|
||||
provider: RAGcoreKnowledgeProvider,
|
||||
language: str = "en-GB",
|
||||
**overrides,
|
||||
) -> dict:
|
||||
body = {
|
||||
"retrieval_run_id": str(uuid.uuid4()),
|
||||
"effective_space_ids": ["00000000-0000-4000-8000-000000000001"],
|
||||
"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.",
|
||||
},
|
||||
"chunk_id": str(uuid.uuid4()),
|
||||
"text": _EXCERPTS[(language, "vehicle-return-procedure")],
|
||||
"citation": _citation(
|
||||
provider,
|
||||
"vehicle-return-procedure",
|
||||
language=language,
|
||||
section="Return",
|
||||
),
|
||||
"rank": 1,
|
||||
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
|
||||
}
|
||||
@@ -588,6 +859,31 @@ def _search_body(**overrides) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
def test_ragcore_search_ignores_malformed_result_and_citation_bodies(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
malformed_search = _search_body(
|
||||
provider,
|
||||
results=[None, {"citation": ["not-an-object"], "scores": "not-an-object"}],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, malformed_search),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask("What is the vehicle return procedure?", "malformed-search-items")
|
||||
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
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
|
||||
@@ -601,26 +897,24 @@ def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypa
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||
"/v1/search": _FakeResponse(200, _search_body()),
|
||||
"/v1/search": _FakeResponse(200, _search_body(provider)),
|
||||
}
|
||||
),
|
||||
)
|
||||
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 "Open the active booking and record the ending odometer" 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."
|
||||
)
|
||||
assert answer.sources[0].excerpt == _EXCERPTS[("en-GB", "vehicle-return-procedure")]
|
||||
|
||||
|
||||
def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0)
|
||||
search_response = _FakeResponse(200, _search_body())
|
||||
search_response = _FakeResponse(200, _search_body(provider))
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
@@ -632,8 +926,7 @@ def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
|
||||
),
|
||||
)
|
||||
assert (
|
||||
provider.ask("What is the vehicle return procedure?", "first").evidence_state
|
||||
== "grounded"
|
||||
provider.ask("What is the vehicle return procedure?", "first").evidence_state == "grounded"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -654,7 +947,7 @@ def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||
"/v1/search": _FakeResponse(200, _search_body()),
|
||||
"/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -676,7 +969,7 @@ def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkey
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
|
||||
"/v1/search": _FakeResponse(200, _search_body(results=[])),
|
||||
"/v1/search": _FakeResponse(200, _search_body(provider, results=[])),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -695,7 +988,7 @@ def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, _search_body()),
|
||||
"/v1/search": _FakeResponse(200, _search_body(provider)),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -707,19 +1000,66 @@ def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
|
||||
assert answer.answer == ""
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_rejects_unknown_document_citation(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body(provider)
|
||||
search["results"][0]["citation"]["source_id"] = str(uuid.uuid4())
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask(
|
||||
"What is the vehicle return procedure?",
|
||||
"unknown-search-citation",
|
||||
)
|
||||
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_rejects_vehicle_colour_despite_high_score(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body(provider)
|
||||
search["results"][0]["scores"]["fused"] = 1.0
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
answer = provider.ask("What colour is this vehicle?", "vehicle-colour")
|
||||
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search = _search_body(provider, "nl-BE", results=[])
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"citation": _citation(
|
||||
provider,
|
||||
"damage-procedure",
|
||||
language="nl-BE",
|
||||
section="Damage",
|
||||
),
|
||||
"rank": 2,
|
||||
"scores": {"fused": 0.01},
|
||||
}
|
||||
@@ -743,16 +1083,15 @@ def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
|
||||
def test_ragcore_search_fallback_accepts_top_ranked_ragcore_damage_evidence(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search = _search_body(provider, "nl-BE", results=[])
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"citation": _citation(
|
||||
provider,
|
||||
"damage-procedure",
|
||||
language="nl-BE",
|
||||
section="Damage",
|
||||
),
|
||||
"rank": 1,
|
||||
# RAGcore uses reciprocal-rank fusion; a genuine rank-one result is about
|
||||
# 1 / (60 + 1), not a normalized 0..1 relevance score.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
MCP_ROUTE_ALLOWLIST = {
|
||||
("GET", "/api/v1/integrations/mcp/operations-summary"),
|
||||
("GET", "/api/v1/integrations/mcp/attention-vehicles"),
|
||||
("GET", "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"),
|
||||
("POST", "/api/v1/integrations/mcp/search-knowledge"),
|
||||
}
|
||||
|
||||
|
||||
def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
|
||||
@@ -154,3 +162,26 @@ def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
||||
]:
|
||||
response = getattr(client, method)(path, headers=_headers())
|
||||
assert response.status_code in (404, 405)
|
||||
|
||||
|
||||
def test_mcp_namespace_matches_exact_route_allowlist_and_rejects_lookalikes(client):
|
||||
implemented = {
|
||||
(method.upper(), path)
|
||||
for path, operations in app.openapi()["paths"].items()
|
||||
if path.startswith("/api/v1/integrations/mcp/")
|
||||
for method in operations
|
||||
if method in {"get", "post", "put", "patch", "delete"}
|
||||
}
|
||||
assert implemented == MCP_ROUTE_ALLOWLIST
|
||||
|
||||
lookalikes = [
|
||||
("get", "/api/v1/integrations/mcp-extra/operations-summary"),
|
||||
("get", "/api/v1/integrations/mcp/operations-summary-extra"),
|
||||
("get", "/api/v1/integrations/mcp/prefix/attention-vehicles"),
|
||||
("get", "/api/v1/integrations/mcp/attention-vehicles/suffix"),
|
||||
("post", "/api/v1/integrations/mcp/search-knowledge-extra"),
|
||||
("post", "/api/v1/integrations/mcp/prefix/search-knowledge"),
|
||||
]
|
||||
for method, path in lookalikes:
|
||||
response = client.request(method, path, headers=_headers(), json={})
|
||||
assert response.status_code == 404, path
|
||||
|
||||
@@ -63,6 +63,24 @@ def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_c
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "anonymized"
|
||||
search = ops_client.get("/api/v1/customers", params={"query": "CUS-PRIVACY"})
|
||||
assert search.status_code == 200
|
||||
assert search.json() == []
|
||||
window = {
|
||||
"starts_at": "2090-01-01T10:00:00Z",
|
||||
"ends_at": "2090-01-02T10:00:00Z",
|
||||
}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
assert available
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-PRIVACY",
|
||||
"vehicle_ref": available[0]["public_ref"],
|
||||
**window,
|
||||
},
|
||||
)
|
||||
assert booking.status_code == 422
|
||||
with SessionLocal() as db:
|
||||
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
|
||||
assert customer is not None
|
||||
|
||||
@@ -128,6 +128,23 @@ def test_seed_scenario_s4_booking_overlap():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_odometer_issues_expose_only_the_regressing_booking_as_correctable():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-0007")
|
||||
assert issue is not None
|
||||
assert issue.evidence_json["source_type"] == "return"
|
||||
assert issue.evidence_json["related_refs"] == [
|
||||
"INSP-0057",
|
||||
"BK-H-0007",
|
||||
"INSP-0007",
|
||||
]
|
||||
assert issue.evidence_json["correctable_booking_refs"] == ["BK-H-0007"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s5_failed_workflow_run():
|
||||
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
|
||||
pending, so the background dispatcher never silently auto-heals it away."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = next(
|
||||
parent
|
||||
for parent in Path(__file__).resolve().parents
|
||||
if (parent / "seed" / "generate_seed.py").is_file()
|
||||
)
|
||||
SEED_DIRECTORY = REPOSITORY_ROOT / "seed"
|
||||
|
||||
|
||||
def _rows_by_ref(path: Path) -> dict[str, dict[str, str]]:
|
||||
with path.open(newline="", encoding="utf-8") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
assert all(None not in row for row in rows), f"malformed CSV row in {path.name}"
|
||||
return {row["public_ref"]: row for row in rows}
|
||||
|
||||
|
||||
def test_generator_reproduces_committed_seed_snapshot_byte_for_byte(tmp_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_DIRECTORY / "generate_seed.py"),
|
||||
"--anchor",
|
||||
"2026-08-01",
|
||||
"--seed",
|
||||
"20260801",
|
||||
"--out",
|
||||
str(tmp_path),
|
||||
],
|
||||
cwd=REPOSITORY_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
committed_files = sorted(SEED_DIRECTORY.glob("*.csv"))
|
||||
generated_files = sorted(tmp_path.glob("*.csv"))
|
||||
assert [path.name for path in generated_files] == [path.name for path in committed_files]
|
||||
for committed in committed_files:
|
||||
generated = tmp_path / committed.name
|
||||
assert generated.read_bytes() == committed.read_bytes(), (
|
||||
f"{committed.name} differs from the deterministic generated snapshot"
|
||||
)
|
||||
|
||||
bookings = _rows_by_ref(tmp_path / "bookings.csv")
|
||||
inspections = _rows_by_ref(tmp_path / "inspections.csv")
|
||||
issues = _rows_by_ref(tmp_path / "data_quality_issues.csv")
|
||||
|
||||
assert int(bookings["BK-H-0007"]["end_odometer_km"]) < int(
|
||||
bookings["BK-H-0057"]["end_odometer_km"]
|
||||
)
|
||||
assert int(bookings["BK-H-0010"]["end_odometer_km"]) < int(
|
||||
bookings["BK-H-0060"]["end_odometer_km"]
|
||||
)
|
||||
assert inspections["INSP-0007"]["odometer_km"] == bookings["BK-H-0007"]["end_odometer_km"]
|
||||
assert inspections["INSP-0010"]["odometer_km"] == bookings["BK-H-0010"]["end_odometer_km"]
|
||||
assert inspections["INSP-0001"]["completed_at"] == bookings["BK-H-0001"]["ends_at"]
|
||||
|
||||
expected_issue_refs = {f"DQ-{index:04d}" for index in range(5, 22)}
|
||||
assert expected_issue_refs <= issues.keys()
|
||||
assert all(
|
||||
issues[public_ref]["evidence"] != "Synthetic deterministic seed issue"
|
||||
for public_ref in expected_issue_refs
|
||||
)
|
||||
assert "INSP-0007" in issues["DQ-0007"]["evidence"]
|
||||
assert "INSP-0057" in issues["DQ-0007"]["evidence"]
|
||||
assert "INSP-0010" in issues["DQ-0010"]["evidence"]
|
||||
assert "INSP-0060" in issues["DQ-0010"]["evidence"]
|
||||
@@ -23,3 +23,22 @@ def test_manager_can_create_and_update_user(ops_client):
|
||||
|
||||
def test_employee_cannot_manage_users(employee_client):
|
||||
assert employee_client.get("/api/v1/users").status_code == 403
|
||||
assert (
|
||||
employee_client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"email": "unauthorised@example.test",
|
||||
"display_name": "Unauthorised User",
|
||||
"role": "rental_employee",
|
||||
"password": "a-secure-demo-password",
|
||||
},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
employee_client.patch(
|
||||
"/api/v1/users/USR-OPS",
|
||||
json={"display_name": "Unauthorised Change"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
@@ -46,6 +46,12 @@ def test_vehicle_detail_includes_related_records(ops_client):
|
||||
assert len(body["quality_issues"]) >= 1
|
||||
|
||||
|
||||
def test_employee_vehicle_detail_never_exposes_quality_evidence(employee_client):
|
||||
response = employee_client.get("/api/v1/vehicles/MO-016")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["quality_issues"] == []
|
||||
|
||||
|
||||
def test_vehicle_detail_404_for_unknown_ref(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles/MO-999")
|
||||
assert response.status_code == 404
|
||||
@@ -78,6 +84,53 @@ def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
|
||||
assert released.json()["operational_status"] == "available"
|
||||
|
||||
|
||||
def test_low_maintenance_reading_keeps_canonical_and_opens_quality_issue(ops_client):
|
||||
existing_issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
|
||||
vehicles = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()
|
||||
vehicle = next(
|
||||
candidate
|
||||
for candidate in vehicles
|
||||
if candidate["odometer_km"] > 0 and candidate["public_ref"] not in unavailable_refs
|
||||
)
|
||||
|
||||
response = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
|
||||
json={
|
||||
"occurred_at": "2053-01-15T12:00:00Z",
|
||||
"odometer_km": vehicle["odometer_km"] - 1,
|
||||
"category": "inspection",
|
||||
"summary": "Imported workshop reading requires verification.",
|
||||
"mark_maintenance": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert detail["odometer_km"] == vehicle["odometer_km"]
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
|
||||
assert issue["evidence"]["source_type"] == "maintenance"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
issue_detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
assert any(
|
||||
snapshot["entity_type"] == "maintenance"
|
||||
and snapshot["public_ref"] == response.json()["public_ref"]
|
||||
for snapshot in issue_detail["related_snapshots"]
|
||||
)
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
|
||||
|
||||
def test_employee_cannot_record_maintenance(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/vehicles/MO-001/maintenance",
|
||||
|
||||
@@ -107,6 +107,8 @@ services:
|
||||
N8N_BASIC_AUTH_PASSWORD: ${N8N_BASIC_AUTH_PASSWORD:-change-me}
|
||||
N8N_SECURE_COOKIE: "false"
|
||||
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
|
||||
MOBILITYOPS_ALERT_RECIPIENT: ${MOBILITYOPS_ALERT_RECIPIENT:-alerts@example.test}
|
||||
MOBILITYOPS_ALERT_SENDER: ${MOBILITYOPS_ALERT_SENDER:-n8n@example.test}
|
||||
MOBILITYOPS_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||
ports:
|
||||
- "5678:5678"
|
||||
|
||||
@@ -2757,10 +2757,6 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Ends At
|
||||
requirements_complete:
|
||||
type: boolean
|
||||
title: Requirements Complete
|
||||
default: false
|
||||
type: object
|
||||
required:
|
||||
- customer_ref
|
||||
@@ -4189,11 +4185,13 @@ components:
|
||||
title: ResolveOverlapRequest
|
||||
ReturnCallbackIn:
|
||||
properties:
|
||||
event_id:
|
||||
type: string
|
||||
format: uuid
|
||||
title: Event Id
|
||||
correlation_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: uuid
|
||||
- type: 'null'
|
||||
type: string
|
||||
format: uuid
|
||||
title: Correlation Id
|
||||
follow_up:
|
||||
anyOf:
|
||||
@@ -4208,6 +4206,9 @@ components:
|
||||
- type: 'null'
|
||||
title: Summary
|
||||
type: object
|
||||
required:
|
||||
- event_id
|
||||
- correlation_id
|
||||
title: ReturnCallbackIn
|
||||
description: 'Body of the n8n return follow-up callback.
|
||||
|
||||
@@ -4777,6 +4778,7 @@ components:
|
||||
correlation_id:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: uuid
|
||||
- type: 'null'
|
||||
title: Correlation Id
|
||||
attempt:
|
||||
|
||||
@@ -1,68 +1,85 @@
|
||||
# Unraid deployment
|
||||
|
||||
MobilityOps is deployed from a committed source archive; the server does not need Gitea
|
||||
credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`; of the
|
||||
MobilityOps-owned services, only the web application is reachable from the LAN. The host's
|
||||
existing shared n8n remains available on its established port 5678.
|
||||
MobilityOps is deployed from a committed source archive; the server does not need Gitea credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`. Of the MobilityOps-owned services, only the web application should be reachable from the trusted network. PostgreSQL and the API remain on the Compose network.
|
||||
|
||||
All hostnames, ports, paths and public domains below are operator-controlled configuration. Do not commit a production `.env`, SSH target or infrastructure inventory.
|
||||
|
||||
## Server layout
|
||||
|
||||
- Directory: `/mnt/user/appdata/mobilityops`
|
||||
- Compose project: `mobilityops`
|
||||
- Public web: `https://fleetops.itworx.tech` (TLS reverse proxy to host port `1236`)
|
||||
- API and PostgreSQL: Compose network only
|
||||
- Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project)
|
||||
Define a local appdata root and URLs before following the examples:
|
||||
|
||||
```bash
|
||||
export MOBILITYOPS_APPDATA_DIR=/mnt/user/appdata/mobilityops
|
||||
export MOBILITYOPS_PUBLIC_URL=https://fleetops.example.com
|
||||
export N8N_BASE_URL=https://automation.example.com
|
||||
```
|
||||
|
||||
The `/mnt/user/appdata/...` value is an illustrative Unraid convention, not a repository requirement. Use a different persistent directory where appropriate.
|
||||
|
||||
- application directory: `${MOBILITYOPS_APPDATA_DIR}`;
|
||||
- Compose project: `mobilityops`;
|
||||
- public web URL: `${MOBILITYOPS_PUBLIC_URL}`;
|
||||
- API and PostgreSQL: Compose network only;
|
||||
- shared n8n: `${N8N_BASE_URL}`, outside the MobilityOps Compose project.
|
||||
|
||||
## Configure
|
||||
|
||||
Create `.env` from `.env.example`, replace every placeholder secret and set:
|
||||
|
||||
```dotenv
|
||||
MOBILITYOPS_ENV=production
|
||||
MOBILITYOPS_PUBLIC_URL=https://fleetops.example.com
|
||||
MOBILITYOPS_API_PUBLIC_URL=https://fleetops.example.com/api
|
||||
N8N_WEBHOOK_URL=https://automation.example.com/webhook/mobilityops-return
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
Configure `KNOWLEDGE_PROVIDER=ragcore` only after the RAGcore health and source-inventory checks pass. When RAGcore runs as a separate Compose project on the same host, keep its HTTP listener private and configure Fleet Ops through a shared Docker network, for example:
|
||||
|
||||
```dotenv
|
||||
RAGCORE_BASE_URL=http://ragcore-app:8080
|
||||
RAGCORE_DOCKER_NETWORK=ragcore-proxy
|
||||
```
|
||||
|
||||
The release deployer takes non-secret integration URLs from the server `.env` on every promotion. Tokens and resolved API environment remain server-side and must not be printed.
|
||||
|
||||
## Deploy
|
||||
|
||||
Create `.env` from `.env.example`, replace every placeholder secret, set
|
||||
`MOBILITYOPS_ENV=production`, set both public URLs to
|
||||
`https://fleetops.itworx.tech`, set `SESSION_COOKIE_SECURE=true`, and configure
|
||||
`KNOWLEDGE_PROVIDER=ragcore` only after the RAGcore health and source inventory checks pass.
|
||||
The internal `1236` listener is
|
||||
an upstream for the TLS proxy, not a user-facing URL.
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
cd "${MOBILITYOPS_APPDATA_DIR}"
|
||||
./deploy/unraid/configure-env.sh \
|
||||
https://fleetops.itworx.tech \
|
||||
https://n8n.itworx.tech/webhook/mobilityops-return
|
||||
"${MOBILITYOPS_PUBLIC_URL}" \
|
||||
"${N8N_BASE_URL}/webhook/mobilityops-return"
|
||||
|
||||
git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
|
||||
sha256sum /tmp/mobilityops-source.tar.gz
|
||||
# Copy the archive and run deploy-release.sh with its SHA-256 and full Git SHA.
|
||||
```
|
||||
|
||||
`deploy-release.sh` stages a clean, commit-named release, builds OCI-labelled immutable
|
||||
API/web images, starts two candidate replicas per stateless service behind a stable gateway,
|
||||
and removes the previous replicas only after public readiness passes. It promotes without a
|
||||
seed/reset and leaves the database, backups and monitoring untouched during routine app
|
||||
releases. Run `python -m app.cli seed --reset` only for initial setup or
|
||||
an explicit synthetic-demo reset; it is never part of a routine deployment.
|
||||
`deploy-release.sh` stages a clean, commit-named release, builds OCI-labelled immutable API/web images, starts candidate replicas behind a stable gateway and removes previous replicas only after public readiness passes. It promotes without a seed/reset and leaves the database, backups and monitoring untouched during routine releases.
|
||||
|
||||
Refresh the pinned stateful and monitoring containers explicitly after reviewing their
|
||||
version/configuration changes:
|
||||
Run `python -m app.cli seed --reset` only for initial setup or an explicit synthetic-demo reset; it is never part of a routine deployment.
|
||||
|
||||
Refresh pinned stateful and monitoring containers explicitly after reviewing version and configuration changes:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/refresh-infrastructure.sh
|
||||
```
|
||||
|
||||
Migrations run automatically in the API entrypoint. Import and publish the MobilityOps
|
||||
workflow into the existing n8n container:
|
||||
Import and publish the MobilityOps workflow into an existing n8n container:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/setup-existing-n8n.sh \
|
||||
n8n \
|
||||
https://fleetops.itworx.tech/api/v1/integrations/n8n/return-callback
|
||||
"${MOBILITYOPS_PUBLIC_URL}/api/v1/integrations/n8n/return-callback"
|
||||
```
|
||||
|
||||
The callback token remains server-side and is never written to the repository. The
|
||||
bundled n8n service is retained only as a standalone fallback behind the explicit
|
||||
`bundled-n8n` Compose profile; it is not started in this deployment.
|
||||
The callback token remains server-side and is never written to the repository. The bundled n8n service is a standalone fallback behind the explicit `bundled-n8n` Compose profile and is not started by the standard deployment.
|
||||
|
||||
## Operate
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/mobilityops
|
||||
cd "${MOBILITYOPS_APPDATA_DIR}"
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
|
||||
@@ -71,29 +88,18 @@ docker logs --tail=200 n8n
|
||||
|
||||
## Backup and restore
|
||||
|
||||
The `backup` service creates a backup immediately and then every 24 hours. Every dump is
|
||||
checked by SHA-256 and `pg_restore --list`; at least weekly the newest dump is also restored
|
||||
into a disposable database and its migration revision and core table counts are verified.
|
||||
Backups are retained for 30 days with at least seven copies protected from pruning. Its
|
||||
healthcheck becomes unhealthy when the daily backup or eight-day restore-drill SLA is missed.
|
||||
The `backup` service creates a backup immediately and every 24 hours. Each dump is checked by SHA-256 and `pg_restore --list`; at least weekly the newest dump is restored into a disposable database and its migration revision and core table counts are verified. Backups are retained for 30 days with at least seven copies protected from pruning. Health becomes unhealthy when the daily backup or eight-day restore-drill SLA is missed.
|
||||
|
||||
For this synthetic demo, OneDrive is the supported real off-site target. The optional worker
|
||||
uploads the newest locally verified dump and checksum, downloads both again, verifies the
|
||||
round trip, and performs a weekly restore into a disposable database. Its OAuth token remains
|
||||
in the untracked mode-0600 rclone configuration directory. Configure it once from an
|
||||
interactive server terminal (Microsoft browser authorization is required):
|
||||
For a synthetic demo, an operator-configured rclone remote may be used as an off-site target. OAuth material belongs in an untracked, mode-0600 rclone configuration directory:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/configure-onedrive-backup.sh onedrive
|
||||
./deploy/unraid/configure-onedrive-backup.sh <rclone-remote>
|
||||
docker inspect --format '{{.State.Health.Status}}' mobilityops-offsite-backup-1
|
||||
```
|
||||
|
||||
Set `RCLONE_ONEDRIVE_PATH` to change the default `FleetOps/backups` folder. Do not set
|
||||
`BACKUP_SECONDARY_DESTINATION` to another path on the same server and call that off-site;
|
||||
that legacy mount remains available only for an independently mounted filesystem.
|
||||
Do not point `BACKUP_SECONDARY_DESTINATION` to another directory on the same storage system and describe it as off-site.
|
||||
|
||||
Create an additional on-demand backup, verify the newest backup, or execute the isolated
|
||||
restore drill:
|
||||
Create an on-demand backup, verify the newest backup or execute an isolated restore drill:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/backup-postgres.sh
|
||||
@@ -102,8 +108,7 @@ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T bac
|
||||
/opt/mobilityops/restore-drill-postgres.sh /backups/<backup>.dump
|
||||
```
|
||||
|
||||
A restore is deliberately guarded and creates an additional safety backup before
|
||||
replacing the database:
|
||||
A restore is guarded and creates an additional safety backup before replacing the database:
|
||||
|
||||
```bash
|
||||
./deploy/unraid/restore-postgres.sh \
|
||||
@@ -111,6 +116,4 @@ replacing the database:
|
||||
RESTORE-MOBILITYOPS
|
||||
```
|
||||
|
||||
The restore stops the API, recreates only the configured MobilityOps database, restarts
|
||||
API/web and verifies the active Alembic revision. Test restores in a disposable environment
|
||||
before using a production backup for incident recovery.
|
||||
Test restores in a disposable environment before using a production backup for incident recovery.
|
||||
|
||||
@@ -20,7 +20,8 @@ docker build --build-arg "VCS_REF=$revision" --tag "$image" \
|
||||
echo "Configure Microsoft OneDrive as remote '$remote'; OAuth opens in your browser."
|
||||
docker run --rm -it --entrypoint rclone -v "$config_dir:/config/rclone" "$image" config
|
||||
chmod 600 "$config_dir/rclone.conf"
|
||||
docker run --rm --entrypoint rclone -v "$config_dir:/config/rclone" "$image" lsd "$remote:"
|
||||
docker run --rm --entrypoint rclone -v "$config_dir:/config/rclone" "$image" \
|
||||
--config /config/rclone/rclone.conf lsd "$remote:"
|
||||
|
||||
history="$(tail -n 1 "$root/.deploy/release-history.log")"
|
||||
# shellcheck disable=SC2086
|
||||
|
||||
@@ -15,6 +15,33 @@ case "$revision" in *[!0-9a-f]*) echo "Revision must be lowercase hexadecimal" >
|
||||
[ -n "$expected_checksum" ] || { echo "Expected SHA-256 is required" >&2; exit 1; }
|
||||
[ -f "$root/.env" ] || { echo "Production .env is missing" >&2; exit 1; }
|
||||
|
||||
read_env_value() {
|
||||
key="$1"
|
||||
awk -v key="$key" '
|
||||
index($0, key "=") == 1 {
|
||||
sub("^[^=]*=", "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$root/.env"
|
||||
}
|
||||
|
||||
# Stateless replicas normally inherit the complete, already-secret-resolved environment
|
||||
# from the serving API. RAGcore routing is an explicit exception: its private endpoint and
|
||||
# cross-project Docker network are non-secret deployment topology and the server .env is
|
||||
# authoritative so infrastructure changes take effect on the next release.
|
||||
ragcore_base_url="$(read_env_value RAGCORE_BASE_URL)"
|
||||
ragcore_network="$(read_env_value RAGCORE_DOCKER_NETWORK)"
|
||||
case "$ragcore_base_url" in
|
||||
http://*|https://*) ;;
|
||||
*) echo "RAGCORE_BASE_URL must be an absolute HTTP(S) URL" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$ragcore_network" in
|
||||
"") ;;
|
||||
*[!a-zA-Z0-9_.-]*) echo "RAGCORE_DOCKER_NETWORK contains invalid characters" >&2; exit 1 ;;
|
||||
*) docker network inspect "$ragcore_network" > /dev/null ;;
|
||||
esac
|
||||
|
||||
actual_checksum="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
[ "$actual_checksum" = "$expected_checksum" ] || {
|
||||
echo "Archive checksum mismatch" >&2
|
||||
@@ -123,7 +150,13 @@ for replica in 1 2; do
|
||||
--label "com.mobilityops.revision=$revision" \
|
||||
--network "$network" --network-alias api --network-alias "api-$short_revision" \
|
||||
--env-file "$api_environment" \
|
||||
--env RUN_MIGRATIONS=false "$api_image")"
|
||||
--env RUN_MIGRATIONS=false --env "RAGCORE_BASE_URL=$ragcore_base_url" "$api_image")"
|
||||
if [ -n "$ragcore_network" ] && ! docker network connect "$ragcore_network" "$id"; then
|
||||
# shellcheck disable=SC2086
|
||||
docker rm -f $new_api_ids "$id" > /dev/null 2>&1 || true
|
||||
echo "Could not attach API candidates to RAGcore network $ragcore_network" >&2
|
||||
exit 1
|
||||
fi
|
||||
new_api_ids="$new_api_ids $id"
|
||||
done
|
||||
|
||||
|
||||
@@ -32,13 +32,26 @@ updates the record and its evidence but leaves the issue open.
|
||||
|
||||
## DQ-03 Odometer regression
|
||||
|
||||
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
|
||||
Flag a newly recorded checkout, return-inspection or maintenance reading below the
|
||||
canonical odometer. The deterministic import scan compares booking, inspection and
|
||||
maintenance readings in chronological order. A return inspection's actual completion
|
||||
time is authoritative over the booking's planned end, so an early return cannot be
|
||||
mistaken for a later regression. Never lower the canonical value automatically.
|
||||
|
||||
When another regression is recorded while the vehicle already has an open DQ-03 issue,
|
||||
append the new source reference and signal to that issue. Do not silently discard the
|
||||
new evidence and do not open parallel issues for the same vehicle.
|
||||
|
||||
Resolution: `POST /resolve-odometer-regression` offers exactly two bounded decisions —
|
||||
`retain_canonical` (the submitted reading is treated as erroneous; canonical is
|
||||
untouched) or `correct_reading` (updates a named related booking's reading and the
|
||||
vehicle's canonical odometer together). A `correct_reading` value below the current
|
||||
canonical is rejected, since it would not resolve the regression, not silently applied.
|
||||
untouched) or `correct_reading`. The correction option is offered only for an explicitly
|
||||
named, correctable booking reading; checkout-only and maintenance-only issues can only
|
||||
retain the canonical value. A corrected return updates the booking, its related return
|
||||
inspection and the vehicle's canonical odometer in one transaction. A
|
||||
`correct_reading` value below the current canonical is rejected, since it would not
|
||||
resolve the regression, not silently applied. When one open issue contains several
|
||||
source readings, correcting one removes only that booking/inspection evidence and keeps
|
||||
the issue open until every remaining signal has received a bounded decision.
|
||||
|
||||
## DQ-04 Booking overlap
|
||||
|
||||
@@ -63,10 +76,11 @@ function to confirm the conflict is actually gone before resolving.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open — the CSV
|
||||
seed rows don't carry a stable evidence fingerprint, so the literal
|
||||
`(..., evidence fingerprint)` scheme from an earlier draft of this rule was dropped as
|
||||
unworkable for seeded data; re-implementing it would need to reconcile with that. Resolved
|
||||
issues remain historical. Reintroduced evidence creates a new issue whose evidence carries
|
||||
`reopened_from` (the prior issue's reference) and `previous_decision` (its resolved
|
||||
status), so a repeat problem is never presented as if no decision was ever made.
|
||||
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open. Source-aware
|
||||
rules additionally store stable evidence identity. For DQ-03 this is the tuple
|
||||
`(source_type, later_ref, later_km)`: an explicit `retain_canonical` decision suppresses
|
||||
only that exact fact on later scans. A changed reading or a different source is actionable
|
||||
again. Resolved issues remain historical. Reintroduced evidence creates a new issue whose
|
||||
evidence carries `reopened_from` (the prior issue's reference) and `previous_decision`
|
||||
(its resolved status), so a repeat problem is never presented as if no decision was ever
|
||||
made.
|
||||
|
||||