diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
index b02896d..fe278bc 100644
--- a/.github/workflows/sonarqube.yml
+++ b/.github/workflows/sonarqube.yml
@@ -48,6 +48,9 @@ jobs:
node --check "$f" || exit 1
done
+ - name: Run frontend collision and manifest checks
+ run: npm run check:frontend
+
- name: Build the frontend bundle
run: node scripts/minify.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eaa44bc..ebff20e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
+- **CI frontend gate.** The manifest-driven JavaScript syntax and top-level declaration collision check now runs in the
+ SonarQube quality workflow, alongside Python compilation, regressions, and the production bundle build.
+- **Structured request correlation.** API and MCP requests now receive a bounded `X-Request-ID` and emit sanitized
+ method/path/status/duration logs without recording request bodies or secrets.
- **Audiobook parser extraction.** Dialogue detection, PDF dehyphenation, and shared speaker-heuristic constants now
live in `audiobook-parse.js`, loaded before the Audiobook casting workflow without changing its legacy API.
- **Rehearser voice-picker extraction.** Voice preview state and matched voice-choice presentation now live in
diff --git a/TODO.md b/TODO.md
index bc1fe3e..0c145c7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -89,6 +89,10 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
bridge, and section-scoped lazy loading are complete foundations; responsibility-level
extraction and the Audiobook/Rehearser/style split remain.
+- [x] **M — Add structured logs with request/job IDs.** API/MCP requests now receive sanitized correlation IDs and
+ duration logs; route-specific logs retain provider/job context without request bodies.
+- [x] **L — Add linting and duplicate-function checks.** The manifest-driven frontend syntax/declaration gate now runs
+ locally and in the SonarQube quality workflow alongside the existing Python and bundle checks.
### P2 — after behavior and safety stabilize
diff --git a/VERSION b/VERSION
index 3823fa5..f3d13e4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.24.27
+1.24.29
diff --git a/server.py b/server.py
index 2896390..83250d9 100644
--- a/server.py
+++ b/server.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os
import time
+import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
@@ -141,6 +142,54 @@ class MutationRateLimitMiddleware:
app.add_middleware(MutationRateLimitMiddleware)
+# ── Request/job correlation logging ──────────────────────────────────────────
+# Keep request diagnostics useful without recording request bodies. Route-level
+# logs add provider/job details where appropriate; this middleware supplies one
+# stable correlation ID and timing envelope for every API/MCP request.
+class RequestCorrelationMiddleware:
+ def __init__(self, app):
+ self.app = app
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+ path = scope.get("path", "")
+ if not (path.startswith("/api/") or path == "/mcp"):
+ await self.app(scope, receive, send)
+ return
+ request_id = next((value.decode("latin-1") for key, value in scope.get("headers", [])
+ if key.lower() == b"x-request-id"), "")
+ if not request_id or len(request_id) > 80 or any(ord(ch) < 33 or ord(ch) > 126 for ch in request_id):
+ request_id = uuid.uuid4().hex
+ started = time.perf_counter()
+ status = 500
+ sent = False
+
+ async def send_wrapper(message):
+ nonlocal status, sent
+ if message["type"] == "http.response.start":
+ status = int(message.get("status", 500))
+ headers = MutableHeaders(scope=message)
+ headers["X-Request-ID"] = request_id
+ if message["type"] == "http.response.body" and not message.get("more_body", False) and not sent:
+ sent = True
+ elapsed_ms = (time.perf_counter() - started) * 1000
+ logger.info("request id=%s method=%s path=%s status=%s duration_ms=%.1f",
+ request_id, scope.get("method", ""), path, status, elapsed_ms)
+ await send(message)
+
+ try:
+ await self.app(scope, receive, send_wrapper)
+ except Exception:
+ if not sent:
+ logger.info("request id=%s method=%s path=%s status=500 duration_ms=%.1f",
+ request_id, scope.get("method", ""), path,
+ (time.perf_counter() - started) * 1000)
+ raise
+
+app.add_middleware(RequestCorrelationMiddleware)
+
# ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler()
diff --git a/static/index.html b/static/index.html
index 1f954fe..895ab2a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,7 +27,7 @@
-
+
@@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) {
-
+